diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 49f4a148a8..8b9cbd51b6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -773,6 +773,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", ] [[package]] diff --git a/codex-rs/README.md b/codex-rs/README.md index 46eda63a1e..4869aab7b3 100644 --- a/codex-rs/README.md +++ b/codex-rs/README.md @@ -55,6 +55,14 @@ In the transcript preview, the footer shows an `Esc edit prev` hint while editin Sometimes it is not convenient to `cd` to the directory you want Codex to use as the "working root" before running Codex. Fortunately, `codex` supports a `--cd` option so you can specify whatever folder you want. You can confirm that Codex is honoring `--cd` by double-checking the **workdir** it reports in the TUI at the start of a new session. +### Resuming sessions + +When you use `codex resume`, provide any follow-up prompt *before* an optional session id. This keeps combinations like `codex resume --last "fix the tests"` working while still letting you resume a specific session when needed: + +- `codex resume --last "kick off linting"` — resume the most recent session and immediately send a new prompt. +- `codex resume "draft release notes" d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a specific session and send a follow-up prompt. +- `codex resume d9b7b8b8-3a1f-4a4d-b0a2-4f04bb8d58df` — resume a session without sending a prompt (the CLI treats lone UUIDs as session ids). + ### Shell completions Generate shell completion scripts via: diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index c62a267c73..5c4dff7562 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -36,6 +36,7 @@ ctor = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } +uuid = { workspace = true } tokio = { workspace = true, features = [ "io-std", "macros", diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index b99577d092..ae94823d0c 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,5 +1,7 @@ use clap::CommandFactory; use clap::Parser; +use clap::error::Error as ClapError; +use clap::error::ErrorKind as ClapErrorKind; use clap_complete::Shell; use clap_complete::generate; use codex_arg0::arg0_dispatch_or_else; @@ -22,6 +24,7 @@ use codex_tui::Cli as TuiCli; use owo_colors::OwoColorize; use std::path::PathBuf; use supports_color::Stream; +use uuid::Uuid; mod mcp_cmd; @@ -112,17 +115,17 @@ struct CompletionCommand { #[derive(Debug, Parser)] struct ResumeCommand { - /// Conversation/session id (UUID). When provided, resumes this session. - /// If omitted, use --last to pick the most recent recorded session. - #[arg(value_name = "SESSION_ID")] - session_id: Option, - - /// Continue the most recent session without showing the picker. - #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] - last: bool, - #[clap(flatten)] config_overrides: TuiCli, + + /// Continue the most recent session without showing the picker. + #[arg(long = "last", default_value_t = false)] + last: bool, + + /// Conversation/session id (UUID). When provided, resumes this session. + /// If omitted, use --last to pick the most recent recorded session. + #[arg(value_name = "SESSION_ID", index = 2)] + session_id: Option, } #[derive(Debug, Parser)] @@ -286,11 +289,15 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() Some(Subcommand::AppServer) => { codex_app_server::run_main(codex_linux_sandbox_exe, root_config_overrides).await?; } - Some(Subcommand::Resume(ResumeCommand { - session_id, - last, - config_overrides, - })) => { + Some(Subcommand::Resume(mut resume_cmd)) => { + if let Err(err) = resume_cmd.normalize() { + err.exit(); + } + let ResumeCommand { + config_overrides, + last, + session_id, + } = resume_cmd; interactive = finalize_resume_interactive( interactive, root_config_overrides.clone(), @@ -491,14 +498,16 @@ mod tests { subcommand, } = cli; - let Subcommand::Resume(ResumeCommand { - session_id, - last, - config_overrides: resume_cli, - }) = subcommand.expect("resume present") - else { - unreachable!() + let mut resume_cmd = match subcommand.expect("resume present") { + Subcommand::Resume(cmd) => cmd, + _ => unreachable!(), }; + resume_cmd.normalize().expect("normalize resume args"); + let ResumeCommand { + config_overrides: resume_cli, + last, + session_id, + } = resume_cmd; finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli) } @@ -575,12 +584,45 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } + #[test] + fn resume_last_accepts_follow_up_prompt() { + let interactive = finalize_from_args(["codex", "resume", "--last", "hi there"].as_ref()); + assert!(interactive.resume_last); + assert_eq!(interactive.prompt.as_deref(), Some("hi there")); + assert_eq!(interactive.resume_session_id, None); + } + + #[test] + fn resume_prompt_before_session_id() { + let interactive = finalize_from_args( + [ + "codex", + "resume", + "summarize progress", + "123e4567-e89b-12d3-a456-426614174000", + ] + .as_ref(), + ); + assert_eq!(interactive.prompt.as_deref(), Some("summarize progress")); + assert_eq!( + interactive.resume_session_id.as_deref(), + Some("123e4567-e89b-12d3-a456-426614174000"), + ); + assert!(!interactive.resume_last); + assert!(!interactive.resume_picker); + } + #[test] fn resume_picker_logic_with_session_id() { - let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref()); + let interactive = finalize_from_args( + ["codex", "resume", "123e4567-e89b-12d3-a456-426614174000"].as_ref(), + ); assert!(!interactive.resume_picker); assert!(!interactive.resume_last); - assert_eq!(interactive.resume_session_id.as_deref(), Some("1234")); + assert_eq!( + interactive.resume_session_id.as_deref(), + Some("123e4567-e89b-12d3-a456-426614174000") + ); } #[test] @@ -589,7 +631,7 @@ mod tests { [ "codex", "resume", - "sid", + "123e4567-e89b-12d3-a456-426614174000", "--oss", "--full-auto", "--search", @@ -637,7 +679,10 @@ mod tests { assert!(has_a && has_b); assert!(!interactive.resume_picker); assert!(!interactive.resume_last); - assert_eq!(interactive.resume_session_id.as_deref(), Some("sid")); + assert_eq!( + interactive.resume_session_id.as_deref(), + Some("123e4567-e89b-12d3-a456-426614174000") + ); } #[test] @@ -656,3 +701,45 @@ mod tests { assert_eq!(interactive.resume_session_id, None); } } + +impl ResumeCommand { + fn normalize(&mut self) -> Result<(), ClapError> { + if self.last { + if let Some(value) = self.session_id.take() { + if Self::looks_like_session_id(&value) { + return Err(ClapError::raw( + ClapErrorKind::ArgumentConflict, + "The argument '--last' cannot be used with '[SESSION_ID]'", + )); + } + if let Some(existing) = &mut self.config_overrides.prompt { + if !existing.is_empty() { + existing.push(' '); + } + existing.push_str(&value); + } else { + self.config_overrides.prompt = Some(value); + } + } + return Ok(()); + } + + if self.session_id.is_some() { + return Ok(()); + } + + if let Some(prompt) = self.config_overrides.prompt.take() { + if Self::looks_like_session_id(&prompt) { + self.session_id = Some(prompt); + } else { + self.config_overrides.prompt = Some(prompt); + } + } + + Ok(()) + } + + fn looks_like_session_id(value: &str) -> bool { + Uuid::parse_str(value).is_ok() + } +} diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 27ae067c92..36bfbe1ee4 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -30,6 +30,7 @@ codex-protocol = { workspace = true } owo-colors = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +uuid = { workspace = true } shlex = { workspace = true } tokio = { workspace = true, features = [ "io-std", diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 0df114cb00..dc48daa96a 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -1,7 +1,10 @@ use clap::Parser; use clap::ValueEnum; +use clap::error::Error as ClapError; +use clap::error::ErrorKind as ClapErrorKind; use codex_common::CliConfigOverrides; use std::path::PathBuf; +use uuid::Uuid; #[derive(Parser, Debug)] #[command(version)] @@ -100,18 +103,59 @@ pub enum Command { #[derive(Parser, Debug)] pub struct ResumeArgs { - /// Conversation/session id (UUID). When provided, resumes this session. - /// If omitted, use --last to pick the most recent recorded session. - #[arg(value_name = "SESSION_ID")] - pub session_id: Option, + /// Prompt to send after resuming the session. If `-` is used, read from stdin. + #[arg(value_name = "PROMPT", index = 1)] + pub prompt: Option, /// Resume the most recent recorded session (newest) without specifying an id. - #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] + #[arg(long = "last", default_value_t = false)] pub last: bool, - /// Prompt to send after resuming the session. If `-` is used, read from stdin. - #[arg(value_name = "PROMPT")] - pub prompt: Option, + /// Conversation/session id (UUID). When provided, resumes this session. + /// If omitted, use --last to pick the most recent recorded session. + #[arg(value_name = "SESSION_ID", index = 2)] + pub session_id: Option, +} + +impl ResumeArgs { + pub fn normalize(&mut self) -> Result<(), ClapError> { + if self.last { + if let Some(value) = self.session_id.take() { + if Self::looks_like_session_id(&value) { + return Err(ClapError::raw( + ClapErrorKind::ArgumentConflict, + "The argument '--last' cannot be used with '[SESSION_ID]'", + )); + } + if let Some(existing) = &mut self.prompt { + if !existing.is_empty() { + existing.push(' '); + } + existing.push_str(&value); + } else { + self.prompt = Some(value); + } + } + return Ok(()); + } + + if self.session_id.is_some() { + return Ok(()); + } + + if let Some(value) = self.prompt.take() { + if Self::looks_like_session_id(&value) { + self.session_id = Some(value); + } else { + self.prompt = Some(value); + } + } + Ok(()) + } + + fn looks_like_session_id(value: &str) -> bool { + Uuid::parse_str(value).is_ok() + } } #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index bdf0365026..b755021dfb 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -61,18 +61,24 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any json: json_mode, experimental_json, sandbox_mode: sandbox_mode_cli_arg, - prompt, + prompt: parent_prompt, output_schema: output_schema_path, include_plan_tool, config_overrides, } = cli; // Determine the prompt source (parent or subcommand) and read from stdin if needed. - let prompt_arg = match &command { + let mut command = command; + let prompt_arg = match &mut command { // Allow prompt before the subcommand by falling back to the parent-level prompt // when the Resume subcommand did not provide its own prompt. - Some(ExecCommand::Resume(args)) => args.prompt.clone().or(prompt), - None => prompt, + Some(ExecCommand::Resume(args)) => { + if let Err(err) = args.normalize() { + err.exit(); + } + args.prompt.clone().or_else(|| parent_prompt.clone()) + } + None => parent_prompt, }; let prompt = match prompt_arg { diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs index 4c4d343ee3..f3e3ab5b0e 100644 --- a/codex-rs/exec/tests/suite/resume.rs +++ b/codex-rs/exec/tests/suite/resume.rs @@ -130,6 +130,62 @@ fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { Ok(()) } +#[test] +fn exec_resume_last_accepts_prompt_after_flag() -> anyhow::Result<()> { + let home = TempDir::new()?; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/cli_responses_fixture.sse"); + + let marker = format!("resume-last-flag-{}", Uuid::new_v4()); + let prompt = format!("echo {marker}"); + + Command::cargo_bin("codex-exec") + .context("should find binary for codex-exec")? + .env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .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 sessions_dir = home.path().join("sessions"); + let path = find_session_file_containing_marker(&sessions_dir, &marker) + .expect("no session file found after first run"); + + let marker2 = format!("resume-last-flag-2-{}", Uuid::new_v4()); + let prompt2 = format!("echo {marker2}"); + + Command::cargo_bin("codex-exec") + .context("should find binary for codex-exec")? + .env("CODEX_HOME", home.path()) + .env("OPENAI_API_KEY", "dummy") + .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(&prompt2) + .assert() + .success(); + + let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) + .expect("no resumed session file containing marker2"); + assert_eq!( + resumed_path, path, + "resume --last should reuse the existing file", + ); + let content = std::fs::read_to_string(&resumed_path)?; + assert!(content.contains(&marker)); + assert!(content.contains(&marker2)); + Ok(()) +} + #[test] fn exec_resume_by_id_appends_to_existing_file() -> anyhow::Result<()> { let home = TempDir::new()?; diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index f0630a34c5..04329a934d 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -7,7 +7,7 @@ use std::path::PathBuf; #[command(version)] pub struct Cli { /// Optional user prompt to start the session. - #[arg(value_name = "PROMPT")] + #[arg(value_name = "PROMPT", index = 1)] pub prompt: Option, /// Optional image(s) to attach to the initial prompt.