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 <session id>`: continue conversation with `session id` `exec`: `codex resume --last`: continue last conversation `codex resume <session id>`: 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
This commit is contained in:
3
codex-rs/Cargo.lock
generated
3
codex-rs/Cargo.lock
generated
@@ -666,6 +666,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"codex-apply-patch",
|
"codex-apply-patch",
|
||||||
|
"codex-file-search",
|
||||||
"codex-mcp-client",
|
"codex-mcp-client",
|
||||||
"codex-protocol",
|
"codex-protocol",
|
||||||
"core_test_support",
|
"core_test_support",
|
||||||
@@ -733,6 +734,8 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"uuid",
|
||||||
|
"walkdir",
|
||||||
"wiremock",
|
"wiremock",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ enum Subcommand {
|
|||||||
#[clap(visible_alias = "a")]
|
#[clap(visible_alias = "a")]
|
||||||
Apply(ApplyCommand),
|
Apply(ApplyCommand),
|
||||||
|
|
||||||
|
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||||
|
Resume(ResumeCommand),
|
||||||
|
|
||||||
/// Internal: generate TypeScript protocol bindings.
|
/// Internal: generate TypeScript protocol bindings.
|
||||||
#[clap(hide = true)]
|
#[clap(hide = true)]
|
||||||
GenerateTs(GenerateTsCommand),
|
GenerateTs(GenerateTsCommand),
|
||||||
@@ -85,6 +88,18 @@ struct CompletionCommand {
|
|||||||
shell: Shell,
|
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<String>,
|
||||||
|
|
||||||
|
/// 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)]
|
#[derive(Debug, Parser)]
|
||||||
struct DebugArgs {
|
struct DebugArgs {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
@@ -143,26 +158,54 @@ fn main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let cli = MultitoolCli::parse();
|
let MultitoolCli {
|
||||||
|
config_overrides: root_config_overrides,
|
||||||
|
mut interactive,
|
||||||
|
subcommand,
|
||||||
|
} = MultitoolCli::parse();
|
||||||
|
|
||||||
match cli.subcommand {
|
match subcommand {
|
||||||
None => {
|
None => {
|
||||||
let mut tui_cli = cli.interactive;
|
prepend_config_flags(
|
||||||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
&mut interactive.config_overrides,
|
||||||
let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?;
|
root_config_overrides.clone(),
|
||||||
|
);
|
||||||
|
let usage = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||||
if !usage.is_zero() {
|
if !usage.is_zero() {
|
||||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Subcommand::Exec(mut exec_cli)) => {
|
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?;
|
codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?;
|
||||||
}
|
}
|
||||||
Some(Subcommand::Mcp) => {
|
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)) => {
|
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 {
|
match login_cli.action {
|
||||||
Some(LoginSubcommand::Status) => {
|
Some(LoginSubcommand::Status) => {
|
||||||
run_login_status(login_cli.config_overrides).await;
|
run_login_status(login_cli.config_overrides).await;
|
||||||
@@ -177,11 +220,17 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Subcommand::Logout(mut logout_cli)) => {
|
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;
|
run_logout(logout_cli.config_overrides).await;
|
||||||
}
|
}
|
||||||
Some(Subcommand::Proto(mut proto_cli)) => {
|
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?;
|
proto::run_main(proto_cli).await?;
|
||||||
}
|
}
|
||||||
Some(Subcommand::Completion(completion_cli)) => {
|
Some(Subcommand::Completion(completion_cli)) => {
|
||||||
@@ -189,7 +238,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
}
|
}
|
||||||
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
|
Some(Subcommand::Debug(debug_args)) => match debug_args.cmd {
|
||||||
DebugCommand::Seatbelt(mut seatbelt_cli) => {
|
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(
|
codex_cli::debug_sandbox::run_command_under_seatbelt(
|
||||||
seatbelt_cli,
|
seatbelt_cli,
|
||||||
codex_linux_sandbox_exe,
|
codex_linux_sandbox_exe,
|
||||||
@@ -197,7 +249,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
.await?;
|
.await?;
|
||||||
}
|
}
|
||||||
DebugCommand::Landlock(mut landlock_cli) => {
|
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(
|
codex_cli::debug_sandbox::run_command_under_landlock(
|
||||||
landlock_cli,
|
landlock_cli,
|
||||||
codex_linux_sandbox_exe,
|
codex_linux_sandbox_exe,
|
||||||
@@ -206,7 +261,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
Some(Subcommand::Apply(mut apply_cli)) => {
|
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?;
|
run_apply_command(apply_cli, None).await?;
|
||||||
}
|
}
|
||||||
Some(Subcommand::GenerateTs(gen_cli)) => {
|
Some(Subcommand::GenerateTs(gen_cli)) => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ base64 = "0.22"
|
|||||||
bytes = "1.10.1"
|
bytes = "1.10.1"
|
||||||
chrono = { version = "0.4", features = ["serde"] }
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
codex-apply-patch = { path = "../apply-patch" }
|
codex-apply-patch = { path = "../apply-patch" }
|
||||||
|
codex-file-search = { path = "../file-search" }
|
||||||
codex-mcp-client = { path = "../mcp-client" }
|
codex-mcp-client = { path = "../mcp-client" }
|
||||||
codex-protocol = { path = "../protocol" }
|
codex-protocol = { path = "../protocol" }
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
|
|||||||
@@ -161,9 +161,6 @@ pub struct Config {
|
|||||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||||
pub chatgpt_base_url: String,
|
pub chatgpt_base_url: String,
|
||||||
|
|
||||||
/// Experimental rollout resume path (absolute path to .jsonl; undocumented).
|
|
||||||
pub experimental_resume: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Include an experimental plan tool that the model can use to update its current plan and status of each step.
|
/// 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,
|
pub include_plan_tool: bool,
|
||||||
|
|
||||||
@@ -603,9 +600,6 @@ pub struct ConfigToml {
|
|||||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||||
pub chatgpt_base_url: Option<String>,
|
pub chatgpt_base_url: Option<String>,
|
||||||
|
|
||||||
/// Experimental rollout resume path (absolute path to .jsonl; undocumented).
|
|
||||||
pub experimental_resume: Option<PathBuf>,
|
|
||||||
|
|
||||||
/// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS.
|
/// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS.
|
||||||
pub experimental_instructions_file: Option<PathBuf>,
|
pub experimental_instructions_file: Option<PathBuf>,
|
||||||
|
|
||||||
@@ -892,8 +886,6 @@ impl Config {
|
|||||||
.and_then(|info| info.auto_compact_token_limit)
|
.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
|
// Load base instructions override from a file if specified. If the
|
||||||
// path is relative, resolve it against the effective cwd so the
|
// path is relative, resolve it against the effective cwd so the
|
||||||
// behaviour matches other path-like config values.
|
// behaviour matches other path-like config values.
|
||||||
@@ -954,8 +946,6 @@ impl Config {
|
|||||||
.chatgpt_base_url
|
.chatgpt_base_url
|
||||||
.or(cfg.chatgpt_base_url)
|
.or(cfg.chatgpt_base_url)
|
||||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||||
|
|
||||||
experimental_resume,
|
|
||||||
include_plan_tool: include_plan_tool.unwrap_or(false),
|
include_plan_tool: include_plan_tool.unwrap_or(false),
|
||||||
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
|
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
|
||||||
tools_web_search_request,
|
tools_web_search_request,
|
||||||
@@ -1481,7 +1471,6 @@ model_verbosity = "high"
|
|||||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||||
model_verbosity: None,
|
model_verbosity: None,
|
||||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||||
experimental_resume: None,
|
|
||||||
base_instructions: None,
|
base_instructions: None,
|
||||||
include_plan_tool: false,
|
include_plan_tool: false,
|
||||||
include_apply_patch_tool: false,
|
include_apply_patch_tool: false,
|
||||||
@@ -1539,7 +1528,6 @@ model_verbosity = "high"
|
|||||||
model_reasoning_summary: ReasoningSummary::default(),
|
model_reasoning_summary: ReasoningSummary::default(),
|
||||||
model_verbosity: None,
|
model_verbosity: None,
|
||||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||||
experimental_resume: None,
|
|
||||||
base_instructions: None,
|
base_instructions: None,
|
||||||
include_plan_tool: false,
|
include_plan_tool: false,
|
||||||
include_apply_patch_tool: false,
|
include_apply_patch_tool: false,
|
||||||
@@ -1612,7 +1600,6 @@ model_verbosity = "high"
|
|||||||
model_reasoning_summary: ReasoningSummary::default(),
|
model_reasoning_summary: ReasoningSummary::default(),
|
||||||
model_verbosity: None,
|
model_verbosity: None,
|
||||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||||
experimental_resume: None,
|
|
||||||
base_instructions: None,
|
base_instructions: None,
|
||||||
include_plan_tool: false,
|
include_plan_tool: false,
|
||||||
include_apply_patch_tool: false,
|
include_apply_patch_tool: false,
|
||||||
@@ -1671,7 +1658,6 @@ model_verbosity = "high"
|
|||||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||||
model_verbosity: Some(Verbosity::High),
|
model_verbosity: Some(Verbosity::High),
|
||||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||||
experimental_resume: None,
|
|
||||||
base_instructions: None,
|
base_instructions: None,
|
||||||
include_plan_tool: false,
|
include_plan_tool: false,
|
||||||
include_apply_patch_tool: false,
|
include_apply_patch_tool: false,
|
||||||
|
|||||||
@@ -59,21 +59,11 @@ impl ConversationManager {
|
|||||||
config: Config,
|
config: Config,
|
||||||
auth_manager: Arc<AuthManager>,
|
auth_manager: Arc<AuthManager>,
|
||||||
) -> CodexResult<NewConversation> {
|
) -> CodexResult<NewConversation> {
|
||||||
// TO BE REFACTORED: use the config experimental_resume field until we have a mainstream way.
|
let CodexSpawnOk {
|
||||||
if let Some(resume_path) = config.experimental_resume.as_ref() {
|
codex,
|
||||||
let initial_history = RolloutRecorder::get_rollout_history(resume_path).await?;
|
conversation_id,
|
||||||
let CodexSpawnOk {
|
} = Codex::spawn(config, auth_manager, InitialHistory::New).await?;
|
||||||
codex,
|
self.finalize_spawn(codex, conversation_id).await
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn finalize_spawn(
|
async fn finalize_spawn(
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
|||||||
pub use rollout::RolloutRecorder;
|
pub use rollout::RolloutRecorder;
|
||||||
pub use rollout::SESSIONS_SUBDIR;
|
pub use rollout::SESSIONS_SUBDIR;
|
||||||
pub use rollout::SessionMeta;
|
pub use rollout::SessionMeta;
|
||||||
|
pub use rollout::find_conversation_path_by_id_str;
|
||||||
pub use rollout::list::ConversationItem;
|
pub use rollout::list::ConversationItem;
|
||||||
pub use rollout::list::ConversationsPage;
|
pub use rollout::list::ConversationsPage;
|
||||||
pub use rollout::list::Cursor;
|
pub use rollout::list::Cursor;
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ use std::io::{self};
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::path::PathBuf;
|
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::OffsetDateTime;
|
||||||
use time::PrimitiveDateTime;
|
use time::PrimitiveDateTime;
|
||||||
use time::format_description::FormatItem;
|
use time::format_description::FormatItem;
|
||||||
@@ -334,3 +338,48 @@ async fn read_head_and_flags(
|
|||||||
|
|
||||||
Ok((head, saw_session_meta, saw_user_event))
|
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<Option<PathBuf>> {
|
||||||
|
// 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<String> = 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)))
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub(crate) mod policy;
|
|||||||
pub mod recorder;
|
pub mod recorder;
|
||||||
|
|
||||||
pub use codex_protocol::protocol::SessionMeta;
|
pub use codex_protocol::protocol::SessionMeta;
|
||||||
|
pub use list::find_conversation_path_by_id_str;
|
||||||
pub use recorder::RolloutRecorder;
|
pub use recorder::RolloutRecorder;
|
||||||
pub use recorder::RolloutRecorderParams;
|
pub use recorder::RolloutRecorderParams;
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,6 @@ impl RolloutRecorder {
|
|||||||
|
|
||||||
pub(crate) async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
|
pub(crate) async fn get_rollout_history(path: &Path) -> std::io::Result<InitialHistory> {
|
||||||
info!("Resuming rollout from {path:?}");
|
info!("Resuming rollout from {path:?}");
|
||||||
tracing::error!("Resuming rollout from {path:?}");
|
|
||||||
let text = tokio::fs::read_to_string(path).await?;
|
let text = tokio::fs::read_to_string(path).await?;
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return Err(IoError::other("empty session file"));
|
return Err(IoError::other("empty session file"));
|
||||||
@@ -254,7 +253,7 @@ impl RolloutRecorder {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tracing::error!(
|
info!(
|
||||||
"Resumed rollout with {} items, conversation ID: {:?}",
|
"Resumed rollout with {} items, conversation ID: {:?}",
|
||||||
items.len(),
|
items.len(),
|
||||||
conversation_id
|
conversation_id
|
||||||
|
|||||||
@@ -420,12 +420,6 @@ async fn integration_creates_and_checks_session_file() {
|
|||||||
// Second run: resume should update the existing file.
|
// Second run: resume should update the existing file.
|
||||||
let marker2 = format!("integration-resume-{}", Uuid::new_v4());
|
let marker2 = format!("integration-resume-{}", Uuid::new_v4());
|
||||||
let prompt2 = format!("echo {marker2}");
|
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");
|
let mut cmd2 = AssertCommand::new("cargo");
|
||||||
cmd2.arg("run")
|
cmd2.arg("run")
|
||||||
.arg("-p")
|
.arg("-p")
|
||||||
@@ -434,11 +428,11 @@ async fn integration_creates_and_checks_session_file() {
|
|||||||
.arg("--")
|
.arg("--")
|
||||||
.arg("exec")
|
.arg("exec")
|
||||||
.arg("--skip-git-repo-check")
|
.arg("--skip-git-repo-check")
|
||||||
.arg("-c")
|
|
||||||
.arg(&resume_override)
|
|
||||||
.arg("-C")
|
.arg("-C")
|
||||||
.arg(env!("CARGO_MANIFEST_DIR"))
|
.arg(env!("CARGO_MANIFEST_DIR"))
|
||||||
.arg(&prompt2);
|
.arg(&prompt2)
|
||||||
|
.arg("resume")
|
||||||
|
.arg("--last");
|
||||||
cmd2.env("CODEX_HOME", home.path())
|
cmd2.env("CODEX_HOME", home.path())
|
||||||
.env("OPENAI_API_KEY", "dummy")
|
.env("OPENAI_API_KEY", "dummy")
|
||||||
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
.env("CODEX_RS_SSE_FIXTURE", &fixture)
|
||||||
|
|||||||
@@ -236,20 +236,21 @@ async fn resume_includes_initial_messages_and_sends_prior_items() {
|
|||||||
let codex_home = TempDir::new().unwrap();
|
let codex_home = TempDir::new().unwrap();
|
||||||
let mut config = load_default_config_for_test(&codex_home);
|
let mut config = load_default_config_for_test(&codex_home);
|
||||||
config.model_provider = model_provider;
|
config.model_provider = model_provider;
|
||||||
config.experimental_resume = Some(session_path.clone());
|
|
||||||
// Also configure user instructions to ensure they are NOT delivered on resume.
|
// Also configure user instructions to ensure they are NOT delivered on resume.
|
||||||
config.user_instructions = Some("be nice".to_string());
|
config.user_instructions = Some("be nice".to_string());
|
||||||
|
|
||||||
let conversation_manager =
|
let conversation_manager =
|
||||||
ConversationManager::with_auth(CodexAuth::from_api_key("Test API Key"));
|
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 {
|
let NewConversation {
|
||||||
conversation: codex,
|
conversation: codex,
|
||||||
session_configured,
|
session_configured,
|
||||||
..
|
..
|
||||||
} = conversation_manager
|
} = conversation_manager
|
||||||
.new_conversation(config)
|
.resume_conversation_from_rollout(config, session_path.clone(), auth_manager)
|
||||||
.await
|
.await
|
||||||
.expect("create new conversation");
|
.expect("resume conversation");
|
||||||
|
|
||||||
// 1) Assert initial_messages only includes existing EventMsg entries; response items are not converted
|
// 1) Assert initial_messages only includes existing EventMsg entries; response items are not converted
|
||||||
let initial_msgs = session_configured
|
let initial_msgs = session_configured
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ mod live_cli;
|
|||||||
mod model_overrides;
|
mod model_overrides;
|
||||||
mod prompt_caching;
|
mod prompt_caching;
|
||||||
mod review;
|
mod review;
|
||||||
|
mod rollout_list_find;
|
||||||
mod seatbelt;
|
mod seatbelt;
|
||||||
mod stream_error_allows_next_turn;
|
mod stream_error_allows_next_turn;
|
||||||
mod stream_no_completed;
|
mod stream_no_completed;
|
||||||
|
|||||||
@@ -379,13 +379,8 @@ async fn review_input_isolated_from_parent_history() {
|
|||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
config.experimental_resume = Some(session_file);
|
let codex =
|
||||||
|
resume_conversation_for_server(&server, &codex_home, session_file.clone(), |_| {}).await;
|
||||||
let codex = new_conversation_for_server(&server, &codex_home, |cfg| {
|
|
||||||
// apply resume file
|
|
||||||
cfg.experimental_resume = config.experimental_resume.clone();
|
|
||||||
})
|
|
||||||
.await;
|
|
||||||
|
|
||||||
// Submit review request; it must start fresh (no parent history in `input`).
|
// Submit review request; it must start fresh (no parent history in `input`).
|
||||||
let review_prompt = "Please review only this".to_string();
|
let review_prompt = "Please review only this".to_string();
|
||||||
@@ -546,3 +541,32 @@ where
|
|||||||
.expect("create conversation")
|
.expect("create conversation")
|
||||||
.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<F>(
|
||||||
|
server: &MockServer,
|
||||||
|
codex_home: &TempDir,
|
||||||
|
resume_path: std::path::PathBuf,
|
||||||
|
mutator: F,
|
||||||
|
) -> Arc<CodexConversation>
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
50
codex-rs/core/tests/suite/rollout_list_find.rs
Normal file
50
codex-rs/core/tests/suite/rollout_list_find.rs
Normal file
@@ -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);
|
||||||
|
}
|
||||||
@@ -46,4 +46,6 @@ core_test_support = { path = "../core/tests/common" }
|
|||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
predicates = "3"
|
predicates = "3"
|
||||||
tempfile = "3.13.0"
|
tempfile = "3.13.0"
|
||||||
|
uuid = "1"
|
||||||
|
walkdir = "2"
|
||||||
wiremock = "0.6"
|
wiremock = "0.6"
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ use std::path::PathBuf;
|
|||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(version)]
|
#[command(version)]
|
||||||
pub struct Cli {
|
pub struct Cli {
|
||||||
|
/// Action to perform. If omitted, runs a new non-interactive session.
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Command>,
|
||||||
|
|
||||||
/// Optional image(s) to attach to the initial prompt.
|
/// 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>,
|
pub images: Vec<PathBuf>,
|
||||||
@@ -69,6 +73,28 @@ pub struct Cli {
|
|||||||
pub prompt: Option<String>,
|
pub prompt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[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<String>,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, ValueEnum)]
|
||||||
#[value(rename_all = "kebab-case")]
|
#[value(rename_all = "kebab-case")]
|
||||||
pub enum Color {
|
pub enum Color {
|
||||||
|
|||||||
@@ -30,11 +30,14 @@ use tracing::error;
|
|||||||
use tracing::info;
|
use tracing::info;
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
|
||||||
|
use crate::cli::Command as ExecCommand;
|
||||||
use crate::event_processor::CodexStatus;
|
use crate::event_processor::CodexStatus;
|
||||||
use crate::event_processor::EventProcessor;
|
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<PathBuf>) -> anyhow::Result<()> {
|
pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()> {
|
||||||
let Cli {
|
let Cli {
|
||||||
|
command,
|
||||||
images,
|
images,
|
||||||
model: model_cli_arg,
|
model: model_cli_arg,
|
||||||
oss,
|
oss,
|
||||||
@@ -51,8 +54,15 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
config_overrides,
|
config_overrides,
|
||||||
} = cli;
|
} = cli;
|
||||||
|
|
||||||
// Determine the prompt based on CLI arg and/or stdin.
|
// Determine the prompt source (parent or subcommand) and read from stdin if needed.
|
||||||
let prompt = match prompt {
|
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,
|
Some(p) if p != "-" => p,
|
||||||
// Either `-` was passed or no positional arg.
|
// Either `-` was passed or no positional arg.
|
||||||
maybe_dash => {
|
maybe_dash => {
|
||||||
@@ -190,11 +200,29 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
|
|
||||||
let conversation_manager =
|
let conversation_manager =
|
||||||
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
|
ConversationManager::new(AuthManager::shared(config.codex_home.clone()));
|
||||||
|
|
||||||
|
// Handle resume subcommand by resolving a rollout path and using explicit resume API.
|
||||||
let NewConversation {
|
let NewConversation {
|
||||||
conversation_id: _,
|
conversation_id: _,
|
||||||
conversation,
|
conversation,
|
||||||
session_configured,
|
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:?}");
|
info!("Codex initialized with event: {session_configured:?}");
|
||||||
|
|
||||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<Event>();
|
||||||
@@ -279,3 +307,23 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn resolve_resume_path(
|
||||||
|
config: &Config,
|
||||||
|
args: &crate::cli::ResumeArgs,
|
||||||
|
) -> anyhow::Result<Option<PathBuf>> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
10
codex-rs/exec/tests/fixtures/cli_responses_fixture.sse
vendored
Normal file
10
codex-rs/exec/tests/fixtures/cli_responses_fixture.sse
vendored
Normal file
@@ -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":[]}}
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
// Aggregates all former standalone integration tests as modules.
|
// Aggregates all former standalone integration tests as modules.
|
||||||
mod apply_patch;
|
mod apply_patch;
|
||||||
mod common;
|
mod common;
|
||||||
|
mod resume;
|
||||||
mod sandbox;
|
mod sandbox;
|
||||||
|
|||||||
267
codex-rs/exec/tests/suite/resume.rs
Normal file
267
codex-rs/exec/tests/suite/resume.rs
Normal file
@@ -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<std::path::PathBuf> {
|
||||||
|
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<Value, _> = 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(())
|
||||||
|
}
|
||||||
@@ -13,35 +13,18 @@ pub struct Cli {
|
|||||||
#[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>,
|
pub images: Vec<PathBuf>,
|
||||||
|
|
||||||
/// Open an interactive picker to resume a previous session recorded on disk
|
// Internal controls set by the top-level `codex resume` subcommand.
|
||||||
/// instead of starting a new one.
|
// These are not exposed as user flags on the base `codex` command.
|
||||||
///
|
#[clap(skip)]
|
||||||
/// Notes:
|
pub resume_picker: bool,
|
||||||
/// - 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,
|
|
||||||
|
|
||||||
/// Continue the most recent conversation without showing the picker.
|
#[clap(skip)]
|
||||||
///
|
pub resume_last: bool,
|
||||||
/// Notes:
|
|
||||||
/// - Mutually exclusive with `--resume`.
|
/// Internal: resume a specific recorded session by id (UUID). Set by the
|
||||||
/// - If no recorded sessions are found, this behaves like starting fresh.
|
/// top-level `codex resume <SESSION_ID>` wrapper; not exposed as a public flag.
|
||||||
/// - Equivalent to picking the newest item in the resume picker.
|
#[clap(skip)]
|
||||||
#[arg(
|
pub resume_session_id: Option<String>,
|
||||||
id = "continue",
|
|
||||||
long = "continue",
|
|
||||||
default_value_t = false,
|
|
||||||
conflicts_with = "resume",
|
|
||||||
hide = true
|
|
||||||
)]
|
|
||||||
pub r#continue: bool,
|
|
||||||
|
|
||||||
/// Model the agent should use.
|
/// Model the agent should use.
|
||||||
#[arg(long, short = 'm')]
|
#[arg(long, short = 'm')]
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use codex_core::config::SWIFTFOX_MEDIUM_MODEL;
|
|||||||
use codex_core::config::find_codex_home;
|
use codex_core::config::find_codex_home;
|
||||||
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
use codex_core::config::load_config_as_toml_with_cli_overrides;
|
||||||
use codex_core::config::persist_model_selection;
|
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::AskForApproval;
|
||||||
use codex_core::protocol::SandboxPolicy;
|
use codex_core::protocol::SandboxPolicy;
|
||||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
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 {
|
match RolloutRecorder::list_conversations(&config.codex_home, 1, None).await {
|
||||||
Ok(page) => page
|
Ok(page) => page
|
||||||
.items
|
.items
|
||||||
@@ -352,7 +362,7 @@ async fn run_ratatui_app(
|
|||||||
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
|
.unwrap_or(resume_picker::ResumeSelection::StartFresh),
|
||||||
Err(_) => 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? {
|
match resume_picker::run_resume_picker(&mut tui, &config.codex_home).await? {
|
||||||
resume_picker::ResumeSelection::Exit => {
|
resume_picker::ResumeSelection::Exit => {
|
||||||
restore();
|
restore();
|
||||||
|
|||||||
@@ -12,6 +12,40 @@ Run Codex head-less in pipelines. Example GitHub Action step:
|
|||||||
codex exec --full-auto "update CHANGELOG for next release"
|
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 <SESSION_ID>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
## Tracing / verbose logging
|
||||||
|
|
||||||
Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior.
|
Because Codex is written in Rust, it honors the `RUST_LOG` environment variable to configure its logging behavior.
|
||||||
|
|||||||
@@ -10,19 +10,24 @@
|
|||||||
|
|
||||||
Key flags: `--model/-m`, `--ask-for-approval/-a`.
|
Key flags: `--model/-m`, `--ask-for-approval/-a`.
|
||||||
|
|
||||||
<!--
|
### Resuming interactive sessions
|
||||||
Resume options:
|
|
||||||
|
|
||||||
- `--resume`: open an interactive picker of recent sessions (shows a preview of the first real user message). Conflicts with `--continue`.
|
- Run `codex resume` to display the session picker UI
|
||||||
- `--continue`: resume the most recent session without showing the picker (falls back to starting fresh if none exist). Conflicts with `--resume`.
|
- Resume most recent: `codex resume --last`
|
||||||
|
- Resume by id: `codex resume <SESSION_ID>` (You can get session ids from /status or `~/.codex/sessions/`)
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
```shell
|
```shell
|
||||||
codex --resume
|
# Open a picker of recent sessions
|
||||||
codex --continue
|
codex resume
|
||||||
|
|
||||||
|
# Resume the most recent session
|
||||||
|
codex resume --last
|
||||||
|
|
||||||
|
# Resume a specific session by id
|
||||||
|
codex resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc
|
||||||
```
|
```
|
||||||
-->
|
|
||||||
|
|
||||||
### Running with a prompt as input
|
### Running with a prompt as input
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user