From a30e5e40ee440d7122e7340fae5a56e7d013bc49 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Sun, 14 Sep 2025 19:33:19 -0400 Subject: [PATCH] enable-resume (#3537) Adding the ability to resume conversations. we have one verb `resume`. Behavior: `tui`: `codex resume`: opens session picker `codex resume --last`: continue last message `codex resume `: continue conversation with `session id` `exec`: `codex resume --last`: continue last conversation `codex resume `: continue conversation with `session id` Implementation: - I added a function to find the path in `~/.codex/sessions/` with a `UUID`. This is helpful in resuming with session id. - Added the above mentioned flags - Added lots of testing --- codex-rs/Cargo.lock | 3 + codex-rs/cli/src/main.rs | 84 +++++- codex-rs/core/Cargo.toml | 1 + codex-rs/core/src/config.rs | 14 - codex-rs/core/src/conversation_manager.rs | 20 +- codex-rs/core/src/lib.rs | 1 + codex-rs/core/src/rollout/list.rs | 49 ++++ codex-rs/core/src/rollout/mod.rs | 1 + codex-rs/core/src/rollout/recorder.rs | 3 +- codex-rs/core/tests/suite/cli_stream.rs | 12 +- codex-rs/core/tests/suite/client.rs | 7 +- codex-rs/core/tests/suite/mod.rs | 1 + codex-rs/core/tests/suite/review.rs | 38 ++- .../core/tests/suite/rollout_list_find.rs | 50 ++++ codex-rs/exec/Cargo.toml | 2 + codex-rs/exec/src/cli.rs | 26 ++ codex-rs/exec/src/lib.rs | 54 +++- .../tests/fixtures/cli_responses_fixture.sse | 10 + codex-rs/exec/tests/suite/mod.rs | 1 + codex-rs/exec/tests/suite/resume.rs | 267 ++++++++++++++++++ codex-rs/tui/src/cli.rs | 39 +-- codex-rs/tui/src/lib.rs | 14 +- docs/advanced.md | 34 +++ docs/getting-started.md | 19 +- 24 files changed, 647 insertions(+), 103 deletions(-) create mode 100644 codex-rs/core/tests/suite/rollout_list_find.rs create mode 100644 codex-rs/exec/tests/fixtures/cli_responses_fixture.sse create mode 100644 codex-rs/exec/tests/suite/resume.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 37767c0dd4..1e8b7a3485 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -666,6 +666,7 @@ dependencies = [ "bytes", "chrono", "codex-apply-patch", + "codex-file-search", "codex-mcp-client", "codex-protocol", "core_test_support", @@ -733,6 +734,8 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "uuid", + "walkdir", "wiremock", ] diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2acc3d84c5..1f13405f10 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -73,6 +73,9 @@ enum Subcommand { #[clap(visible_alias = "a")] Apply(ApplyCommand), + /// Resume a previous interactive session (picker by default; use --last to continue the most recent). + Resume(ResumeCommand), + /// Internal: generate TypeScript protocol bindings. #[clap(hide = true)] GenerateTs(GenerateTsCommand), @@ -85,6 +88,18 @@ struct CompletionCommand { shell: Shell, } +#[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, +} + #[derive(Debug, Parser)] struct DebugArgs { #[command(subcommand)] @@ -143,26 +158,54 @@ fn main() -> anyhow::Result<()> { } async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { - let cli = MultitoolCli::parse(); + let MultitoolCli { + config_overrides: root_config_overrides, + mut interactive, + subcommand, + } = MultitoolCli::parse(); - match cli.subcommand { + match subcommand { None => { - let mut tui_cli = cli.interactive; - prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides); - let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?; + prepend_config_flags( + &mut interactive.config_overrides, + root_config_overrides.clone(), + ); + let usage = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; if !usage.is_zero() { println!("{}", codex_core::protocol::FinalOutput::from(usage)); } } Some(Subcommand::Exec(mut exec_cli)) => { - prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut exec_cli.config_overrides, + root_config_overrides.clone(), + ); codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } Some(Subcommand::Mcp) => { - codex_mcp_server::run_main(codex_linux_sandbox_exe, cli.config_overrides).await?; + codex_mcp_server::run_main(codex_linux_sandbox_exe, root_config_overrides.clone()) + .await?; + } + Some(Subcommand::Resume(ResumeCommand { session_id, last })) => { + // 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; + interactive.resume_session_id = resume_session_id; + + // Propagate any root-level config overrides (e.g. `-c key=value`). + prepend_config_flags( + &mut interactive.config_overrides, + root_config_overrides.clone(), + ); + codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; } Some(Subcommand::Login(mut login_cli)) => { - prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut login_cli.config_overrides, + root_config_overrides.clone(), + ); match login_cli.action { Some(LoginSubcommand::Status) => { run_login_status(login_cli.config_overrides).await; @@ -177,11 +220,17 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } } Some(Subcommand::Logout(mut logout_cli)) => { - prepend_config_flags(&mut logout_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut logout_cli.config_overrides, + root_config_overrides.clone(), + ); run_logout(logout_cli.config_overrides).await; } Some(Subcommand::Proto(mut proto_cli)) => { - prepend_config_flags(&mut proto_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut proto_cli.config_overrides, + root_config_overrides.clone(), + ); proto::run_main(proto_cli).await?; } Some(Subcommand::Completion(completion_cli)) => { @@ -189,7 +238,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } Some(Subcommand::Debug(debug_args)) => match debug_args.cmd { DebugCommand::Seatbelt(mut seatbelt_cli) => { - prepend_config_flags(&mut seatbelt_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut seatbelt_cli.config_overrides, + root_config_overrides.clone(), + ); codex_cli::debug_sandbox::run_command_under_seatbelt( seatbelt_cli, codex_linux_sandbox_exe, @@ -197,7 +249,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() .await?; } DebugCommand::Landlock(mut landlock_cli) => { - prepend_config_flags(&mut landlock_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut landlock_cli.config_overrides, + root_config_overrides.clone(), + ); codex_cli::debug_sandbox::run_command_under_landlock( landlock_cli, codex_linux_sandbox_exe, @@ -206,7 +261,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() } }, Some(Subcommand::Apply(mut apply_cli)) => { - prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides); + prepend_config_flags( + &mut apply_cli.config_overrides, + root_config_overrides.clone(), + ); run_apply_command(apply_cli, None).await?; } Some(Subcommand::GenerateTs(gen_cli)) => { diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 34c7ba554a..4fb10affb3 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -19,6 +19,7 @@ base64 = "0.22" bytes = "1.10.1" chrono = { version = "0.4", features = ["serde"] } codex-apply-patch = { path = "../apply-patch" } +codex-file-search = { path = "../file-search" } codex-mcp-client = { path = "../mcp-client" } codex-protocol = { path = "../protocol" } dirs = "6" diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 937b0e8eab..1b54f8b9e3 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -161,9 +161,6 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, - /// Experimental rollout resume path (absolute path to .jsonl; undocumented). - pub experimental_resume: Option, - /// Include an experimental plan tool that the model can use to update its current plan and status of each step. pub include_plan_tool: bool, @@ -603,9 +600,6 @@ pub struct ConfigToml { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: Option, - /// Experimental rollout resume path (absolute path to .jsonl; undocumented). - pub experimental_resume: Option, - /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. pub experimental_instructions_file: Option, @@ -892,8 +886,6 @@ impl Config { .and_then(|info| info.auto_compact_token_limit) }); - let experimental_resume = cfg.experimental_resume; - // Load base instructions override from a file if specified. If the // path is relative, resolve it against the effective cwd so the // behaviour matches other path-like config values. @@ -954,8 +946,6 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), - - experimental_resume, include_plan_tool: include_plan_tool.unwrap_or(false), include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false), tools_web_search_request, @@ -1481,7 +1471,6 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_resume: None, base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, @@ -1539,7 +1528,6 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_resume: None, base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, @@ -1612,7 +1600,6 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::default(), model_verbosity: None, chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_resume: None, base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, @@ -1671,7 +1658,6 @@ model_verbosity = "high" model_reasoning_summary: ReasoningSummary::Detailed, model_verbosity: Some(Verbosity::High), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), - experimental_resume: None, base_instructions: None, include_plan_tool: false, include_apply_patch_tool: false, diff --git a/codex-rs/core/src/conversation_manager.rs b/codex-rs/core/src/conversation_manager.rs index 3c02e0ebfa..3ca361a38a 100644 --- a/codex-rs/core/src/conversation_manager.rs +++ b/codex-rs/core/src/conversation_manager.rs @@ -59,21 +59,11 @@ impl ConversationManager { config: Config, auth_manager: Arc, ) -> CodexResult { - // TO BE REFACTORED: use the config experimental_resume field until we have a mainstream way. - if let Some(resume_path) = config.experimental_resume.as_ref() { - let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?; - let CodexSpawnOk { - codex, - conversation_id, - } = Codex::spawn(config, auth_manager, initial_history).await?; - self.finalize_spawn(codex, conversation_id).await - } else { - let CodexSpawnOk { - codex, - conversation_id, - } = Codex::spawn(config, auth_manager, InitialHistory::New).await?; - self.finalize_spawn(codex, conversation_id).await - } + let CodexSpawnOk { + codex, + conversation_id, + } = Codex::spawn(config, auth_manager, InitialHistory::New).await?; + self.finalize_spawn(codex, conversation_id).await } async fn finalize_spawn( diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 55d0062454..7b9c3dc9f0 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -70,6 +70,7 @@ pub use rollout::ARCHIVED_SESSIONS_SUBDIR; pub use rollout::RolloutRecorder; pub use rollout::SESSIONS_SUBDIR; pub use rollout::SessionMeta; +pub use rollout::find_conversation_path_by_id_str; pub use rollout::list::ConversationItem; pub use rollout::list::ConversationsPage; pub use rollout::list::Cursor; diff --git a/codex-rs/core/src/rollout/list.rs b/codex-rs/core/src/rollout/list.rs index 3d6964512d..6f06709396 100644 --- a/codex-rs/core/src/rollout/list.rs +++ b/codex-rs/core/src/rollout/list.rs @@ -3,6 +3,10 @@ use std::io::{self}; use std::path::Path; use std::path::PathBuf; +use codex_file_search as file_search; +use std::num::NonZero; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; use time::OffsetDateTime; use time::PrimitiveDateTime; use time::format_description::FormatItem; @@ -334,3 +338,48 @@ async fn read_head_and_flags( Ok((head, saw_session_meta, saw_user_event)) } + +/// Locate a recorded conversation rollout file by its UUID string using the existing +/// paginated listing implementation. Returns `Ok(Some(path))` if found, `Ok(None)` if not present +/// or the id is invalid. +pub async fn find_conversation_path_by_id_str( + codex_home: &Path, + id_str: &str, +) -> io::Result> { + // Validate UUID format early. + if Uuid::parse_str(id_str).is_err() { + return Ok(None); + } + + let mut root = codex_home.to_path_buf(); + root.push(SESSIONS_SUBDIR); + if !root.exists() { + return Ok(None); + } + // This is safe because we know the values are valid. + #[allow(clippy::unwrap_used)] + let limit = NonZero::new(1).unwrap(); + // This is safe because we know the values are valid. + #[allow(clippy::unwrap_used)] + let threads = NonZero::new(2).unwrap(); + let cancel = Arc::new(AtomicBool::new(false)); + let exclude: Vec = Vec::new(); + let compute_indices = false; + + let results = file_search::run( + id_str, + limit, + &root, + exclude, + threads, + cancel, + compute_indices, + ) + .map_err(|e| io::Error::other(format!("file search failed: {e}")))?; + + Ok(results + .matches + .into_iter() + .next() + .map(|m| root.join(m.path))) +} diff --git a/codex-rs/core/src/rollout/mod.rs b/codex-rs/core/src/rollout/mod.rs index 6bf1cf9429..3c4cb10535 100644 --- a/codex-rs/core/src/rollout/mod.rs +++ b/codex-rs/core/src/rollout/mod.rs @@ -8,6 +8,7 @@ pub(crate) mod policy; pub mod recorder; pub use codex_protocol::protocol::SessionMeta; +pub use list::find_conversation_path_by_id_str; pub use recorder::RolloutRecorder; pub use recorder::RolloutRecorderParams; diff --git a/codex-rs/core/src/rollout/recorder.rs b/codex-rs/core/src/rollout/recorder.rs index 580a41ed37..6befdb1fe9 100644 --- a/codex-rs/core/src/rollout/recorder.rs +++ b/codex-rs/core/src/rollout/recorder.rs @@ -204,7 +204,6 @@ impl RolloutRecorder { pub(crate) async fn get_rollout_history(path: &Path) -> std::io::Result { info!("Resuming rollout from {path:?}"); - tracing::error!("Resuming rollout from {path:?}"); let text = tokio::fs::read_to_string(path).await?; if text.trim().is_empty() { return Err(IoError::other("empty session file")); @@ -254,7 +253,7 @@ impl RolloutRecorder { } } - tracing::error!( + info!( "Resumed rollout with {} items, conversation ID: {:?}", items.len(), conversation_id diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index 01b9d6bfc1..1a0c016272 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -420,12 +420,6 @@ async fn integration_creates_and_checks_session_file() { // Second run: resume should update the existing file. let marker2 = format!("integration-resume-{}", Uuid::new_v4()); let prompt2 = format!("echo {marker2}"); - // Cross‑platform safe resume override. On Windows, backslashes in a TOML string must be escaped - // or the parse will fail and the raw literal (including quotes) may be preserved all the way down - // to Config, which in turn breaks resume because the path is invalid. Normalize to forward slashes - // to sidestep the issue. - let resume_path_str = path.to_string_lossy().replace('\\', "/"); - let resume_override = format!("experimental_resume=\"{resume_path_str}\""); let mut cmd2 = AssertCommand::new("cargo"); cmd2.arg("run") .arg("-p") @@ -434,11 +428,11 @@ async fn integration_creates_and_checks_session_file() { .arg("--") .arg("exec") .arg("--skip-git-repo-check") - .arg("-c") - .arg(&resume_override) .arg("-C") .arg(env!("CARGO_MANIFEST_DIR")) - .arg(&prompt2); + .arg(&prompt2) + .arg("resume") + .arg("--last"); cmd2.env("CODEX_HOME", home.path()) .env("OPENAI_API_KEY", "dummy") .env("CODEX_RS_SSE_FIXTURE", &fixture) diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 70e0161d13..cfc6f5f40c 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -236,20 +236,21 @@ async fn resume_includes_initial_messages_and_sends_prior_items() { let codex_home = TempDir::new().unwrap(); let mut config = load_default_config_for_test(&codex_home); config.model_provider = model_provider; - config.experimental_resume = Some(session_path.clone()); // Also configure user instructions to ensure they are NOT delivered on resume. config.user_instructions = Some("be nice".to_string()); let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let auth_manager = + codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); let NewConversation { conversation: codex, session_configured, .. } = conversation_manager - .new_conversation(config) + .resume_conversation_from_rollout(config, session_path.clone(), auth_manager) .await - .expect("create new conversation"); + .expect("resume conversation"); // 1) Assert initial_messages only includes existing EventMsg entries; response items are not converted let initial_msgs = session_configured diff --git a/codex-rs/core/tests/suite/mod.rs b/codex-rs/core/tests/suite/mod.rs index 323cb78d33..4c217b63f4 100644 --- a/codex-rs/core/tests/suite/mod.rs +++ b/codex-rs/core/tests/suite/mod.rs @@ -11,6 +11,7 @@ mod live_cli; mod model_overrides; mod prompt_caching; mod review; +mod rollout_list_find; mod seatbelt; mod stream_error_allows_next_turn; mod stream_no_completed; diff --git a/codex-rs/core/tests/suite/review.rs b/codex-rs/core/tests/suite/review.rs index 26e0f1107a..2bcb67ff41 100644 --- a/codex-rs/core/tests/suite/review.rs +++ b/codex-rs/core/tests/suite/review.rs @@ -379,13 +379,8 @@ async fn review_input_isolated_from_parent_history() { .await .unwrap(); } - config.experimental_resume = Some(session_file); - - let codex = new_conversation_for_server(&server, &codex_home, |cfg| { - // apply resume file - cfg.experimental_resume = config.experimental_resume.clone(); - }) - .await; + let codex = + resume_conversation_for_server(&server, &codex_home, session_file.clone(), |_| {}).await; // Submit review request; it must start fresh (no parent history in `input`). let review_prompt = "Please review only this".to_string(); @@ -546,3 +541,32 @@ where .expect("create conversation") .conversation } + +/// Create a conversation resuming from a rollout file, configured to talk to the provided mock server. +#[expect(clippy::expect_used)] +async fn resume_conversation_for_server( + server: &MockServer, + codex_home: &TempDir, + resume_path: std::path::PathBuf, + mutator: F, +) -> Arc +where + F: FnOnce(&mut Config), +{ + let model_provider = ModelProviderInfo { + base_url: Some(format!("{}/v1", server.uri())), + ..built_in_model_providers()["openai"].clone() + }; + let mut config = load_default_config_for_test(codex_home); + config.model_provider = model_provider; + mutator(&mut config); + let conversation_manager = + ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key")); + let auth_manager = + codex_core::AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key")); + conversation_manager + .resume_conversation_from_rollout(config, resume_path, auth_manager) + .await + .expect("resume conversation") + .conversation +} diff --git a/codex-rs/core/tests/suite/rollout_list_find.rs b/codex-rs/core/tests/suite/rollout_list_find.rs new file mode 100644 index 0000000000..88409a4616 --- /dev/null +++ b/codex-rs/core/tests/suite/rollout_list_find.rs @@ -0,0 +1,50 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use std::io::Write; +use std::path::PathBuf; + +use codex_core::find_conversation_path_by_id_str; +use tempfile::TempDir; +use uuid::Uuid; + +/// Create sessions/YYYY/MM/DD and write a minimal rollout file containing the +/// provided conversation id in the SessionMeta line. Returns the absolute path. +fn write_minimal_rollout_with_id(codex_home: &TempDir, id: Uuid) -> PathBuf { + let sessions = codex_home.path().join("sessions/2024/01/01"); + std::fs::create_dir_all(&sessions).unwrap(); + + let file = sessions.join(format!("rollout-2024-01-01T00-00-00-{id}.jsonl")); + let mut f = std::fs::File::create(&file).unwrap(); + // Minimal first line: session_meta with the id so content search can find it + writeln!( + f, + "{}", + serde_json::json!({ + "timestamp": "2024-01-01T00:00:00.000Z", + "type": "session_meta", + "payload": { + "id": id, + "timestamp": "2024-01-01T00:00:00Z", + "instructions": null, + "cwd": ".", + "originator": "test", + "cli_version": "test" + } + }) + ) + .unwrap(); + + file +} + +#[tokio::test] +async fn find_locates_rollout_file_by_id() { + let home = TempDir::new().unwrap(); + let id = Uuid::new_v4(); + let expected = write_minimal_rollout_with_id(&home, id); + + let found = find_conversation_path_by_id_str(home.path(), &id.to_string()) + .await + .unwrap(); + + assert_eq!(found.unwrap(), expected); +} diff --git a/codex-rs/exec/Cargo.toml b/codex-rs/exec/Cargo.toml index 89dc3951e7..9f6833eb41 100644 --- a/codex-rs/exec/Cargo.toml +++ b/codex-rs/exec/Cargo.toml @@ -46,4 +46,6 @@ core_test_support = { path = "../core/tests/common" } libc = "0.2" predicates = "3" tempfile = "3.13.0" +uuid = "1" +walkdir = "2" wiremock = "0.6" diff --git a/codex-rs/exec/src/cli.rs b/codex-rs/exec/src/cli.rs index 31dd410991..19093ec925 100644 --- a/codex-rs/exec/src/cli.rs +++ b/codex-rs/exec/src/cli.rs @@ -6,6 +6,10 @@ use std::path::PathBuf; #[derive(Parser, Debug)] #[command(version)] pub struct Cli { + /// Action to perform. If omitted, runs a new non-interactive session. + #[command(subcommand)] + 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..)] pub images: Vec, @@ -69,6 +73,28 @@ pub struct Cli { pub prompt: Option, } +#[derive(Debug, clap::Subcommand)] +pub enum Command { + /// Resume a previous session by id or pick the most recent with --last. + Resume(ResumeArgs), +} + +#[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, + + /// Resume the most recent recorded session (newest) without specifying an id. + #[arg(long = "last", default_value_t = false, conflicts_with = "session_id")] + pub last: bool, + + /// Prompt to send after resuming the session. If `-` is used, read from stdin. + #[arg(value_name = "PROMPT")] + pub prompt: Option, +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)] #[value(rename_all = "kebab-case")] pub enum Color { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 7853374b55..097231da60 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -30,11 +30,14 @@ use tracing::error; use tracing::info; use tracing_subscriber::EnvFilter; +use crate::cli::Command as ExecCommand; use crate::event_processor::CodexStatus; use crate::event_processor::EventProcessor; +use codex_core::find_conversation_path_by_id_str; pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let Cli { + command, images, model: model_cli_arg, oss, @@ -51,8 +54,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any config_overrides, } = cli; - // Determine the prompt based on CLI arg and/or stdin. - let prompt = match prompt { + // Determine the prompt source (parent or subcommand) and read from stdin if needed. + let prompt_arg = match &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, + }; + + let prompt = match prompt_arg { Some(p) if p != "-" => p, // Either `-` was passed or no positional arg. maybe_dash => { @@ -190,11 +200,29 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any let conversation_manager = ConversationManager::new(AuthManager::shared(config.codex_home.clone())); + + // Handle resume subcommand by resolving a rollout path and using explicit resume API. let NewConversation { conversation_id: _, conversation, session_configured, - } = conversation_manager.new_conversation(config).await?; + } = if let Some(ExecCommand::Resume(args)) = command { + let resume_path = resolve_resume_path(&config, &args).await?; + + if let Some(path) = resume_path { + conversation_manager + .resume_conversation_from_rollout( + config.clone(), + path, + AuthManager::shared(config.codex_home.clone()), + ) + .await? + } else { + conversation_manager.new_conversation(config).await? + } + } else { + conversation_manager.new_conversation(config).await? + }; info!("Codex initialized with event: {session_configured:?}"); let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); @@ -279,3 +307,23 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> any Ok(()) } + +async fn resolve_resume_path( + config: &Config, + args: &crate::cli::ResumeArgs, +) -> anyhow::Result> { + if args.last { + match codex_core::RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { + Ok(page) => Ok(page.items.first().map(|it| it.path.clone())), + Err(e) => { + error!("Error listing conversations: {e}"); + Ok(None) + } + } + } else if let Some(id_str) = args.session_id.as_deref() { + let path = find_conversation_path_by_id_str(&config.codex_home, id_str).await?; + Ok(path) + } else { + Ok(None) + } +} diff --git a/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse b/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse new file mode 100644 index 0000000000..660c56b1d8 --- /dev/null +++ b/codex-rs/exec/tests/fixtures/cli_responses_fixture.sse @@ -0,0 +1,10 @@ +event: response.created +data: {"type":"response.created","response":{"id":"resp1"}} + +event: response.output_item.done +data: {"type":"response.output_item.done","item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"fixture hello"}]}} + +event: response.completed +data: {"type":"response.completed","response":{"id":"resp1","output":[]}} + + diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index 75b19ee1b2..5748fba1e8 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,4 +1,5 @@ // Aggregates all former standalone integration tests as modules. mod apply_patch; mod common; +mod resume; mod sandbox; diff --git a/codex-rs/exec/tests/suite/resume.rs b/codex-rs/exec/tests/suite/resume.rs new file mode 100644 index 0000000000..5868ed8eb2 --- /dev/null +++ b/codex-rs/exec/tests/suite/resume.rs @@ -0,0 +1,267 @@ +#![allow(clippy::unwrap_used, clippy::expect_used)] +use anyhow::Context; +use assert_cmd::prelude::*; +use serde_json::Value; +use std::process::Command; +use tempfile::TempDir; +use uuid::Uuid; +use walkdir::WalkDir; + +/// Utility: scan the sessions dir for a rollout file that contains `marker` +/// in any response_item.message.content entry. Returns the absolute path. +fn find_session_file_containing_marker( + sessions_dir: &std::path::Path, + marker: &str, +) -> Option { + for entry in WalkDir::new(sessions_dir) { + let entry = match entry { + Ok(e) => e, + Err(_) => continue, + }; + if !entry.file_type().is_file() { + continue; + } + if !entry.file_name().to_string_lossy().ends_with(".jsonl") { + continue; + } + let path = entry.path(); + let Ok(content) = std::fs::read_to_string(path) else { + continue; + }; + // Skip the first meta line and scan remaining JSONL entries. + let mut lines = content.lines(); + if lines.next().is_none() { + continue; + } + for line in 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") + && let Some(payload) = item.get("payload") + && payload.get("type").and_then(|t| t.as_str()) == Some("message") + && payload + .get("content") + .map(|c| c.to_string()) + .unwrap_or_default() + .contains(marker) + { + return Some(path.to_path_buf()); + } + } + } + None +} + +/// Extract the conversation UUID from the first SessionMeta line in the rollout file. +fn extract_conversation_id(path: &std::path::Path) -> String { + let content = std::fs::read_to_string(path).unwrap(); + let mut lines = content.lines(); + let meta_line = lines.next().expect("missing meta line"); + let meta: Value = serde_json::from_str(meta_line).expect("invalid meta json"); + meta.get("payload") + .and_then(|p| p.get("id")) + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string() +} + +#[test] +fn exec_resume_last_appends_to_existing_file() -> anyhow::Result<()> { + let home = TempDir::new()?; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/cli_responses_fixture.sse"); + + // 1) First run: create a session with a unique marker in the content. + let marker = format!("resume-last-{}", 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(); + + // Find the created session file containing the marker. + 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"); + + // 2) Second run: resume the most recent file with a new marker. + let marker2 = format!("resume-last-2-{}", Uuid::new_v4()); + let prompt2 = format!("echo {marker2}"); + + let mut binding = assert_cmd::Command::cargo_bin("codex-exec") + .context("should find binary for codex-exec")?; + let cmd = binding + .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(&prompt2) + .arg("resume") + .arg("--last"); + cmd.assert().success(); + + // Ensure the same file was updated and contains both markers. + 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 append to 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()?; + let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/cli_responses_fixture.sse"); + + // 1) First run: create a session + let marker = format!("resume-by-id-{}", 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 session_id = extract_conversation_id(&path); + assert!( + !session_id.is_empty(), + "missing conversation id in meta line" + ); + + // 2) Resume by id + let marker2 = format!("resume-by-id-2-{}", Uuid::new_v4()); + let prompt2 = format!("echo {marker2}"); + + let mut binding = assert_cmd::Command::cargo_bin("codex-exec") + .context("should find binary for codex-exec")?; + let cmd = binding + .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(&prompt2) + .arg("resume") + .arg(&session_id); + cmd.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 by id should append to existing file" + ); + let content = std::fs::read_to_string(&resumed_path)?; + assert!(content.contains(&marker)); + assert!(content.contains(&marker2)); + Ok(()) +} + +#[test] +fn exec_resume_preserves_cli_configuration_overrides() -> 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-config-{}", 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("--sandbox") + .arg("workspace-write") + .arg("--model") + .arg("gpt-5") + .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-config-2-{}", Uuid::new_v4()); + let prompt2 = format!("echo {marker2}"); + + let output = 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("--sandbox") + .arg("workspace-write") + .arg("--model") + .arg("gpt-5-high") + .arg("-C") + .arg(env!("CARGO_MANIFEST_DIR")) + .arg(&prompt2) + .arg("resume") + .arg("--last") + .output() + .context("resume run should succeed")?; + + assert!(output.status.success(), "resume run failed: {output:?}"); + + let stdout = String::from_utf8(output.stdout)?; + assert!( + stdout.contains("model: gpt-5-high"), + "stdout missing model override: {stdout}" + ); + assert!( + stdout.contains("sandbox: workspace-write"), + "stdout missing sandbox override: {stdout}" + ); + + let resumed_path = find_session_file_containing_marker(&sessions_dir, &marker2) + .expect("no resumed session file containing marker2"); + assert_eq!(resumed_path, path, "resume should append to same file"); + + let content = std::fs::read_to_string(&resumed_path)?; + assert!(content.contains(&marker)); + assert!(content.contains(&marker2)); + Ok(()) +} diff --git a/codex-rs/tui/src/cli.rs b/codex-rs/tui/src/cli.rs index cb5f8ac778..e9e999268d 100644 --- a/codex-rs/tui/src/cli.rs +++ b/codex-rs/tui/src/cli.rs @@ -13,35 +13,18 @@ pub struct Cli { #[arg(long = "image", short = 'i', value_name = "FILE", value_delimiter = ',', num_args = 1..)] pub images: Vec, - /// Open an interactive picker to resume a previous session recorded on disk - /// instead of starting a new one. - /// - /// Notes: - /// - Mutually exclusive with `--continue`. - /// - The picker displays recent sessions and a preview of the first real user - /// message to help you select the right one. - #[arg( - long = "resume", - default_value_t = false, - conflicts_with = "continue", - hide = true - )] - pub resume: bool, + // Internal controls set by the top-level `codex resume` subcommand. + // These are not exposed as user flags on the base `codex` command. + #[clap(skip)] + pub resume_picker: bool, - /// Continue the most recent conversation without showing the picker. - /// - /// Notes: - /// - Mutually exclusive with `--resume`. - /// - If no recorded sessions are found, this behaves like starting fresh. - /// - Equivalent to picking the newest item in the resume picker. - #[arg( - id = "continue", - long = "continue", - default_value_t = false, - conflicts_with = "resume", - hide = true - )] - pub r#continue: bool, + #[clap(skip)] + pub resume_last: bool, + + /// Internal: resume a specific recorded session by id (UUID). Set by the + /// top-level `codex resume ` wrapper; not exposed as a public flag. + #[clap(skip)] + pub resume_session_id: Option, /// Model the agent should use. #[arg(long, short = 'm')] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e050e5b5c6..83c37b75fa 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -15,6 +15,7 @@ use codex_core::config::SWIFTFOX_MEDIUM_MODEL; use codex_core::config::find_codex_home; use codex_core::config::load_config_as_toml_with_cli_overrides; use codex_core::config::persist_model_selection; +use codex_core::find_conversation_path_by_id_str; use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_ollama::DEFAULT_OSS_MODEL; @@ -343,7 +344,16 @@ async fn run_ratatui_app( } } - let resume_selection = if cli.r#continue { + // Determine resume behavior: explicit id, then resume last, then picker. + let resume_selection = if let Some(id_str) = cli.resume_session_id.as_deref() { + match find_conversation_path_by_id_str(&config.codex_home, id_str).await? { + Some(path) => resume_picker::ResumeSelection::Resume(path), + None => { + error!("Error finding conversation path: {id_str}"); + resume_picker::ResumeSelection::StartFresh + } + } + } else if cli.resume_last { match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await { Ok(page) => page .items @@ -352,7 +362,7 @@ async fn run_ratatui_app( .unwrap_or(resume_picker::ResumeSelection::StartFresh), Err(_) => resume_picker::ResumeSelection::StartFresh, } - } else if cli.resume { + } else if cli.resume_picker { match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? { resume_picker::ResumeSelection::Exit => { restore(); diff --git a/docs/advanced.md b/docs/advanced.md index 71ede3133a..4edca7646f 100644 --- a/docs/advanced.md +++ b/docs/advanced.md @@ -12,6 +12,40 @@ Run Codex head-less in pipelines. Example GitHub Action step: codex exec --full-auto "update CHANGELOG for next release" ``` +### Resuming non-interactive sessions + +You can resume a previous headless run to continue the same conversation context and append to the same rollout file. + +Interactive TUI equivalent: + +```shell +codex resume # picker +codex resume --last # most recent +codex resume +``` + +Compatibility: + +- Latest source builds include `codex exec resume` (examples below). +- Current released CLI may not include this yet. If `codex exec --help` shows no `resume`, use the workaround in the next subsection. + +```shell +# Resume the most recent recorded session and run with a new prompt (source builds) +codex exec "ship a release draft changelog" resume --last + +# Alternatively, pass the prompt via stdin (source builds) +# Note: omit the trailing '-' to avoid it being parsed as a SESSION_ID +echo "ship a release draft changelog" | codex exec resume --last + +# Or resume a specific session by id (UUID) (source builds) +codex exec resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc "continue the task" +``` + +Notes: + +- When using `--last`, Codex picks the newest recorded session; if none exist, it behaves like starting fresh. +- Resuming appends new events to the existing session file and maintains the same conversation id. + ## Tracing / verbose logging Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior. diff --git a/docs/getting-started.md b/docs/getting-started.md index cc7dd1293c..e97de6a048 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,19 +10,24 @@ Key flags: `--model/-m`, `--ask-for-approval/-a`. - ### Running with a prompt as input