diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index 42ec2063b495..ea6dacc9ee9f 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -504,6 +504,7 @@ mod config_processor; mod environment_processor; mod external_agent_config_processor; mod external_agent_session_import; +mod external_agent_session_rollout; mod feedback_doctor_report; mod feedback_processor; mod fs_processor; diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs index e0a53a9c983e..7621c6281f80 100644 --- a/codex-rs/app-server/src/request_processors/external_agent_session_import.rs +++ b/codex-rs/app-server/src/request_processors/external_agent_session_import.rs @@ -1,6 +1,9 @@ +use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; +use chrono::DateTime; +use chrono::SecondsFormat; use chrono::Utc; use codex_arg0::Arg0DispatchPaths; use codex_core::ThreadManager; @@ -15,28 +18,33 @@ use codex_models_manager::manager::RefreshStrategy; use codex_protocol::ThreadId; use codex_protocol::models::BaseInstructions; use codex_protocol::protocol::MultiAgentVersion; +use codex_protocol::protocol::SessionContextWindow; +use codex_protocol::protocol::SessionMeta; use codex_protocol::protocol::ThreadHistoryMode; use codex_protocol::protocol::ThreadMemoryMode; use codex_rollout::is_persisted_rollout_item; -use codex_thread_store::AppendThreadItemsParams; -use codex_thread_store::CreateThreadParams; +use codex_thread_store::DeleteThreadParams; +use codex_thread_store::LocalThreadStore; use codex_thread_store::ThreadMetadataPatch; -use codex_thread_store::ThreadPersistenceMetadata; use codex_thread_store::ThreadStore; use codex_thread_store::UpdateThreadMetadataParams; use futures::StreamExt; +use tokio::sync::Mutex; use tokio::sync::Semaphore; use crate::config::external_agent_config::ExternalAgentConfigImportItemResult; use crate::config::external_agent_config::record_import_error; use crate::config_manager::ConfigManager; +use super::external_agent_session_rollout::materialize_imported_rollout; + const SESSION_IMPORT_CONCURRENCY: usize = 5; #[derive(Clone)] pub(super) struct ExternalAgentSessionImporter { codex_home: PathBuf, permits: Arc, + reserved_source_identities: Arc>>, thread_manager: Arc, thread_store: Arc, config_manager: ConfigManager, @@ -54,6 +62,7 @@ impl ExternalAgentSessionImporter { Self { codex_home, permits: Arc::new(Semaphore::new(1)), + reserved_source_identities: Arc::new(Mutex::new(HashSet::new())), thread_manager, thread_store, config_manager, @@ -134,16 +143,38 @@ impl ExternalAgentSessionImporter { else { return Ok(None); }; - let imported_thread_id = - self.persist_session(pending_import.session) - .await - .map_err(|message| SessionImportFailure { - source_path: pending_import.source_path.clone(), + let source_identity = pending_import + .session + .source_session_id + .as_ref() + .map(|session_id| format!("session:{session_id}")) + .unwrap_or_else(|| format!("path:{}", pending_import.source_path.display())); + if !self + .reserved_source_identities + .lock() + .await + .insert(source_identity.clone()) + { + return Ok(None); + } + let source_session_id = pending_import.session.source_session_id.clone(); + let imported_thread_id = match self.persist_session(pending_import.session).await { + Ok(imported_thread_id) => imported_thread_id, + Err(message) => { + self.reserved_source_identities + .lock() + .await + .remove(&source_identity); + return Err(SessionImportFailure { + source_path: pending_import.source_path, message, stage: "session_persist", - })?; + }); + } + }; Ok(Some(CompletedExternalAgentSessionImport { source_path: pending_import.source_path, + source_session_id, source_content_sha256: pending_import.source_content_sha256, imported_thread_id, })) @@ -164,10 +195,18 @@ impl ExternalAgentSessionImporter { &self, session: ImportedExternalAgentSession, ) -> Result { + if !self.thread_store.as_any().is::() { + return Err( + "external agent session import requires the local thread store".to_string(), + ); + } let ImportedExternalAgentSession { cwd, title, first_user_message, + source_session_id: _, + created_at, + updated_at, mut rollout_items, } = session; let config = self @@ -204,42 +243,63 @@ impl ExternalAgentSessionImporter { ThreadMemoryMode::Disabled }; let now = Utc::now(); - let create_params = CreateThreadParams { + let created_at = created_at + .and_then(|timestamp| DateTime::::from_timestamp(timestamp, 0)) + .unwrap_or(now); + let updated_at = updated_at + .and_then(|timestamp| DateTime::::from_timestamp(timestamp, 0)) + .unwrap_or(created_at); + let base_instructions = BaseInstructions { + text: config + .base_instructions + .clone() + .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), + }; + let session_meta = SessionMeta { session_id: thread_id.into(), - thread_id, - extra_config: None, + id: thread_id, forked_from_id: None, parent_thread_id: None, + timestamp: created_at.to_rfc3339_opts(SecondsFormat::Millis, true), + cwd: cwd.clone(), + originator: codex_login::default_client::originator().value, + cli_version: env!("CARGO_PKG_VERSION").to_string(), + agent_nickname: source.get_nickname(), + agent_role: source.get_agent_role(), + agent_path: source.get_agent_path().map(Into::into), source: source.clone(), thread_source: None, - originator: codex_login::default_client::originator().value, - base_instructions: BaseInstructions { - text: config - .base_instructions - .clone() - .unwrap_or_else(|| model_info.get_model_instructions(config.personality)), - }, - dynamic_tools: Vec::new(), + model_provider: Some(model_provider.clone()), + base_instructions: Some(base_instructions), + dynamic_tools: None, selected_capability_roots: Vec::new(), - multi_agent_version: Some(MultiAgentVersion::V1), + memory_mode: matches!(memory_mode, ThreadMemoryMode::Disabled) + .then_some("disabled".to_string()), history_mode: ThreadHistoryMode::Legacy, - initial_window_id: uuid::Uuid::now_v7().to_string(), - metadata: ThreadPersistenceMetadata { - cwd: Some(cwd.clone()), - model_provider: model_provider.clone(), - memory_mode, - }, + multi_agent_version: Some(MultiAgentVersion::V1), + context_window: Some(SessionContextWindow::new(uuid::Uuid::now_v7().to_string())), }; rollout_items.retain(is_persisted_rollout_item); + let rollout_path = materialize_imported_rollout( + self.codex_home.as_path(), + created_at, + updated_at, + session_meta, + rollout_items, + ) + .await + .map_err(|err| format!("failed to materialize imported session: {err}"))?; let title = title .as_deref() .and_then(codex_core::util::normalize_thread_name); let metadata = ThreadMetadataPatch { + rollout_path: Some(rollout_path.clone()), title, preview: first_user_message.clone(), model_provider: Some(model_provider), - created_at: Some(now), - updated_at: Some(now), + created_at: Some(created_at), + updated_at: Some(updated_at), + advance_recency_at: Some(updated_at), source: Some(source.clone()), thread_source: Some(None), agent_nickname: Some(source.get_nickname()), @@ -252,39 +312,22 @@ impl ExternalAgentSessionImporter { ..Default::default() }; - self.thread_store - .create_thread(create_params) - .await - .map_err(|err| format!("failed to import session: {err}"))?; - if !rollout_items.is_empty() - && let Err(err) = self - .thread_store - .append_items(AppendThreadItemsParams { - thread_id, - items: rollout_items, - }) - .await - { - let _ = self.thread_store.discard_thread(thread_id).await; - return Err(format!("failed to import session: {err}")); - } - - self.thread_store + if let Err(err) = self + .thread_store .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: metadata, include_archived: false, }) .await - .map_err(|err| format!("failed to update imported session: {err}"))?; - self.thread_store - .persist_thread(thread_id) - .await - .map_err(|err| format!("failed to persist imported session: {err}"))?; - self.thread_store - .shutdown_thread(thread_id) - .await - .map_err(|err| format!("failed to shutdown imported session: {err}"))?; + { + let _ = self + .thread_store + .delete_thread(DeleteThreadParams { thread_id }) + .await; + let _ = std::fs::remove_file(rollout_path); + return Err(format!("failed to index imported session: {err}")); + } Ok(thread_id) } } diff --git a/codex-rs/app-server/src/request_processors/external_agent_session_rollout.rs b/codex-rs/app-server/src/request_processors/external_agent_session_rollout.rs new file mode 100644 index 000000000000..59d3a58f01ca --- /dev/null +++ b/codex-rs/app-server/src/request_processors/external_agent_session_rollout.rs @@ -0,0 +1,124 @@ +use std::fs::FileTimes; +use std::fs::OpenOptions; +use std::io; +use std::io::BufWriter; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; + +use chrono::DateTime; +use chrono::SecondsFormat; +use chrono::Utc; +use codex_git_utils::collect_git_info; +use codex_protocol::protocol::GitInfo as ProtocolGitInfo; +use codex_protocol::protocol::RolloutItem; +use codex_protocol::protocol::RolloutLine; +use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; +use codex_rollout::SESSIONS_SUBDIR; + +pub(super) async fn materialize_imported_rollout( + codex_home: &Path, + created_at: DateTime, + updated_at: DateTime, + session_meta: SessionMeta, + rollout_items: Vec, +) -> io::Result { + let git = collect_git_info(session_meta.cwd.as_path()) + .await + .map(|info| ProtocolGitInfo { + commit_hash: info.commit_hash, + branch: info.branch, + repository_url: info.repository_url, + }); + let codex_home = codex_home.to_path_buf(); + tokio::task::spawn_blocking(move || { + write_imported_rollout( + codex_home.as_path(), + created_at, + updated_at, + SessionMetaLine { + meta: session_meta, + git, + }, + rollout_items, + ) + }) + .await + .map_err(|err| io::Error::other(format!("imported rollout task failed: {err}")))? +} + +fn write_imported_rollout( + codex_home: &Path, + created_at: DateTime, + updated_at: DateTime, + session_meta: SessionMetaLine, + rollout_items: Vec, +) -> io::Result { + let rollout_dir = codex_home + .join(SESSIONS_SUBDIR) + .join(created_at.format("%Y").to_string()) + .join(created_at.format("%m").to_string()) + .join(created_at.format("%d").to_string()); + std::fs::create_dir_all(rollout_dir.as_path())?; + let filename_timestamp = created_at.format("%Y-%m-%dT%H-%M-%S"); + let rollout_path = rollout_dir.join(format!( + "rollout-{filename_timestamp}-{}.jsonl", + session_meta.meta.id + )); + if rollout_path.exists() { + return Err(io::Error::new( + io::ErrorKind::AlreadyExists, + format!( + "imported rollout already exists: {}", + rollout_path.display() + ), + )); + } + let temporary_path = rollout_path.with_extension("jsonl.tmp"); + let mut renamed = false; + let result = (|| { + let file = OpenOptions::new() + .create_new(true) + .write(true) + .open(temporary_path.as_path())?; + let mut writer = BufWriter::new(file); + write_rollout_line( + &mut writer, + created_at.to_rfc3339_opts(SecondsFormat::Millis, true), + RolloutItem::SessionMeta(session_meta), + )?; + let item_timestamp = updated_at.to_rfc3339_opts(SecondsFormat::Millis, true); + for item in rollout_items { + write_rollout_line(&mut writer, item_timestamp.clone(), item)?; + } + writer.flush()?; + writer.get_ref().sync_all()?; + drop(writer); + std::fs::rename(temporary_path.as_path(), rollout_path.as_path())?; + renamed = true; + OpenOptions::new() + .write(true) + .open(rollout_path.as_path())? + .set_times(FileTimes::new().set_modified(updated_at.into()))?; + Ok(()) + })(); + if let Err(err) = result { + let _ = std::fs::remove_file(temporary_path); + if renamed { + let _ = std::fs::remove_file(rollout_path); + } + return Err(err); + } + Ok(rollout_path) +} + +fn write_rollout_line( + writer: &mut BufWriter, + timestamp: String, + item: RolloutItem, +) -> io::Result<()> { + serde_json::to_writer(writer.by_ref(), &RolloutLine { timestamp, item }) + .map_err(io::Error::other)?; + writer.write_all(b"\n") +} diff --git a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs index 6bef968941ad..9759e85408e0 100644 --- a/codex-rs/app-server/tests/suite/v2/external_agent_config.rs +++ b/codex-rs/app-server/tests/suite/v2/external_agent_config.rs @@ -612,7 +612,8 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { let codex_home = TempDir::new()?; create_config_toml(codex_home.path(), &server.uri())?; let project_root = codex_home.path().join("repo"); - let recent_timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + let source_created_at = "2026-06-03T12:00:00Z"; + let source_updated_at = "2026-06-03T12:00:05Z"; let session_dir = external_agent_home(codex_home.path()).join("projects/repo"); let session_path = session_dir.join("session.jsonl"); std::fs::create_dir_all(&project_root)?; @@ -623,14 +624,14 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { serde_json::json!({ "type": "user", "cwd": &project_root, - "timestamp": &recent_timestamp, + "timestamp": source_created_at, "message": { "content": "first request" }, }) .to_string(), serde_json::json!({ "type": "assistant", "cwd": &project_root, - "timestamp": &recent_timestamp, + "timestamp": source_updated_at, "message": { "content": "first answer" }, }) .to_string(), @@ -745,6 +746,40 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { assert_eq!(imported_thread_id, thread.id.to_string()); assert_eq!(thread.preview, "first request"); assert_eq!(thread.name.as_deref(), Some("source session title")); + let source_created_at = chrono::DateTime::parse_from_rfc3339(source_created_at)?.timestamp(); + let source_updated_at = chrono::DateTime::parse_from_rfc3339(source_updated_at)?.timestamp(); + let rollout_path = codex_rollout::find_thread_path_by_id_str( + codex_home.path(), + &imported_thread_id, + /*state_db_ctx*/ None, + ) + .await? + .expect("imported rollout path"); + assert_eq!( + rollout_path.strip_prefix(codex_home.path())?, + PathBuf::from(format!( + "sessions/2026/06/03/rollout-2026-06-03T12-00-00-{imported_thread_id}.jsonl" + )) + ); + assert_eq!( + chrono::DateTime::::from(std::fs::metadata(&rollout_path)?.modified()?) + .timestamp(), + source_updated_at + ); + assert_eq!( + ( + thread.created_at, + thread.updated_at, + thread.recency_at, + thread.cwd.as_path(), + ), + ( + source_created_at, + source_updated_at, + Some(source_updated_at), + project_root.as_path(), + ) + ); let request_id = mcp .send_thread_read_request(ThreadReadParams { @@ -759,6 +794,18 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> { .await??; let response: ThreadReadResponse = to_response(response)?; assert_eq!(response.thread.turns.len(), 1); + assert_eq!( + ( + response.thread.turns[0].started_at, + response.thread.turns[0].completed_at, + response.thread.turns[0].duration_ms, + ), + ( + Some(source_created_at), + Some(source_updated_at), + Some(5_000), + ) + ); let items = &response.thread.turns[0].items; assert_eq!(items.len(), 3); assert_eq!( diff --git a/codex-rs/external-agent-sessions/src/detect.rs b/codex-rs/external-agent-sessions/src/detect.rs index f42c90766b10..78e895e71304 100644 --- a/codex-rs/external-agent-sessions/src/detect.rs +++ b/codex-rs/external-agent-sessions/src/detect.rs @@ -84,7 +84,14 @@ pub fn detect_recent_sessions( let mut migrations = Vec::new(); let mut ledger_changed = false; for (modified_at, path) in file_candidates { - match ledger.refresh_current_source(&path, modified_at.0) { + let Ok(Some(summary)) = summarize_session(&path) else { + continue; + }; + match ledger.refresh_current_source( + &path, + summary.source_session_id.as_deref(), + modified_at.0, + ) { Ok(false) => {} Ok(true) => { ledger_changed = true; @@ -92,9 +99,6 @@ pub fn detect_recent_sessions( } Err(_) => continue, } - let Ok(Some(summary)) = summarize_session(&path) else { - continue; - }; let migration = summary.migration; if !migration.cwd.is_dir() { continue; @@ -323,7 +327,7 @@ mod tests { let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - assert_eq!(sessions, vec![oldest_session.clone()]); + assert_eq!(sessions, vec![oldest_session]); for session in sessions { record_imported_session(root.path(), &session.path, ThreadId::new()) .expect("record import"); @@ -347,17 +351,11 @@ mod tests { ); } - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - - assert_eq!(sessions, expected); - for session in sessions { - record_imported_session(root.path(), &session.path, ThreadId::new()) - .expect("record import"); - } - - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - - assert_eq!(sessions, vec![oldest_session]); + assert!( + detect_recent_sessions(&external_agent_home, root.path()) + .expect("detect") + .is_empty() + ); } #[test] @@ -383,7 +381,7 @@ mod tests { } #[test] - fn redetects_sessions_when_source_contents_change_after_import() { + fn skips_imported_sessions_when_source_contents_change() { let root = TempDir::new().expect("tempdir"); let external_agent_home = root.path().join(".external"); let project_root = root.path().join("repo"); @@ -405,14 +403,10 @@ mod tests { ) .expect("update session"); - let sessions = detect_recent_sessions(&external_agent_home, root.path()).expect("detect"); - assert_eq!( - sessions, - vec![ExternalAgentSessionMigration { - path: session_path, - cwd: project_root, - title: Some("hello there".to_string()), - }] + assert!( + detect_recent_sessions(&external_agent_home, root.path()) + .expect("detect") + .is_empty() ); } diff --git a/codex-rs/external-agent-sessions/src/export.rs b/codex-rs/external-agent-sessions/src/export.rs index 6391d27bd378..82cdefe831e8 100644 --- a/codex-rs/external-agent-sessions/src/export.rs +++ b/codex-rs/external-agent-sessions/src/export.rs @@ -41,6 +41,14 @@ pub(crate) fn load_session_for_import_with_content_sha256( .find(|message| message.role == MessageRole::User) .map(|message| summarize_for_label(&message.text)); let title = parsed.source_title.or_else(|| first_user_message.clone()); + let created_at = messages + .iter() + .filter_map(|message| message.timestamp) + .min(); + let updated_at = messages + .iter() + .filter_map(|message| message.timestamp) + .max(); let rollout_items = rollout_items_from_messages(messages); if rollout_items.is_empty() { return Ok(None); @@ -50,6 +58,9 @@ pub(crate) fn load_session_for_import_with_content_sha256( cwd, title, first_user_message, + source_session_id: parsed.source_session_id, + created_at, + updated_at, rollout_items, }, parsed.content_sha256, @@ -62,16 +73,23 @@ fn rollout_items_from_messages(messages: Vec) -> Vec { if let Some(turn_id) = current_turn.take() { - items.push(turn_complete_item(turn_id, /*completed_at*/ None)); + items.push(turn_complete_item( + turn_id, + current_turn_started_at, + current_turn_completed_at, + )); } user_turn_count += 1; let turn_id = format!("external-import-turn-{user_turn_count}"); + current_turn_started_at = message.timestamp; + current_turn_completed_at = message.timestamp; items.push(RolloutItem::EventMsg(EventMsg::TurnStarted( TurnStartedEvent { turn_id: turn_id.clone(), @@ -96,6 +114,12 @@ fn rollout_items_from_messages(messages: Vec) -> Vec) -> Vec RolloutItem { })) } -fn turn_complete_item(turn_id: String, completed_at: Option) -> RolloutItem { +fn turn_complete_item( + turn_id: String, + started_at: Option, + completed_at: Option, +) -> RolloutItem { + let duration_ms = started_at + .zip(completed_at) + .and_then(|(started_at, completed_at)| completed_at.checked_sub(started_at)) + .filter(|duration| *duration >= 0) + .and_then(|duration| duration.checked_mul(1_000)); RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { turn_id, last_agent_message: None, completed_at, - duration_ms: None, + duration_ms, time_to_first_token_ms: None, })) } @@ -218,6 +255,78 @@ mod tests { ); } + #[test] + fn preserves_source_session_and_turn_timing() { + let root = TempDir::new().expect("tempdir"); + let project_root = root.path().join("repo"); + std::fs::create_dir_all(&project_root).expect("project root"); + let path = root.path().join("session.jsonl"); + let first_started_at = "2026-06-03T12:00:00Z"; + let first_completed_at = "2026-06-03T12:00:05Z"; + let second_started_at = "2026-06-03T12:01:00Z"; + let second_completed_at = "2026-06-03T12:01:09Z"; + std::fs::write( + &path, + jsonl(&[ + record_at("user", "first request", &project_root, first_started_at), + record_at( + "assistant", + "first answer", + &project_root, + first_completed_at, + ), + record_at("user", "second request", &project_root, second_started_at), + record_at( + "assistant", + "second answer", + &project_root, + second_completed_at, + ), + ]), + ) + .expect("session"); + + let imported = load_session_for_import(&path) + .expect("load") + .expect("session"); + let turns = build_turns_from_rollout_items(&imported.rollout_items); + let first_started_at = chrono::DateTime::parse_from_rfc3339(first_started_at) + .expect("first started at") + .timestamp(); + let first_completed_at = chrono::DateTime::parse_from_rfc3339(first_completed_at) + .expect("first completed at") + .timestamp(); + let second_started_at = chrono::DateTime::parse_from_rfc3339(second_started_at) + .expect("second started at") + .timestamp(); + let second_completed_at = chrono::DateTime::parse_from_rfc3339(second_completed_at) + .expect("second completed at") + .timestamp(); + + assert_eq!( + (imported.created_at, imported.updated_at), + (Some(first_started_at), Some(second_completed_at)) + ); + assert_eq!( + turns + .iter() + .map(|turn| (turn.started_at, turn.completed_at, turn.duration_ms)) + .collect::>(), + vec![ + ( + Some(first_started_at), + Some(first_completed_at), + Some(5_000) + ), + ( + Some(second_started_at), + Some(second_completed_at), + Some(9_000), + ), + ] + ); + } + #[test] fn adds_import_marker_without_copying_last_agent_message() { let root = TempDir::new().expect("tempdir"); @@ -407,6 +516,10 @@ mod tests { fn record(role: &str, text: &str, cwd: &Path) -> JsonValue { let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + record_at(role, text, cwd, ×tamp) + } + + fn record_at(role: &str, text: &str, cwd: &Path, timestamp: &str) -> JsonValue { serde_json::json!({ "type": role, "cwd": cwd, diff --git a/codex-rs/external-agent-sessions/src/ledger.rs b/codex-rs/external-agent-sessions/src/ledger.rs index 9a3b2042b86d..b5f8fd3da9ab 100644 --- a/codex-rs/external-agent-sessions/src/ledger.rs +++ b/codex-rs/external-agent-sessions/src/ledger.rs @@ -23,6 +23,8 @@ pub(super) struct ImportedExternalAgentSessionLedger { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] struct ImportedExternalAgentSessionRecord { source_path: PathBuf, + #[serde(default)] + source_session_id: Option, content_sha256: String, imported_thread_id: ThreadId, imported_at: i64, @@ -33,6 +35,7 @@ struct ImportedExternalAgentSessionRecord { #[derive(Debug, PartialEq, Eq)] pub struct CompletedExternalAgentSessionImport { pub source_path: PathBuf, + pub source_session_id: Option, pub source_content_sha256: String, pub imported_thread_id: ThreadId, } @@ -46,8 +49,9 @@ pub(super) struct ImportedSourceState { pub fn has_current_session_been_imported( codex_home: &Path, source_path: &Path, + source_session_id: Option<&str>, ) -> io::Result { - load_import_ledger(codex_home)?.contains_current_source(source_path) + load_import_ledger(codex_home)?.contains_source_identity(source_path, source_session_id) } #[cfg(test)] @@ -62,6 +66,7 @@ pub(crate) fn record_imported_session( vec![CompletedExternalAgentSessionImport { source_content_sha256: session_content_sha256(&source_path)?, source_path, + source_session_id: None, imported_thread_id, }], ) @@ -79,10 +84,18 @@ pub fn record_completed_session_imports( for import in imports { let source_modified_at = session_modified_at(&import.source_path).ok().flatten(); if let Some(index) = ledger.records.iter().rposition(|record| { - record.source_path == import.source_path - && record.content_sha256 == import.source_content_sha256 + record_matches_source_identity( + record, + &import.source_path, + import.source_session_id.as_deref(), + ) }) { let mut record = ledger.records.remove(index); + record.source_path = import.source_path; + if import.source_session_id.is_some() { + record.source_session_id = import.source_session_id; + } + record.content_sha256 = import.source_content_sha256; record.imported_thread_id = import.imported_thread_id; record.imported_at = imported_at; record.source_modified_at = source_modified_at.or(record.source_modified_at); @@ -91,6 +104,7 @@ pub fn record_completed_session_imports( } ledger.records.push(ImportedExternalAgentSessionRecord { source_path: import.source_path, + source_session_id: import.source_session_id, content_sha256: import.source_content_sha256, imported_thread_id: import.imported_thread_id, imported_at, @@ -115,44 +129,40 @@ impl ImportedExternalAgentSessionLedger { states } - pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result { + pub(super) fn contains_source_identity( + &self, + source_path: &Path, + source_session_id: Option<&str>, + ) -> io::Result { if self.records.is_empty() { return Ok(false); } let source_path = canonical_source_path(source_path)?; - if !self + Ok(self .records .iter() - .any(|record| record.source_path == source_path) - { - return Ok(false); - } - let content_sha256 = session_content_sha256(&source_path)?; - Ok(self.records.iter().any(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 - })) + .any(|record| record_matches_source_identity(record, &source_path, source_session_id))) } pub(super) fn refresh_current_source( &mut self, source_path: &Path, + source_session_id: Option<&str>, source_modified_at: i64, ) -> io::Result { let source_path = canonical_source_path(source_path)?; - if !self - .records - .iter() - .any(|record| record.source_path == source_path) - { - return Ok(false); - } - let content_sha256 = session_content_sha256(&source_path)?; let Some(index) = self.records.iter().rposition(|record| { - record.source_path == source_path && record.content_sha256 == content_sha256 + record_matches_source_identity(record, &source_path, source_session_id) }) else { return Ok(false); }; + let content_sha256 = session_content_sha256(&source_path)?; let mut record = self.records.remove(index); + record.source_path = source_path; + if let Some(source_session_id) = source_session_id { + record.source_session_id = Some(source_session_id.to_string()); + } + record.content_sha256 = content_sha256; record.imported_at = now_unix_seconds(); record.source_modified_at = Some(source_modified_at); self.records.push(record); @@ -160,6 +170,23 @@ impl ImportedExternalAgentSessionLedger { } } +fn record_matches_source_identity( + record: &ImportedExternalAgentSessionRecord, + source_path: &Path, + source_session_id: Option<&str>, +) -> bool { + record.source_path == source_path + || source_session_id.is_some_and(|source_session_id| { + record.source_session_id.as_deref() == Some(source_session_id) + || record.source_session_id.is_none() + && record + .source_path + .file_stem() + .and_then(|stem| stem.to_str()) + == Some(source_session_id) + }) +} + pub(super) fn load_import_ledger( codex_home: &Path, ) -> io::Result { diff --git a/codex-rs/external-agent-sessions/src/ledger_tests.rs b/codex-rs/external-agent-sessions/src/ledger_tests.rs index f4b9dd6f7c2a..98ff9bfd07ed 100644 --- a/codex-rs/external-agent-sessions/src/ledger_tests.rs +++ b/codex-rs/external-agent-sessions/src/ledger_tests.rs @@ -13,7 +13,7 @@ fn empty_ledger_does_not_read_source() { assert!( !ImportedExternalAgentSessionLedger::default() - .contains_current_source(&missing_source) + .contains_source_identity(&missing_source, /*source_session_id*/ None) .expect("empty ledger cannot contain sources") ); } @@ -33,6 +33,7 @@ fn completed_imports_do_not_read_source_files() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: format!("{:x}", Sha256::digest(contents)), imported_thread_id, }], @@ -62,6 +63,7 @@ fn completed_import_refreshes_existing_record_metadata() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: content_sha256.clone(), imported_thread_id: first_thread_id, }], @@ -71,6 +73,7 @@ fn completed_import_refreshes_existing_record_metadata() { &codex_home, vec![CompletedExternalAgentSessionImport { source_path: source_path.clone(), + source_session_id: None, source_content_sha256: content_sha256, imported_thread_id: second_thread_id, }], @@ -83,3 +86,77 @@ fn completed_import_refreshes_existing_record_metadata() { assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); assert!(ledger.records[0].source_modified_at.is_some()); } + +#[test] +fn stable_session_id_deduplicates_moved_and_changed_sources() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let first_path = root.path().join("first-session.jsonl"); + let second_path = root.path().join("moved-session.jsonl"); + std::fs::write(&first_path, "first contents").expect("first source"); + std::fs::write(&second_path, "updated contents").expect("moved source"); + let first_path = std::fs::canonicalize(first_path).expect("canonical first source"); + let second_path = std::fs::canonicalize(second_path).expect("canonical moved source"); + let source_session_id = "source-session-id"; + let first_thread_id = ThreadId::new(); + let second_thread_id = ThreadId::new(); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: first_path, + source_session_id: Some(source_session_id.to_string()), + source_content_sha256: format!("{:x}", Sha256::digest(b"first contents")), + imported_thread_id: first_thread_id, + }], + ) + .expect("record first import"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert!( + ledger + .contains_source_identity(&second_path, Some(source_session_id)) + .expect("match moved source") + ); + + record_completed_session_imports( + &codex_home, + vec![CompletedExternalAgentSessionImport { + source_path: second_path.clone(), + source_session_id: Some(source_session_id.to_string()), + source_content_sha256: format!("{:x}", Sha256::digest(b"updated contents")), + imported_thread_id: second_thread_id, + }], + ) + .expect("record moved import"); + + let ledger = super::load_import_ledger(&codex_home).expect("updated ledger"); + assert_eq!(ledger.records.len(), 1); + assert_eq!(ledger.records[0].source_path, second_path); + assert_eq!( + ledger.records[0].source_session_id.as_deref(), + Some(source_session_id) + ); + assert_eq!(ledger.records[0].imported_thread_id, second_thread_id); +} + +#[test] +fn legacy_ledger_uses_source_filename_as_session_id() { + let root = TempDir::new().expect("tempdir"); + let codex_home = root.path().join("codex-home"); + let source_session_id = "source-session-id"; + let first_path = root.path().join(format!("{source_session_id}.jsonl")); + let moved_path = root.path().join("moved-session.jsonl"); + std::fs::write(&first_path, "first contents").expect("first source"); + std::fs::write(&moved_path, "first contents").expect("moved source"); + super::record_imported_session(&codex_home, &first_path, ThreadId::new()) + .expect("record legacy import"); + let moved_path = std::fs::canonicalize(moved_path).expect("canonical moved source"); + + let ledger = super::load_import_ledger(&codex_home).expect("ledger"); + assert!( + ledger + .contains_source_identity(&moved_path, Some(source_session_id)) + .expect("match legacy source") + ); +} diff --git a/codex-rs/external-agent-sessions/src/lib.rs b/codex-rs/external-agent-sessions/src/lib.rs index 0b7a4eb2bac9..781edfe207e9 100644 --- a/codex-rs/external-agent-sessions/src/lib.rs +++ b/codex-rs/external-agent-sessions/src/lib.rs @@ -32,6 +32,9 @@ pub struct ImportedExternalAgentSession { pub cwd: PathBuf, pub title: Option, pub first_user_message: Option, + pub source_session_id: Option, + pub created_at: Option, + pub updated_at: Option, pub rollout_items: Vec, } @@ -46,15 +49,19 @@ pub fn prepare_validated_session_import( codex_home: &Path, session: ExternalAgentSessionMigration, ) -> io::Result> { - let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?; - if has_been_imported { - return Ok(None); - } let Some((source_path, imported_session, source_content_sha256)) = load_importable_session(&session.path)? else { return Ok(None); }; + let has_been_imported = has_current_session_been_imported( + codex_home, + &source_path, + imported_session.source_session_id.as_deref(), + )?; + if has_been_imported { + return Ok(None); + } Ok(Some(PendingSessionImport { source_path, source_content_sha256, @@ -127,9 +134,31 @@ mod tests { let root = TempDir::new().expect("tempdir"); let codex_home = root.path().join("codex-home"); let source_path = root.path().join("session.jsonl"); - std::fs::write(&source_path, "{}\n").expect("session"); + let source_record = serde_json::json!({ + "type": "user", + "cwd": root.path(), + "sessionId": "source-session-id", + "timestamp": "2026-06-03T12:00:00Z", + "message": { "content": "first request" }, + }); + std::fs::write(&source_path, source_record.to_string()).expect("session"); ledger::record_imported_session(&codex_home, &source_path, ThreadId::new()) .expect("record import"); + std::fs::write( + &source_path, + format!( + "{}\n{}", + source_record, + serde_json::json!({ + "type": "assistant", + "cwd": root.path(), + "sessionId": "source-session-id", + "timestamp": "2026-06-03T12:00:05Z", + "message": { "content": "first answer" }, + }) + ), + ) + .expect("updated session"); let pending = prepare_validated_session_import(&codex_home, session_migration(&source_path)) @@ -156,6 +185,7 @@ mod tests { let contents = serde_json::json!({ "type": "user", "cwd": root.path(), + "sessionId": "source-session-id", "timestamp": "2026-06-03T12:00:00Z", "message": { "content": "first request" }, }) @@ -171,6 +201,10 @@ mod tests { pending.source_content_sha256, format!("{:x}", Sha256::digest(contents)) ); + assert_eq!( + pending.session.source_session_id.as_deref(), + Some("source-session-id") + ); } fn session_migration(path: &Path) -> ExternalAgentSessionMigration { diff --git a/codex-rs/external-agent-sessions/src/records.rs b/codex-rs/external-agent-sessions/src/records.rs index 00307fa1d52f..9b9c9d21c7cb 100644 --- a/codex-rs/external-agent-sessions/src/records.rs +++ b/codex-rs/external-agent-sessions/src/records.rs @@ -20,12 +20,14 @@ const EXTERNAL_AGENT_TOOL_RESULT_TAG: &str = "external_agent_tool_result"; pub struct SessionSummary { pub latest_timestamp: i64, + pub source_session_id: Option, pub migration: ExternalAgentSessionMigration, } pub(super) struct ParsedSessionImport { pub cwd: Option, pub source_title: Option, + pub source_session_id: Option, pub messages: Vec, pub content_sha256: String, } @@ -37,6 +39,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { let mut custom_title = None; let mut ai_title = None; let mut title = None; + let mut source_session_id = None; let mut latest_timestamp = None; let mut saw_message = false; @@ -49,6 +52,9 @@ pub fn summarize_session(path: &Path) -> io::Result> { let Ok(mut record) = serde_json::from_str::(trimmed) else { continue; }; + if source_session_id.is_none() { + source_session_id = source_session_id_from_record(&record).map(str::to_string); + } if cwd.is_none() { cwd = record .get("cwd") @@ -85,6 +91,7 @@ pub fn summarize_session(path: &Path) -> io::Result> { }; Ok(Some(SessionSummary { latest_timestamp, + source_session_id, migration: ExternalAgentSessionMigration { path: path.to_path_buf(), cwd, @@ -99,6 +106,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result io::Result(trimmed) else { continue; }; + if source_session_id.is_none() { + source_session_id = source_session_id_from_record(&record).map(str::to_string); + } if cwd.is_none() { cwd = record .get("cwd") @@ -134,6 +145,7 @@ pub(super) fn read_session_import(path: &Path) -> io::Result Option<&str> { title_from_record(record, "ai-title", "aiTitle") } +fn source_session_id_from_record(record: &JsonValue) -> Option<&str> { + record + .get("sessionId") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|session_id| !session_id.is_empty()) +} + fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) -> Option<&'a str> { (record.get("type").and_then(JsonValue::as_str) == Some(record_type)) .then(|| record.get(field).and_then(JsonValue::as_str)) @@ -351,6 +371,7 @@ mod tests { serde_json::json!({ "type": "user", "cwd": root.path(), + "sessionId": "source-session-id", "timestamp": "2026-06-03T12:00:00Z", "message": { "content": "first request" }, }) @@ -374,6 +395,10 @@ mod tests { assert_eq!(parsed.cwd.as_deref(), Some(root.path())); assert_eq!(parsed.source_title.as_deref(), Some("custom title")); + assert_eq!( + parsed.source_session_id.as_deref(), + Some("source-session-id") + ); assert_eq!(parsed.messages.len(), 1); assert_eq!(parsed.messages[0].text, "first request"); assert_eq!(