mirror of
https://github.com/openai/codex.git
synced 2026-10-30 19:18:49 +00:00
V1
This commit is contained in:
@@ -2125,7 +2125,17 @@ impl CodexMessageProcessor {
|
||||
.codex_home
|
||||
.join(codex_core::ARCHIVED_SESSIONS_SUBDIR);
|
||||
tokio::fs::create_dir_all(&archive_folder).await?;
|
||||
tokio::fs::rename(&canonical_rollout_path, &archive_folder.join(&file_name)).await?;
|
||||
let destination = archive_folder.join(&file_name);
|
||||
tracing::debug!(
|
||||
"archiving {conversation_id}: moving {} -> {}",
|
||||
canonical_rollout_path.display(),
|
||||
destination.display()
|
||||
);
|
||||
tokio::fs::rename(&canonical_rollout_path, &destination).await?;
|
||||
tracing::debug!(
|
||||
"archiving {conversation_id}: archived rollout moved to {}",
|
||||
destination.display()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
.await;
|
||||
|
||||
@@ -46,6 +46,7 @@ pub fn create_fake_rollout(
|
||||
instructions: None,
|
||||
source: SessionSource::Cli,
|
||||
model_provider: model_provider.map(str::to_string),
|
||||
name: None,
|
||||
})?;
|
||||
|
||||
let lines = [
|
||||
|
||||
@@ -100,6 +100,12 @@ enum Subcommand {
|
||||
/// Resume a previous interactive session (picker by default; use --last to continue the most recent).
|
||||
Resume(ResumeCommand),
|
||||
|
||||
/// Fork an existing session into a new conversation.
|
||||
Fork(ForkCommand),
|
||||
|
||||
/// Manage saved sessions.
|
||||
Session(SessionCommand),
|
||||
|
||||
/// [EXPERIMENTAL] Browse tasks from Codex Cloud and apply changes locally.
|
||||
#[clap(name = "cloud", alias = "cloud-tasks")]
|
||||
Cloud(CloudTasksCli),
|
||||
@@ -138,6 +144,28 @@ struct ResumeCommand {
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct ForkCommand {
|
||||
/// Resume from a saved session name or rollout id, but start a new conversation.
|
||||
#[arg(value_name = "ID|NAME")]
|
||||
target: String,
|
||||
|
||||
#[clap(flatten)]
|
||||
config_overrides: TuiCli,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SessionCommand {
|
||||
#[command(subcommand)]
|
||||
action: SessionSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, clap::Subcommand)]
|
||||
enum SessionSubcommand {
|
||||
/// List saved sessions created via `/save <name>`.
|
||||
List,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct SandboxArgs {
|
||||
#[command(subcommand)]
|
||||
@@ -460,6 +488,24 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Fork(ForkCommand {
|
||||
target,
|
||||
config_overrides,
|
||||
})) => {
|
||||
interactive = finalize_fork_interactive(
|
||||
interactive,
|
||||
root_config_overrides.clone(),
|
||||
target,
|
||||
config_overrides,
|
||||
);
|
||||
let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?;
|
||||
handle_app_exit(exit_info)?;
|
||||
}
|
||||
Some(Subcommand::Session(SessionCommand { action })) => match action {
|
||||
SessionSubcommand::List => {
|
||||
list_saved_sessions_cli().await?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::Login(mut login_cli)) => {
|
||||
prepend_config_flags(
|
||||
&mut login_cli.config_overrides,
|
||||
@@ -619,6 +665,7 @@ fn finalize_resume_interactive(
|
||||
interactive.resume_picker = resume_session_id.is_none() && !last;
|
||||
interactive.resume_last = last;
|
||||
interactive.resume_session_id = resume_session_id;
|
||||
interactive.fork_source = None;
|
||||
|
||||
// Merge resume-scoped flags and overrides with highest precedence.
|
||||
merge_resume_cli_flags(&mut interactive, resume_cli);
|
||||
@@ -629,6 +676,21 @@ fn finalize_resume_interactive(
|
||||
interactive
|
||||
}
|
||||
|
||||
fn finalize_fork_interactive(
|
||||
mut interactive: TuiCli,
|
||||
root_config_overrides: CliConfigOverrides,
|
||||
target: String,
|
||||
fork_cli: TuiCli,
|
||||
) -> TuiCli {
|
||||
interactive.resume_picker = false;
|
||||
interactive.resume_last = false;
|
||||
interactive.resume_session_id = None;
|
||||
interactive.fork_source = Some(target);
|
||||
merge_resume_cli_flags(&mut interactive, fork_cli);
|
||||
prepend_config_flags(&mut interactive.config_overrides, root_config_overrides);
|
||||
interactive
|
||||
}
|
||||
|
||||
/// Merge flags provided to `codex resume` so they take precedence over any
|
||||
/// root-level flags. Only overrides fields explicitly set on the resume-scoped
|
||||
/// CLI. Also appends `-c key=value` overrides with highest precedence.
|
||||
@@ -682,6 +744,35 @@ fn print_completion(cmd: CompletionCommand) {
|
||||
generate(cmd.shell, &mut app, name, &mut std::io::stdout());
|
||||
}
|
||||
|
||||
async fn list_saved_sessions_cli() -> anyhow::Result<()> {
|
||||
let codex_home = codex_core::config::find_codex_home()?;
|
||||
let entries = codex_core::list_saved_sessions(&codex_home).await?;
|
||||
if entries.is_empty() {
|
||||
println!("No saved sessions. Run `/save <name>` inside Codex to create one.");
|
||||
return Ok(());
|
||||
}
|
||||
println!(
|
||||
"{:<20} {:<36} {:<20} {:<12} CWD",
|
||||
"NAME", "CONVERSATION ID", "SAVED AT", "MODEL"
|
||||
);
|
||||
for entry in entries {
|
||||
let cid = entry.conversation_id.to_string();
|
||||
println!(
|
||||
"{:<20} {:<36} {:<20} {:<12} {}",
|
||||
entry.name,
|
||||
cid,
|
||||
entry.saved_at,
|
||||
entry.model,
|
||||
entry.cwd.display()
|
||||
);
|
||||
let created = entry.created_at.as_deref().unwrap_or("-");
|
||||
println!("{:<20} created: {}", "", created);
|
||||
println!("{:<20} path: {}", "", entry.rollout_path.display());
|
||||
println!();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -711,6 +802,26 @@ mod tests {
|
||||
finalize_resume_interactive(interactive, root_overrides, session_id, last, resume_cli)
|
||||
}
|
||||
|
||||
fn fork_from_args(args: &[&str]) -> TuiCli {
|
||||
let cli = MultitoolCli::try_parse_from(args).expect("parse");
|
||||
let MultitoolCli {
|
||||
interactive,
|
||||
config_overrides: root_overrides,
|
||||
subcommand,
|
||||
feature_toggles: _,
|
||||
} = cli;
|
||||
|
||||
let Subcommand::Fork(ForkCommand {
|
||||
target,
|
||||
config_overrides,
|
||||
}) = subcommand.expect("fork present")
|
||||
else {
|
||||
unreachable!()
|
||||
};
|
||||
|
||||
finalize_fork_interactive(interactive, root_overrides, target, config_overrides)
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation: Option<&str>) -> AppExitInfo {
|
||||
let token_usage = TokenUsage {
|
||||
output_tokens: 2,
|
||||
@@ -793,6 +904,15 @@ mod tests {
|
||||
assert_eq!(interactive.resume_session_id.as_deref(), Some("1234"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fork_sets_target_and_disables_resume_controls() {
|
||||
let interactive = fork_from_args(["codex", "fork", "saved"].as_ref());
|
||||
assert_eq!(interactive.fork_source.as_deref(), Some("saved"));
|
||||
assert!(!interactive.resume_picker);
|
||||
assert!(!interactive.resume_last);
|
||||
assert!(interactive.resume_session_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_merges_option_flags_and_full_auto() {
|
||||
let interactive = finalize_from_args(
|
||||
|
||||
@@ -147,6 +147,7 @@ pub struct Codex {
|
||||
pub struct CodexSpawnOk {
|
||||
pub codex: Codex,
|
||||
pub conversation_id: ConversationId,
|
||||
pub(crate) session: Arc<Session>,
|
||||
}
|
||||
|
||||
pub(crate) const INITIAL_SUBMIT_ID: &str = "";
|
||||
@@ -202,7 +203,8 @@ impl Codex {
|
||||
let conversation_id = session.conversation_id;
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
tokio::spawn(submission_loop(session, config, rx_sub));
|
||||
let submission_session = Arc::clone(&session);
|
||||
tokio::spawn(submission_loop(submission_session, config, rx_sub));
|
||||
let codex = Codex {
|
||||
next_id: AtomicU64::new(0),
|
||||
tx_sub,
|
||||
@@ -212,6 +214,7 @@ impl Codex {
|
||||
Ok(CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -616,15 +619,27 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Ensure all rollout writes are durably flushed.
|
||||
pub(crate) async fn flush_rollout(&self) {
|
||||
pub(crate) async fn flush_rollout(&self) -> std::io::Result<()> {
|
||||
let recorder = {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(rec) = recorder
|
||||
&& let Err(e) = rec.flush().await
|
||||
{
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
if let Some(rec) = recorder {
|
||||
rec.flush().await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn set_session_name(&self, name: Option<String>) -> std::io::Result<()> {
|
||||
let recorder = {
|
||||
let guard = self.services.rollout.lock().await;
|
||||
guard.clone()
|
||||
};
|
||||
if let Some(rec) = recorder {
|
||||
rec.set_session_name(name).await
|
||||
} else {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -643,7 +658,9 @@ impl Session {
|
||||
let items = self.build_initial_context(&turn_context);
|
||||
self.record_conversation_items(&turn_context, &items).await;
|
||||
// Ensure initial items are visible to immediate readers (e.g., tests, forks).
|
||||
self.flush_rollout().await;
|
||||
if let Err(e) = self.flush_rollout().await {
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
}
|
||||
InitialHistory::Resumed(_) | InitialHistory::Forked(_) => {
|
||||
let rollout_items = conversation_history.get_rollout_items();
|
||||
@@ -689,7 +706,9 @@ impl Session {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
// Flush after seeding history and any persisted rollout copy.
|
||||
self.flush_rollout().await;
|
||||
if let Err(e) = self.flush_rollout().await {
|
||||
warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::Session;
|
||||
use crate::error::Result as CodexResult;
|
||||
use crate::protocol::Event;
|
||||
use crate::protocol::Op;
|
||||
use crate::protocol::Submission;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct CodexConversation {
|
||||
codex: Codex,
|
||||
rollout_path: PathBuf,
|
||||
session: Arc<Session>,
|
||||
}
|
||||
|
||||
/// Conduit for the bidirectional stream of messages that compose a conversation
|
||||
/// in Codex.
|
||||
impl CodexConversation {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: PathBuf) -> Self {
|
||||
pub(crate) fn new(codex: Codex, rollout_path: PathBuf, session: Arc<Session>) -> Self {
|
||||
Self {
|
||||
codex,
|
||||
rollout_path,
|
||||
session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +40,12 @@ impl CodexConversation {
|
||||
pub fn rollout_path(&self) -> PathBuf {
|
||||
self.rollout_path.clone()
|
||||
}
|
||||
|
||||
pub async fn flush_rollout(&self) -> std::io::Result<()> {
|
||||
self.session.flush_rollout().await
|
||||
}
|
||||
|
||||
pub async fn set_session_name(&self, name: Option<String>) -> std::io::Result<()> {
|
||||
self.session.set_session_name(name).await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use crate::CodexAuth;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnOk;
|
||||
use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::codex::Session;
|
||||
use crate::codex_conversation::CodexConversation;
|
||||
use crate::config::Config;
|
||||
use crate::error::CodexErr;
|
||||
@@ -69,6 +70,7 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -76,13 +78,14 @@ impl ConversationManager {
|
||||
self.session_source.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
|
||||
async fn finalize_spawn(
|
||||
&self,
|
||||
codex: Codex,
|
||||
conversation_id: ConversationId,
|
||||
session: Arc<Session>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
// The first event must be `SessionInitialized`. Validate and forward it
|
||||
// to the caller so that they can display it in the conversation
|
||||
@@ -101,6 +104,7 @@ impl ConversationManager {
|
||||
let conversation = Arc::new(CodexConversation::new(
|
||||
codex,
|
||||
session_configured.rollout_path.clone(),
|
||||
session,
|
||||
));
|
||||
self.conversations
|
||||
.write()
|
||||
@@ -145,6 +149,7 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(
|
||||
config,
|
||||
auth_manager,
|
||||
@@ -152,7 +157,23 @@ impl ConversationManager {
|
||||
self.session_source.clone(),
|
||||
)
|
||||
.await?;
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
|
||||
pub async fn fork_from_rollout(
|
||||
&self,
|
||||
config: Config,
|
||||
path: PathBuf,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
) -> CodexResult<NewConversation> {
|
||||
let initial_history = RolloutRecorder::get_rollout_history(&path).await?;
|
||||
let forked = match initial_history {
|
||||
InitialHistory::Resumed(resumed) => InitialHistory::Forked(resumed.history),
|
||||
InitialHistory::Forked(items) => InitialHistory::Forked(items),
|
||||
InitialHistory::New => InitialHistory::New,
|
||||
};
|
||||
self.resume_conversation_with_history(config, forked, auth_manager)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes the conversation from the manager's internal map, though the
|
||||
@@ -185,9 +206,10 @@ impl ConversationManager {
|
||||
let CodexSpawnOk {
|
||||
codex,
|
||||
conversation_id,
|
||||
session,
|
||||
} = Codex::spawn(config, auth_manager, history, self.session_source.clone()).await?;
|
||||
|
||||
self.finalize_spawn(codex, conversation_id).await
|
||||
self.finalize_spawn(codex, conversation_id, session).await
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ mod openai_model_info;
|
||||
pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod saved_sessions;
|
||||
pub mod seatbelt;
|
||||
pub mod shell;
|
||||
pub mod spawn;
|
||||
@@ -81,6 +82,11 @@ pub use rollout::list::ConversationsPage;
|
||||
pub use rollout::list::Cursor;
|
||||
pub use rollout::list::parse_cursor;
|
||||
pub use rollout::list::read_head_for_summary;
|
||||
pub use saved_sessions::SavedSessionEntry;
|
||||
pub use saved_sessions::build_saved_session_entry;
|
||||
pub use saved_sessions::list_saved_sessions;
|
||||
pub use saved_sessions::resolve_saved_session;
|
||||
pub use saved_sessions::upsert_saved_session;
|
||||
mod function_tool;
|
||||
mod state;
|
||||
mod tasks;
|
||||
|
||||
@@ -11,6 +11,8 @@ use serde_json::Value;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::FormatItem;
|
||||
use time::macros::format_description;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncSeekExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc::Sender;
|
||||
use tokio::sync::mpsc::{self};
|
||||
@@ -70,6 +72,10 @@ enum RolloutCmd {
|
||||
Shutdown {
|
||||
ack: oneshot::Sender<()>,
|
||||
},
|
||||
SetName {
|
||||
name: Option<String>,
|
||||
ack: oneshot::Sender<std::io::Result<()>>,
|
||||
},
|
||||
}
|
||||
|
||||
impl RolloutRecorderParams {
|
||||
@@ -148,11 +154,14 @@ impl RolloutRecorder {
|
||||
instructions,
|
||||
source,
|
||||
model_provider: Some(config.model_provider_id.clone()),
|
||||
name: None,
|
||||
}),
|
||||
)
|
||||
}
|
||||
RolloutRecorderParams::Resume { path } => (
|
||||
tokio::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?,
|
||||
@@ -196,6 +205,21 @@ impl RolloutRecorder {
|
||||
.map_err(|e| IoError::other(format!("failed to queue rollout items: {e}")))
|
||||
}
|
||||
|
||||
/// Update the session name stored in the rollout's SessionMeta line.
|
||||
pub async fn set_session_name(&self, name: Option<String>) -> std::io::Result<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
self.tx
|
||||
.send(RolloutCmd::SetName { name, ack: tx })
|
||||
.await
|
||||
.map_err(|e| IoError::other(format!("failed to queue session name update: {e}")))?;
|
||||
match rx.await {
|
||||
Ok(result) => result,
|
||||
Err(e) => Err(IoError::other(format!(
|
||||
"failed waiting for session name update: {e}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush all queued writes and wait until they are committed by the writer task.
|
||||
pub async fn flush(&self) -> std::io::Result<()> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
@@ -334,6 +358,8 @@ fn create_log_file(
|
||||
|
||||
let path = dir.join(filename);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
|
||||
.append(true)
|
||||
.create(true)
|
||||
.open(&path)?;
|
||||
@@ -389,6 +415,10 @@ async fn rollout_writer(
|
||||
RolloutCmd::Shutdown { ack } => {
|
||||
let _ = ack.send(());
|
||||
}
|
||||
RolloutCmd::SetName { name, ack } => {
|
||||
let result = rewrite_session_meta_name(&mut writer.file, name).await;
|
||||
let _ = ack.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -422,3 +452,38 @@ impl JsonlWriter {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn rewrite_session_meta_name(
|
||||
file: &mut tokio::fs::File,
|
||||
name: Option<String>,
|
||||
) -> std::io::Result<()> {
|
||||
use std::io::SeekFrom;
|
||||
|
||||
file.flush().await?;
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
let mut contents = Vec::new();
|
||||
file.read_to_end(&mut contents).await?;
|
||||
if contents.is_empty() {
|
||||
return Err(IoError::other("empty rollout file"));
|
||||
}
|
||||
let newline_idx = contents
|
||||
.iter()
|
||||
.position(|&b| b == b'\n')
|
||||
.ok_or_else(|| IoError::other("rollout missing newline after SessionMeta"))?;
|
||||
let first_line = &contents[..newline_idx];
|
||||
let mut rollout_line: RolloutLine = serde_json::from_slice(first_line)
|
||||
.map_err(|e| IoError::other(format!("failed to parse SessionMeta: {e}")))?;
|
||||
let RolloutItem::SessionMeta(ref mut session_meta_line) = rollout_line.item else {
|
||||
return Err(IoError::other("first rollout item is not SessionMeta"));
|
||||
};
|
||||
session_meta_line.meta.name = name;
|
||||
let mut updated = serde_json::to_vec(&rollout_line)?;
|
||||
updated.push(b'\n');
|
||||
updated.extend_from_slice(&contents[newline_idx + 1..]);
|
||||
file.set_len(0).await?;
|
||||
file.seek(SeekFrom::Start(0)).await?;
|
||||
file.write_all(&updated).await?;
|
||||
file.flush().await?;
|
||||
file.seek(SeekFrom::End(0)).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -594,6 +594,7 @@ async fn test_tail_includes_last_response_items() -> Result<()> {
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
name: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
@@ -687,6 +688,7 @@ async fn test_tail_handles_short_sessions() -> Result<()> {
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
name: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
@@ -781,6 +783,7 @@ async fn test_tail_skips_trailing_non_responses() -> Result<()> {
|
||||
cli_version: "test_version".into(),
|
||||
source: SessionSource::VSCode,
|
||||
model_provider: Some("test-provider".into()),
|
||||
name: None,
|
||||
},
|
||||
git: None,
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
use crate::rollout::list::read_head_for_summary;
|
||||
use codex_protocol::ConversationId;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::collections::BTreeMap;
|
||||
use std::io::Error as IoError;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SavedSessionEntry {
|
||||
pub name: String,
|
||||
pub conversation_id: ConversationId,
|
||||
pub rollout_path: PathBuf,
|
||||
pub cwd: PathBuf,
|
||||
pub model: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model_provider: Option<String>,
|
||||
pub saved_at: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub created_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct SavedSessionsFile {
|
||||
#[serde(default)]
|
||||
entries: BTreeMap<String, SavedSessionEntry>,
|
||||
}
|
||||
|
||||
fn saved_sessions_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("saved_sessions.json")
|
||||
}
|
||||
|
||||
async fn load_saved_sessions_file(path: &Path) -> std::io::Result<SavedSessionsFile> {
|
||||
match tokio::fs::read_to_string(path).await {
|
||||
Ok(text) => serde_json::from_str(&text)
|
||||
.map_err(|e| IoError::other(format!("failed to parse saved sessions: {e}"))),
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => Ok(SavedSessionsFile::default()),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
async fn write_saved_sessions_file(path: &Path, file: &SavedSessionsFile) -> std::io::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(file)
|
||||
.map_err(|e| IoError::other(format!("failed to serialize saved sessions: {e}")))?;
|
||||
let tmp_path = path.with_extension("json.tmp");
|
||||
tokio::fs::write(&tmp_path, json).await?;
|
||||
tokio::fs::rename(tmp_path, path).await
|
||||
}
|
||||
|
||||
/// Create a new entry from the rollout's SessionMeta line.
|
||||
pub async fn build_saved_session_entry(
|
||||
name: String,
|
||||
rollout_path: PathBuf,
|
||||
model: String,
|
||||
) -> std::io::Result<SavedSessionEntry> {
|
||||
let head = read_head_for_summary(&rollout_path).await?;
|
||||
let first = head.first().ok_or_else(|| {
|
||||
IoError::other(format!(
|
||||
"rollout at {} has no SessionMeta",
|
||||
rollout_path.display()
|
||||
))
|
||||
})?;
|
||||
let SessionMetaLine { mut meta, .. } = serde_json::from_value::<SessionMetaLine>(first.clone())
|
||||
.map_err(|e| IoError::other(format!("failed to parse SessionMeta: {e}")))?;
|
||||
meta.name = Some(name.clone());
|
||||
let saved_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
|
||||
let created_at = if meta.timestamp.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(meta.timestamp.clone())
|
||||
};
|
||||
Ok(SavedSessionEntry {
|
||||
name,
|
||||
conversation_id: meta.id,
|
||||
rollout_path,
|
||||
cwd: meta.cwd,
|
||||
model,
|
||||
model_provider: meta.model_provider,
|
||||
saved_at,
|
||||
created_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn upsert_saved_session(
|
||||
codex_home: &Path,
|
||||
entry: SavedSessionEntry,
|
||||
) -> std::io::Result<()> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let mut file = load_saved_sessions_file(&path).await?;
|
||||
file.entries.insert(entry.name.clone(), entry);
|
||||
write_saved_sessions_file(&path, &file).await
|
||||
}
|
||||
|
||||
pub async fn resolve_saved_session(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<SavedSessionEntry>> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let file = load_saved_sessions_file(&path).await?;
|
||||
Ok(file.entries.get(name).cloned())
|
||||
}
|
||||
|
||||
pub async fn list_saved_sessions(codex_home: &Path) -> std::io::Result<Vec<SavedSessionEntry>> {
|
||||
let path = saved_sessions_path(codex_home);
|
||||
let file = load_saved_sessions_file(&path).await?;
|
||||
let mut entries: Vec<SavedSessionEntry> = file.entries.values().cloned().collect();
|
||||
entries.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -128,7 +128,9 @@ impl Session {
|
||||
task_cancellation_token.child_token(),
|
||||
)
|
||||
.await;
|
||||
session_ctx.clone_session().flush_rollout().await;
|
||||
if let Err(e) = session_ctx.clone_session().flush_rollout().await {
|
||||
tracing::warn!("failed to flush rollout recorder: {e}");
|
||||
}
|
||||
if !task_cancellation_token.is_cancelled() {
|
||||
// Emit completion uniformly from spawn site so all tasks share the same lifecycle.
|
||||
let sess = session_ctx.clone_session();
|
||||
|
||||
@@ -1088,6 +1088,8 @@ pub struct SessionMeta {
|
||||
#[serde(default)]
|
||||
pub source: SessionSource,
|
||||
pub model_provider: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for SessionMeta {
|
||||
@@ -1101,6 +1103,7 @@ impl Default for SessionMeta {
|
||||
instructions: None,
|
||||
source: SessionSource::default(),
|
||||
model_provider: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
# Saved Session / Fork Design (Closer to `codex resume`)
|
||||
|
||||
## Overview
|
||||
|
||||
This design keeps as close as possible to the existing `codex resume` behavior, but adds:
|
||||
|
||||
- In‑session slash command:
|
||||
- `/save <NAME>` – snapshot the current session so it can be resumed/forked later.
|
||||
- CLI commands:
|
||||
- `codex resume ID|NAME` – existing behavior, extended so `<NAME>` aliases a saved session.
|
||||
- `codex fork ID|NAME` – new: start a new session as a copy of a saved session or existing id.
|
||||
- `codex session list` – list all **saved** sessions (by `<NAME>` and underlying id).
|
||||
|
||||
The underlying persistence remains the existing rollout files under `~/.codex/sessions/...`. “Saved sessions” are thin, named pointers to snapshots of those rollouts.
|
||||
|
||||
---
|
||||
|
||||
## Behavior Summary
|
||||
|
||||
- `/save <NAME>` (in-session)
|
||||
- Takes the **current state of the conversation** (as if you hit Ctrl‑C and then later did `codex resume <id>`).
|
||||
- Writes or updates a saved entry with user‑chosen `<NAME>`.
|
||||
- If `<NAME>` already exists, it is **overwritten** to point at the new snapshot (this matches “override the list of sessions” semantics).
|
||||
|
||||
- `codex resume ID|NAME` (CLI)
|
||||
- Behaves like `codex resume <ID>` today.
|
||||
- Additionally, if `ID|NAME` matches a saved `<NAME>`, it is resolved to the underlying saved snapshot and resumed from there.
|
||||
|
||||
- `codex fork ID|NAME` (CLI)
|
||||
- Like `codex resume`, but starts a **new session** with a **new conversation id**, using the same snapshot as the source.
|
||||
- `ID|NAME` can be either:
|
||||
- A saved name created via `/save`, or
|
||||
- A raw conversation id, in which case the fork is created from that rollout.
|
||||
|
||||
- `codex session list`
|
||||
- Lists all **saved** sessions:
|
||||
- `<NAME>`, underlying conversation id, created_at/saved_at, cwd, model, and rollout path.
|
||||
- The list is entirely driven by `/save`; unsaved transient sessions do not appear here.
|
||||
|
||||
---
|
||||
|
||||
## Data Model
|
||||
|
||||
We build entirely on top of the **existing session persistence** (rollout files under `~/.codex/sessions/...`) and add a **name field on the session itself** instead of a separate registry.
|
||||
|
||||
### Session name
|
||||
|
||||
Conceptually, a session has:
|
||||
|
||||
```rust
|
||||
struct SessionMeta {
|
||||
id: ConversationId, // existing UUID
|
||||
// ...
|
||||
name: Option<String>, // NEW: user-supplied `<NAME>`, if any
|
||||
}
|
||||
```
|
||||
|
||||
Concretely:
|
||||
|
||||
- We extend the existing `SessionMeta` (the struct written in the first JSONL record of each rollout) with an optional `name: Option<String>`.
|
||||
- The app server’s `Thread` view can surface this name so the TUI has easy access to it.
|
||||
|
||||
**Storage**
|
||||
|
||||
- No new files are introduced.
|
||||
- The session name is persisted **with the session rollout itself**:
|
||||
- Either directly in `SessionMeta.name`, or
|
||||
- Via a small additional rollout item that updates the name (implementation detail).
|
||||
- Listing / resolving sessions uses the existing rollout listing mechanisms (`RolloutRecorder::list_conversations`, app‑server `ThreadList`) and simply reads the name field.
|
||||
|
||||
Notes:
|
||||
|
||||
- **Name uniqueness** is a logical constraint, not a separate registry:
|
||||
- Saving with the same `<NAME>` again just updates the name on the current session.
|
||||
- For resolution, we treat `<NAME>` as an alias for the “most recent” session with that name (older sessions with the same name effectively fall out of the saved list).
|
||||
- Underlying rollout files remain in the existing `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` structure; we just annotate them with an optional name.
|
||||
|
||||
### Relationship to existing types
|
||||
|
||||
- We reuse:
|
||||
- `RolloutRecorder` for persistence.
|
||||
- `RolloutRecorder::list_conversations` / `find_conversation_path_by_id_str` for discovery.
|
||||
- `InitialHistory::Resumed` + `ConversationManager::resume_conversation_from_rollout` and `resume_conversation_with_history` for resuming.
|
||||
- The existing “resume by id” flow; `<NAME>` just resolves to the underlying conversation id/path using the stored name.
|
||||
|
||||
---
|
||||
|
||||
## `/save <NAME>`
|
||||
|
||||
### Semantics
|
||||
|
||||
- User issues `/save <NAME>` from within a Codex session (TUI).
|
||||
- Codex:
|
||||
- Flushes the current rollout so all events up to this point are durably written (as if the process were about to exit).
|
||||
- Records a **saved snapshot** pointing to this rollout file and conversation id.
|
||||
- If a saved entry with the same `<NAME>` already exists, it is **replaced**:
|
||||
- The session list now reflects only the latest snapshot for that name.
|
||||
|
||||
This matches the mental model of “if I hit Ctrl‑C right now, I can later `codex resume <id>` from this point”, just with a human‑friendly name instead of a raw UUID.
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
TUI:
|
||||
|
||||
- Recognize `/save <NAME>` as a local command (do not send it to the model).
|
||||
- Call a new app‑server RPC, e.g. `session/save`:
|
||||
|
||||
```rust
|
||||
struct SessionSaveParams {
|
||||
thread_id: String, // current thread (maps to ConversationId)
|
||||
name: String, // user-specified `<NAME>`
|
||||
}
|
||||
```
|
||||
|
||||
App server:
|
||||
|
||||
- Resolve `(ConversationId, CodexConversation)` from `thread_id`.
|
||||
- Ask the underlying session to flush the rollout:
|
||||
- e.g. `session.flush_rollout().await` (or equivalent).
|
||||
- Update the session metadata to set `SessionMeta.name = Some(<NAME>)`, and ensure that change is persisted to the rollout.
|
||||
- If another session already has the same name, the system will treat the **most recent** session with that name as canonical when resolving `<NAME>`.
|
||||
|
||||
No new persistence format is required beyond adding the `name` field to existing session metadata.
|
||||
|
||||
---
|
||||
|
||||
## `codex resume ID|NAME`
|
||||
|
||||
### Semantics
|
||||
|
||||
- `codex resume <ARG>` (CLI) keeps existing behavior, but with an extra resolution step:
|
||||
- If `<ARG>` matches a saved `<NAME>` in `saved_sessions.json`, resume from that saved session.
|
||||
- Otherwise, treat `<ARG>` as a raw conversation id and use the current resume behavior.
|
||||
|
||||
This means:
|
||||
|
||||
- Existing workflows (`codex resume <uuid>`, `codex resume --last`) continue to work.
|
||||
- A user can type `codex resume codex-core` after doing `/save codex-core` in a session.
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
CLI:
|
||||
|
||||
- Extend the existing `codex resume` subcommand resolution:
|
||||
|
||||
1. Try to resolve `<ARG>` as a saved name:
|
||||
- Use rollout listing APIs (`RolloutRecorder::list_conversations` or app‑server `ThreadList`) to find sessions whose `SessionMeta.name == <ARG>`, pick the most recent one, and obtain `(conversation_id, rollout_path)`.
|
||||
2. If found, resume from that rollout path (see below).
|
||||
3. If not found, fall back to the existing id‑based lookup using `find_conversation_path_by_id_str`.
|
||||
|
||||
App server:
|
||||
|
||||
- The CLI already uses app‑server APIs to resume conversations; the new behavior only needs to:
|
||||
- Inject the correct `path` or `conversation_id` into the existing `ResumeConversationParams`, based on the resolution above.
|
||||
- Existing logic in `handle_resume_conversation` (which uses `RolloutRecorder::get_rollout_history` and `ConversationManager::resume_conversation_with_history`) remains unchanged.
|
||||
|
||||
---
|
||||
|
||||
## `codex fork ID|NAME`
|
||||
|
||||
### Semantics
|
||||
|
||||
- `codex fork <ARG>` (CLI) creates a **new** conversation whose initial state is copied from an existing one:
|
||||
- If `<ARG>` is a saved name, resolve it to the saved rollout path and conversation id.
|
||||
- If `<ARG>` is a raw id, locate its rollout using `find_conversation_path_by_id_str`.
|
||||
- In both cases, a new conversation is spawned with:
|
||||
- A **new conversation id**.
|
||||
- Initial history loaded from the source rollout.
|
||||
|
||||
This is the “explore once, fork many times” workflow driven from the CLI:
|
||||
|
||||
- Explore `codex-core` in TUI.
|
||||
- `/save codex-core`.
|
||||
- `codex fork codex-core` for “feature A”.
|
||||
- `codex fork codex-core` again for “feature B`.
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
CLI:
|
||||
|
||||
- Add a new subcommand:
|
||||
|
||||
```text
|
||||
codex fork <ID|NAME>
|
||||
```
|
||||
|
||||
- Resolution:
|
||||
1. Try `<ID|NAME>` as a saved name by scanning sessions whose `SessionMeta.name == <ID|NAME>` and picking the most recent one.
|
||||
2. If not found, treat it as a raw id and call `find_conversation_path_by_id_str`.
|
||||
3. If still not found, print a helpful error.
|
||||
|
||||
- Once a `rollout_path` is known, call an app‑server method (or use the existing resume machinery) that:
|
||||
- Loads `InitialHistory` via `RolloutRecorder::get_rollout_history(&rollout_path)`.
|
||||
- Spawns a new conversation with `ConversationManager::resume_conversation_with_history`.
|
||||
|
||||
App server:
|
||||
|
||||
- We can either:
|
||||
- Add a dedicated `session/fork` RPC that accepts `path` or `conversation_id`, or
|
||||
- Reuse the existing `ResumeConversationParams` and add a small wrapper on the server that:
|
||||
- Loads history from the given rollout.
|
||||
- Spawns a new conversation with that history (instead of attaching to an existing id).
|
||||
|
||||
In both cases, the “fork” operation is implemented entirely in terms of existing rollout + `InitialHistory` plumbing; the only new behavior is that it always uses a fresh conversation id.
|
||||
|
||||
---
|
||||
|
||||
## `codex session list`
|
||||
|
||||
### Semantics
|
||||
|
||||
`codex session list` prints the list of **saved** sessions, one per line:
|
||||
|
||||
- `<NAME>` (user‑chosen)
|
||||
- Underlying conversation id
|
||||
- `cwd`
|
||||
- `model`
|
||||
- `created_at` and/or `updated_at`
|
||||
|
||||
This gives users a quick way to discover what names they can use with `/resume`, `/fork`, or `codex resume`.
|
||||
|
||||
### Implementation sketch
|
||||
|
||||
CLI:
|
||||
|
||||
- Add a new subcommand, conceptually:
|
||||
|
||||
```text
|
||||
codex session list
|
||||
```
|
||||
|
||||
- The CLI loads configuration, locates `codex_home`, then:
|
||||
- Uses `RolloutRecorder::list_conversations` (or app‑server `ThreadList`) to iterate over sessions.
|
||||
- Filters down to those with a non‑empty `SessionMeta.name`.
|
||||
- Prints entries in a compact table format, grouping by name if multiple sessions share the same one (showing only the most recent for each name).
|
||||
|
||||
No separate registry file is needed; we derive the list directly from persisted session metadata.
|
||||
|
||||
---
|
||||
|
||||
## UX Notes & Edge Cases
|
||||
|
||||
- **Overwriting names**
|
||||
- `/save <NAME>` **always overwrites** any existing saved entry with that name.
|
||||
- This matches the “override the list of sessions” intent: the list reflects the latest saved snapshots for each name.
|
||||
|
||||
- **Continuing after `/save`**
|
||||
- After `/save`, the current conversation continues as normal.
|
||||
- The saved entry points at the rollout **as it existed at the time of save**:
|
||||
- On resume/fork we read the rollout file; because we flushed on save, the state is consistent with how the session looked at that moment.
|
||||
- Additional events recorded after `/save` will also end up in the same rollout file; conceptually this makes the “saved snapshot” move forward in time as you keep using the same session. If we want a truly frozen snapshot, we can later add an option to write to a dedicated copy of the rollout.
|
||||
|
||||
- **Name vs id collisions**
|
||||
- If a user picks a `<NAME>` that happens to look like a UUID, name resolution still prefers explicit saved entries:
|
||||
- First look up saved name.
|
||||
- If not found, treat as id.
|
||||
|
||||
- **Listing behavior**
|
||||
- `codex session list` only shows saved sessions, not all historical rollouts.
|
||||
- This keeps the list small and intentional; users explicitly control it via `/save`.
|
||||
|
||||
- **Deletion**
|
||||
- Initial version can omit deletion; names can be overwritten by re‑saving.
|
||||
- A future `codex session delete <NAME>` and/or `/delete-session <NAME>` command could remove entries from `saved_sessions.json`.
|
||||
|
||||
---
|
||||
|
||||
## Implementation Phasing
|
||||
|
||||
1. **Saved registry + resolution**
|
||||
- Implement `SavedSessionEntry` and `saved_sessions.json` read/write helpers.
|
||||
- Implement name → entry resolution and fallback to id → rollout path using existing `find_conversation_path_by_id_str`.
|
||||
|
||||
2. **App server RPCs + TUI slash command**
|
||||
- Add `session/save` RPC.
|
||||
- Wire `/save <NAME>` in TUI to `session/save`.
|
||||
|
||||
3. **CLI integration**
|
||||
- Extend `codex resume` to accept `<NAME>` as well as `<ID>`.
|
||||
- Add `codex fork <ID|NAME>` that uses the same resolution and history loading, but always spawns a new conversation.
|
||||
- Add `codex session list` to show saved sessions.
|
||||
|
||||
This keeps the design very close to today’s `codex resume` behavior while adding the ergonomics you described: explicit `/save <NAME>`, `/fork <NAME>`, `/resume <NAME>`, and a simple `codex session list` view.
|
||||
@@ -21,6 +21,7 @@ use codex_common::model_presets::ModelUpgrade;
|
||||
use codex_common::model_presets::all_model_presets;
|
||||
use codex_core::AuthManager;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_core::build_saved_session_entry;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::edit::ConfigEditsBuilder;
|
||||
use codex_core::model_family::find_family_for_model;
|
||||
@@ -28,6 +29,7 @@ use codex_core::protocol::FinalOutput;
|
||||
use codex_core::protocol::SessionSource;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
use codex_core::protocol_config_types::ReasoningEffort as ReasoningEffortConfig;
|
||||
use codex_core::upsert_saved_session;
|
||||
use codex_protocol::ConversationId;
|
||||
use color_eyre::eyre::Result;
|
||||
use color_eyre::eyre::WrapErr;
|
||||
@@ -261,6 +263,27 @@ impl App {
|
||||
resumed.session_configured,
|
||||
)
|
||||
}
|
||||
ResumeSelection::Fork(path) => {
|
||||
let resumed = conversation_manager
|
||||
.fork_from_rollout(config.clone(), path.clone(), auth_manager.clone())
|
||||
.await
|
||||
.wrap_err_with(|| format!("Failed to fork session from {}", path.display()))?;
|
||||
let init = crate::chatwidget::ChatWidgetInit {
|
||||
config: config.clone(),
|
||||
frame_requester: tui.frame_requester(),
|
||||
app_event_tx: app_event_tx.clone(),
|
||||
initial_prompt: initial_prompt.clone(),
|
||||
initial_images: initial_images.clone(),
|
||||
enhanced_keys_supported,
|
||||
auth_manager: auth_manager.clone(),
|
||||
feedback: feedback.clone(),
|
||||
};
|
||||
ChatWidget::new_from_existing(
|
||||
init,
|
||||
resumed.conversation,
|
||||
resumed.session_configured,
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone());
|
||||
@@ -386,6 +409,96 @@ impl App {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn save_session(&mut self, requested_name: String) {
|
||||
let name = requested_name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
self.chat_widget
|
||||
.add_error_message("Usage: /save <name>".to_string());
|
||||
return;
|
||||
}
|
||||
let Some(conversation_id) = self.chat_widget.conversation_id() else {
|
||||
self.chat_widget.add_error_message(
|
||||
"Session is not ready yet; try /save again in a moment.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let Some(rollout_path) = self.chat_widget.rollout_path() else {
|
||||
self.chat_widget.add_error_message(
|
||||
"Rollout path is not available yet; try /save again shortly.".to_string(),
|
||||
);
|
||||
return;
|
||||
};
|
||||
let conversation = match self.server.get_conversation(conversation_id).await {
|
||||
Ok(conv) => conv,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
conversation_id = %conversation_id,
|
||||
error = %err,
|
||||
"failed to look up conversation for /save"
|
||||
);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save session '{name}': {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = conversation.flush_rollout().await {
|
||||
tracing::error!(
|
||||
conversation_id = %conversation_id,
|
||||
error = %err,
|
||||
"failed to flush rollout before /save"
|
||||
);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save session '{name}': {err}"));
|
||||
return;
|
||||
}
|
||||
if let Err(err) = conversation.set_session_name(Some(name.clone())).await {
|
||||
tracing::error!(
|
||||
conversation_id = %conversation_id,
|
||||
error = %err,
|
||||
"failed to update session name before /save"
|
||||
);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save session '{name}': {err}"));
|
||||
return;
|
||||
}
|
||||
let model = self.chat_widget.config_ref().model.clone();
|
||||
let entry = match build_saved_session_entry(name.clone(), rollout_path.clone(), model).await
|
||||
{
|
||||
Ok(entry) => entry,
|
||||
Err(err) => {
|
||||
tracing::error!(
|
||||
rollout = %rollout_path.display(),
|
||||
error = %err,
|
||||
"failed to build saved session entry"
|
||||
);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save session '{name}': {err}"));
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(err) = upsert_saved_session(&self.config.codex_home, entry.clone()).await {
|
||||
tracing::error!(
|
||||
rollout = %rollout_path.display(),
|
||||
error = %err,
|
||||
"failed to persist saved session entry"
|
||||
);
|
||||
self.chat_widget
|
||||
.add_error_message(format!("Failed to save session '{name}': {err}"));
|
||||
return;
|
||||
}
|
||||
let hint = format!(
|
||||
"Resume with `codex resume {}` or fork with `codex fork {}`.",
|
||||
entry.name, entry.name
|
||||
);
|
||||
self.chat_widget.add_info_message(
|
||||
format!(
|
||||
"Saved session '{}' (conversation {}).",
|
||||
entry.name, entry.conversation_id
|
||||
),
|
||||
Some(hint),
|
||||
);
|
||||
}
|
||||
|
||||
async fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result<bool> {
|
||||
match event {
|
||||
AppEvent::NewSession => {
|
||||
@@ -468,6 +581,9 @@ impl App {
|
||||
AppEvent::ConversationHistory(ev) => {
|
||||
self.on_conversation_history_for_backtrack(tui, ev).await?;
|
||||
}
|
||||
AppEvent::SaveSession { name } => {
|
||||
self.save_session(name).await;
|
||||
}
|
||||
AppEvent::ExitRequest => {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
@@ -136,6 +136,11 @@ pub(crate) enum AppEvent {
|
||||
/// Forwarded conversation history snapshot from the current conversation.
|
||||
ConversationHistory(ConversationPathResponseEvent),
|
||||
|
||||
/// Save the current session under a user-provided name.
|
||||
SaveSession {
|
||||
name: String,
|
||||
},
|
||||
|
||||
/// Open the branch picker option from the review popup.
|
||||
OpenReviewBranchPicker(PathBuf),
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum InputResult {
|
||||
Submitted(String),
|
||||
Command(SlashCommand),
|
||||
Command { command: SlashCommand, args: String },
|
||||
None,
|
||||
}
|
||||
|
||||
@@ -333,6 +333,14 @@ impl ChatComposer {
|
||||
PasteBurst::recommended_flush_delay()
|
||||
}
|
||||
|
||||
fn command_args_from_line(line: &str, command: SlashCommand) -> String {
|
||||
if let Some((name, rest)) = parse_slash_name(line)
|
||||
&& name == command.command() {
|
||||
return rest.to_string();
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -505,8 +513,9 @@ impl ChatComposer {
|
||||
if let Some(sel) = popup.selected_item() {
|
||||
match sel {
|
||||
CommandItem::Builtin(cmd) => {
|
||||
let args = Self::command_args_from_line(first_line, cmd);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
return (InputResult::Command { command: cmd, args }, true);
|
||||
}
|
||||
CommandItem::UserPrompt(idx) => {
|
||||
if let Some(prompt) = popup.prompt(idx) {
|
||||
@@ -920,22 +929,21 @@ impl ChatComposer {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
} => {
|
||||
// If the first line is a bare built-in slash command (no args),
|
||||
// dispatch it even when the slash popup isn't visible. This preserves
|
||||
// the workflow: type a prefix ("/di"), press Tab to complete to
|
||||
// "/diff ", then press Enter to run it. Tab moves the cursor beyond
|
||||
// the '/name' token and our caret-based heuristic hides the popup,
|
||||
// but Enter should still dispatch the command rather than submit
|
||||
// literal text.
|
||||
// If the first line is a built-in slash command, dispatch it even when
|
||||
// the slash popup isn't visible. This preserves the workflow:
|
||||
// type a prefix ("/di"), press Tab to complete to "/diff ", then press
|
||||
// Enter to run it. Tab moves the cursor beyond the '/name' token and
|
||||
// our caret-based heuristic hides the popup, but Enter should still
|
||||
// dispatch the command rather than submit literal text.
|
||||
let first_line = self.textarea.text().lines().next().unwrap_or("");
|
||||
if let Some((name, rest)) = parse_slash_name(first_line)
|
||||
&& rest.is_empty()
|
||||
if let Some((name, _rest)) = parse_slash_name(first_line)
|
||||
&& let Some((_n, cmd)) = built_in_slash_commands()
|
||||
.into_iter()
|
||||
.find(|(n, _)| *n == name)
|
||||
{
|
||||
let args = Self::command_args_from_line(first_line, cmd);
|
||||
self.textarea.set_text("");
|
||||
return (InputResult::Command(cmd), true);
|
||||
return (InputResult::Command { command: cmd, args }, true);
|
||||
}
|
||||
// If we're in a paste-like burst capture, treat Enter as part of the burst
|
||||
// and accumulate it rather than submitting or inserting immediately.
|
||||
@@ -2399,8 +2407,9 @@ mod tests {
|
||||
// When a slash command is dispatched, the composer should return a
|
||||
// Command result (not submit literal text) and clear its textarea.
|
||||
match result {
|
||||
InputResult::Command(cmd) => {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "init");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
@@ -2474,7 +2483,10 @@ mod tests {
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Command(cmd) => assert_eq!(cmd.command(), "diff"),
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "diff");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch after Tab completion, got literal submit: {text}")
|
||||
}
|
||||
@@ -2483,6 +2495,42 @@ mod tests {
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_command_with_args_dispatches_and_preserves_args() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = unbounded_channel::<AppEvent>();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(
|
||||
true,
|
||||
sender,
|
||||
false,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
false,
|
||||
);
|
||||
|
||||
composer.textarea.set_text("/save feature-one");
|
||||
|
||||
let (result, _needs_redraw) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd, SlashCommand::Save);
|
||||
assert_eq!(args, "feature-one");
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!(
|
||||
"expected slash command dispatch, but composer submitted literal text: {text}"
|
||||
)
|
||||
}
|
||||
InputResult::None => panic!("expected Command result for '/save feature-one'"),
|
||||
}
|
||||
assert!(composer.textarea.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slash_mention_dispatches_command_and_inserts_at() {
|
||||
use crossterm::event::KeyCode;
|
||||
@@ -2505,8 +2553,9 @@ mod tests {
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
match result {
|
||||
InputResult::Command(cmd) => {
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
assert_eq!(cmd.command(), "mention");
|
||||
assert!(args.is_empty());
|
||||
}
|
||||
InputResult::Submitted(text) => {
|
||||
panic!("expected command dispatch, but composer submitted literal text: {text}")
|
||||
|
||||
@@ -1301,8 +1301,8 @@ impl ChatWidget {
|
||||
};
|
||||
self.queue_user_message(user_message);
|
||||
}
|
||||
InputResult::Command(cmd) => {
|
||||
self.dispatch_command(cmd);
|
||||
InputResult::Command { command: cmd, args } => {
|
||||
self.dispatch_command(cmd, args);
|
||||
}
|
||||
InputResult::None => {}
|
||||
}
|
||||
@@ -1325,7 +1325,7 @@ impl ChatWidget {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
fn dispatch_command(&mut self, cmd: SlashCommand) {
|
||||
fn dispatch_command(&mut self, cmd: SlashCommand, args: String) {
|
||||
if !cmd.available_during_task() && self.bottom_pane.is_task_running() {
|
||||
let message = format!(
|
||||
"'/{}' is disabled while a task is in progress.",
|
||||
@@ -1346,6 +1346,9 @@ impl ChatWidget {
|
||||
SlashCommand::New => {
|
||||
self.app_event_tx.send(AppEvent::NewSession);
|
||||
}
|
||||
SlashCommand::Save => {
|
||||
self.handle_save_command(args);
|
||||
}
|
||||
SlashCommand::Init => {
|
||||
let init_target = self.config.cwd.join(DEFAULT_PROJECT_DOC_FILENAME);
|
||||
if init_target.exists() {
|
||||
@@ -1462,6 +1465,26 @@ impl ChatWidget {
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_save_command(&mut self, args: String) {
|
||||
let name = args.trim();
|
||||
if name.is_empty() {
|
||||
self.add_to_history(history_cell::new_error_event(
|
||||
"Usage: /save <name>".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
if self.conversation_id.is_none() {
|
||||
self.add_to_history(history_cell::new_error_event(
|
||||
"Session is not ready yet; try /save again in a moment.".to_string(),
|
||||
));
|
||||
return;
|
||||
}
|
||||
self.app_event_tx.send(AppEvent::SaveSession {
|
||||
name: name.to_string(),
|
||||
});
|
||||
self.add_info_message(format!("Saving session '{name}'..."), None);
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
|
||||
@@ -943,7 +943,7 @@ fn slash_init_skips_when_project_doc_exists() {
|
||||
std::fs::write(&existing_path, "existing instructions").unwrap();
|
||||
chat.config.cwd = tempdir.path().to_path_buf();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Init);
|
||||
chat.dispatch_command(SlashCommand::Init, String::new());
|
||||
|
||||
match op_rx.try_recv() {
|
||||
Err(TryRecvError::Empty) => {}
|
||||
@@ -971,7 +971,7 @@ fn slash_init_skips_when_project_doc_exists() {
|
||||
fn slash_quit_requests_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Quit);
|
||||
chat.dispatch_command(SlashCommand::Quit, String::new());
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
}
|
||||
@@ -980,7 +980,7 @@ fn slash_quit_requests_exit() {
|
||||
fn slash_exit_requests_exit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Exit);
|
||||
chat.dispatch_command(SlashCommand::Exit, String::new());
|
||||
|
||||
assert_matches!(rx.try_recv(), Ok(AppEvent::ExitRequest));
|
||||
}
|
||||
@@ -989,7 +989,7 @@ fn slash_exit_requests_exit() {
|
||||
fn slash_undo_sends_op() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Undo);
|
||||
chat.dispatch_command(SlashCommand::Undo, String::new());
|
||||
|
||||
match rx.try_recv() {
|
||||
Ok(AppEvent::CodexOp(Op::Undo)) => {}
|
||||
@@ -1003,7 +1003,7 @@ fn slash_rollout_displays_current_path() {
|
||||
let rollout_path = PathBuf::from("/tmp/codex-test-rollout.jsonl");
|
||||
chat.current_rollout_path = Some(rollout_path.clone());
|
||||
|
||||
chat.dispatch_command(SlashCommand::Rollout);
|
||||
chat.dispatch_command(SlashCommand::Rollout, String::new());
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(cells.len(), 1, "expected info message for rollout path");
|
||||
@@ -1018,7 +1018,7 @@ fn slash_rollout_displays_current_path() {
|
||||
fn slash_rollout_handles_missing_path() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
chat.dispatch_command(SlashCommand::Rollout);
|
||||
chat.dispatch_command(SlashCommand::Rollout, String::new());
|
||||
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
assert_eq!(
|
||||
@@ -1567,7 +1567,7 @@ fn feedback_selection_popup_snapshot() {
|
||||
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Open the feedback category selection popup via slash command.
|
||||
chat.dispatch_command(SlashCommand::Feedback);
|
||||
chat.dispatch_command(SlashCommand::Feedback, String::new());
|
||||
|
||||
let popup = render_bottom_popup(&chat, 80);
|
||||
assert_snapshot!("feedback_selection_popup", popup);
|
||||
@@ -1645,7 +1645,7 @@ fn disabled_slash_command_while_task_running_snapshot() {
|
||||
chat.bottom_pane.set_task_running(true);
|
||||
|
||||
// Dispatch a command that is unavailable while a task runs (e.g., /model)
|
||||
chat.dispatch_command(SlashCommand::Model);
|
||||
chat.dispatch_command(SlashCommand::Model, String::new());
|
||||
|
||||
// Drain history and snapshot the rendered error line(s)
|
||||
let cells = drain_insert_history(&mut rx);
|
||||
|
||||
@@ -28,6 +28,9 @@ pub struct Cli {
|
||||
#[clap(skip)]
|
||||
pub resume_session_id: Option<String>,
|
||||
|
||||
#[clap(skip)]
|
||||
pub fork_source: Option<String>,
|
||||
|
||||
/// Model the agent should use.
|
||||
#[arg(long, short = 'm')]
|
||||
pub model: Option<String>,
|
||||
|
||||
+44
-3
@@ -22,11 +22,14 @@ use codex_core::config::resolve_oss_provider;
|
||||
use codex_core::find_conversation_path_by_id_str;
|
||||
use codex_core::get_platform_sandbox;
|
||||
use codex_core::protocol::AskForApproval;
|
||||
use codex_core::resolve_saved_session;
|
||||
use codex_protocol::config_types::SandboxMode;
|
||||
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
|
||||
use std::fs::OpenOptions;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing::warn;
|
||||
use tracing_appender::non_blocking;
|
||||
use tracing_subscriber::EnvFilter;
|
||||
use tracing_subscriber::filter::Targets;
|
||||
@@ -335,6 +338,23 @@ pub async fn run_main(
|
||||
.map_err(|err| std::io::Error::other(err.to_string()))
|
||||
}
|
||||
|
||||
async fn resolve_rollout_path(
|
||||
codex_home: &Path,
|
||||
identifier: &str,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
if let Some(entry) = resolve_saved_session(codex_home, identifier).await? {
|
||||
if entry.rollout_path.exists() {
|
||||
return Ok(Some(entry.rollout_path));
|
||||
}
|
||||
warn!(
|
||||
"saved session '{}' points to missing rollout at {}",
|
||||
identifier,
|
||||
entry.rollout_path.display()
|
||||
);
|
||||
}
|
||||
find_conversation_path_by_id_str(codex_home, identifier).await
|
||||
}
|
||||
|
||||
async fn run_ratatui_app(
|
||||
cli: Cli,
|
||||
initial_config: Config,
|
||||
@@ -445,8 +465,29 @@ async fn run_ratatui_app(
|
||||
};
|
||||
|
||||
// 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? {
|
||||
let resume_selection = if let Some(target) = cli.fork_source.as_deref() {
|
||||
match resolve_rollout_path(&config.codex_home, target).await? {
|
||||
Some(path) => resume_picker::ResumeSelection::Fork(path),
|
||||
None => {
|
||||
error!("Error finding conversation path: {target}");
|
||||
restore();
|
||||
session_log::log_session_end();
|
||||
let _ = tui.terminal.clear();
|
||||
if let Err(err) = writeln!(
|
||||
std::io::stdout(),
|
||||
"No saved session or rollout found for '{target}'. Run `codex resume` without a name to choose from existing sessions."
|
||||
) {
|
||||
error!("Failed to write resume error message: {err}");
|
||||
}
|
||||
return Ok(AppExitInfo {
|
||||
token_usage: codex_core::protocol::TokenUsage::default(),
|
||||
conversation_id: None,
|
||||
update_action: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if let Some(id_str) = cli.resume_session_id.as_deref() {
|
||||
match resolve_rollout_path(&config.codex_home, id_str).await? {
|
||||
Some(path) => resume_picker::ResumeSelection::Resume(path),
|
||||
None => {
|
||||
error!("Error finding conversation path: {id_str}");
|
||||
@@ -455,7 +496,7 @@ async fn run_ratatui_app(
|
||||
let _ = tui.terminal.clear();
|
||||
if let Err(err) = writeln!(
|
||||
std::io::stdout(),
|
||||
"No saved session found with ID {id_str}. Run `codex resume` without an ID to choose from existing sessions."
|
||||
"No saved session or rollout found for '{id_str}'. Run `codex resume` without a name to choose from existing sessions."
|
||||
) {
|
||||
error!("Failed to write resume error message: {err}");
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
pub enum ResumeSelection {
|
||||
StartFresh,
|
||||
Resume(PathBuf),
|
||||
Fork(PathBuf),
|
||||
Exit,
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ pub enum SlashCommand {
|
||||
Approvals,
|
||||
Review,
|
||||
New,
|
||||
Save,
|
||||
Init,
|
||||
Compact,
|
||||
Undo,
|
||||
@@ -37,6 +38,7 @@ impl SlashCommand {
|
||||
match self {
|
||||
SlashCommand::Feedback => "send logs to maintainers",
|
||||
SlashCommand::New => "start a new chat during a conversation",
|
||||
SlashCommand::Save => "save this session so it can be resumed later",
|
||||
SlashCommand::Init => "create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
|
||||
SlashCommand::Review => "review my current changes and find issues",
|
||||
@@ -64,6 +66,7 @@ impl SlashCommand {
|
||||
pub fn available_during_task(self) -> bool {
|
||||
match self {
|
||||
SlashCommand::New
|
||||
| SlashCommand::Save
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Compact
|
||||
| SlashCommand::Undo
|
||||
|
||||
Reference in New Issue
Block a user