Skip to content
Open
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
87 changes: 81 additions & 6 deletions codex-rs/cli/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use clap::Args;
use clap::CommandFactory;
use clap::Parser;
use clap::ValueHint;
use clap_complete::Shell;
use clap_complete::generate;
use codex_arg0::arg0_dispatch_or_else;
Expand Down Expand Up @@ -37,6 +38,8 @@ use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::features::is_known_feature_key;

const RESUME_LAST_PROMPT_SENTINEL: &str = "__codex_resume_last_flag__";

/// Codex CLI
///
/// If no subcommand is specified, options will be forwarded to the interactive CLI.
Expand Down Expand Up @@ -135,9 +138,17 @@ struct ResumeCommand {
#[arg(value_name = "SESSION_ID")]
session_id: Option<String>,

/// Continue the most recent session without showing the picker.
#[arg(long = "last", default_value_t = false, conflicts_with = "session_id")]
last: bool,
/// Continue the most recent session without showing the picker. Optionally provide a prompt
/// right after the flag (e.g. `--last <PROMPT>`).
#[arg(
long = "last",
value_name = "PROMPT",
value_hint = ValueHint::Other,
num_args = 0..=1,
default_missing_value = RESUME_LAST_PROMPT_SENTINEL,
conflicts_with = "session_id"
)]
last: Option<String>,

/// Show all sessions (disables cwd filtering and shows CWD column).
#[arg(long = "all", default_value_t = false)]
Expand Down Expand Up @@ -641,21 +652,32 @@ fn finalize_resume_interactive(
mut interactive: TuiCli,
root_config_overrides: CliConfigOverrides,
session_id: Option<String>,
last: bool,
last: Option<String>,
show_all: bool,
resume_cli: TuiCli,
) -> TuiCli {
// Start with the parsed interactive CLI so resume shares the same
// configuration surface area as `codex` without additional flags.
let resume_session_id = session_id;
interactive.resume_picker = resume_session_id.is_none() && !last;
interactive.resume_last = last;
let resume_last = last.is_some();
let last_prompt = last
.as_deref()
.filter(|value| *value != RESUME_LAST_PROMPT_SENTINEL)
.map(str::to_owned);
interactive.resume_picker = resume_session_id.is_none() && !resume_last;
interactive.resume_last = resume_last;
interactive.resume_session_id = resume_session_id;
interactive.resume_show_all = show_all;

// Merge resume-scoped flags and overrides with highest precedence.
merge_resume_cli_flags(&mut interactive, resume_cli);

if interactive.prompt.is_none()
&& let Some(prompt) = last_prompt
{
interactive.prompt = Some(prompt);
}

// Propagate any root-level config overrides (e.g. `-c key=value`).
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);

Expand Down Expand Up @@ -828,6 +850,59 @@ mod tests {
assert!(!interactive.resume_show_all);
}

#[test]
fn resume_last_accepts_prompt_after_flag() {
let interactive = finalize_from_args(["codex", "resume", "--last", "next steps"].as_ref());
assert!(interactive.resume_last);
assert_eq!(interactive.prompt.as_deref(), Some("next steps"));
}

#[test]
fn resume_last_rejects_uuid_with_last_flag() {
// Interactive mode uses conflicts_with, so clap should reject this combination
let result = MultitoolCli::try_parse_from([
"codex",
"resume",
"123e4567-e89b-12d3-a456-426614174000",
"--last",
]);
assert!(result.is_err(), "Should reject UUID with --last flag");
let err_msg = result.unwrap_err().to_string();
assert!(
err_msg.contains("cannot be used") || err_msg.contains("conflicts"),
"Error message should mention conflict: {err_msg}"
);
}

#[test]
fn resume_last_rejects_uuid_with_last_flag_and_prompt() {
// Test UUID with --last and prompt value
let result = MultitoolCli::try_parse_from([
"codex",
"resume",
"123e4567-e89b-12d3-a456-426614174000",
"--last",
"prompt",
]);
assert!(
result.is_err(),
"Should reject UUID with --last flag even with prompt"
);
}

#[test]
fn resume_prompt_before_last_flag_parsed_as_session_id() {
// When prompt comes before --last, clap parses it as session_id
// But since it's not a UUID and conflicts_with is set, it should still error
// This tests the edge case mentioned in the issue comment
let result = MultitoolCli::try_parse_from(["codex", "resume", "2+2", "--last"]);
// With conflicts_with, clap will reject this because session_id is set
assert!(
result.is_err(),
"Should reject when positional before --last conflicts"
);
}

#[test]
fn resume_picker_logic_with_session_id() {
let interactive = finalize_from_args(["codex", "resume", "1234"].as_ref());
Expand Down
26 changes: 24 additions & 2 deletions codex-rs/exec/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ use clap::ValueEnum;
use codex_common::CliConfigOverrides;
use std::path::PathBuf;

const RESUME_LAST_PROMPT_SENTINEL: &str = "__codex_resume_last_flag__";

#[derive(Parser, Debug)]
#[command(version)]
pub struct Cli {
Expand Down Expand Up @@ -101,8 +103,16 @@ pub struct ResumeArgs {
pub session_id: Option<String>,

/// Resume the most recent recorded session (newest) without specifying an id.
#[arg(long = "last", default_value_t = false)]
pub last: bool,
/// Optionally accepts a prompt directly after the flag (e.g. `--last <PROMPT>`).
#[arg(
long = "last",
value_name = "PROMPT",
value_hint = clap::ValueHint::Other,
num_args = 0..=1,
default_missing_value = "__codex_resume_last_flag__",
conflicts_with = "session_id"
)]
pub last: Option<String>,

/// Prompt to send after resuming the session. If `-` is used, read from stdin.
#[arg(value_name = "PROMPT", value_hint = clap::ValueHint::Other)]
Expand All @@ -117,3 +127,15 @@ pub enum Color {
#[default]
Auto,
}

impl ResumeArgs {
pub fn resume_last(&self) -> bool {
self.last.is_some()
}

pub fn last_prompt(&self) -> Option<&str> {
self.last
.as_deref()
.filter(|value| *value != RESUME_LAST_PROMPT_SENTINEL)
}
}
19 changes: 9 additions & 10 deletions codex-rs/exec/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,15 +87,7 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
let resume_prompt = args
.prompt
.clone()
// When using `resume --last <PROMPT>`, clap still parses the first positional
// as `session_id`. Reinterpret it as the prompt so the flag works with JSON mode.
.or_else(|| {
if args.last {
args.session_id.clone()
} else {
None
}
});
.or_else(|| args.last_prompt().map(str::to_owned));
resume_prompt.or(prompt)
}
None => prompt,
Expand Down Expand Up @@ -336,7 +328,12 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
conversation_manager
.resume_conversation_from_rollout(config.clone(), path, auth_manager.clone())
.await?
} else if let Some(id_str) = args.session_id.as_deref() {
// If a session_id was explicitly provided but not found, error out.
eprintln!("No saved session found with ID {id_str}.");
std::process::exit(1);
} else {
// No session_id provided and --last didn't find anything, start fresh.
conversation_manager
.new_conversation(config.clone())
.await?
Expand Down Expand Up @@ -452,7 +449,9 @@ async fn resolve_resume_path(
config: &Config,
args: &crate::cli::ResumeArgs,
) -> anyhow::Result<Option<PathBuf>> {
if args.last {
if args.resume_last() {
// If --last is present, use it to find the most recent session.
// With conflicts_with, session_id cannot be present when --last is set.
let default_provider_filter = vec![config.model_provider_id.clone()];
match codex_core::RolloutRecorder::list_conversations(
&config.codex_home,
Expand Down
1 change: 1 addition & 0 deletions codex-rs/exec/tests/suite/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ mod auth_env;
mod originator;
mod output_schema;
mod resume;
mod resume_verification;
mod sandbox;
mod server_error_exit;
Loading
Loading