//! Parsing and export helpers for external-agent session histories. mod detect; mod export; mod ledger; mod records; use codex_protocol::protocol::RolloutItem; use std::io; use std::path::Path; use std::path::PathBuf; pub use detect::detect_recent_sessions; use export::load_session_for_import_with_content_sha256; pub use ledger::CompletedExternalAgentSessionImport; pub use ledger::has_current_session_been_imported; pub use ledger::record_completed_session_imports; pub use records::SessionSummary; pub use records::summarize_session; const SESSION_TITLE_MAX_LEN: usize = 120; #[derive(Debug, Clone, PartialEq, Eq)] pub struct ExternalAgentSessionMigration { pub path: PathBuf, pub cwd: PathBuf, pub title: Option, } #[derive(Debug, Clone)] pub struct ImportedExternalAgentSession { pub cwd: PathBuf, pub title: Option, pub first_user_message: Option, pub rollout_items: Vec, } #[derive(Debug, Clone)] pub struct PendingSessionImport { pub source_path: PathBuf, pub source_content_sha256: String, pub session: ImportedExternalAgentSession, } 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); }; Ok(Some(PendingSessionImport { source_path, source_content_sha256, session: imported_session, })) } fn load_importable_session( path: &Path, ) -> io::Result> { let source_path = std::fs::canonicalize(path)?; let Some((imported_session, source_content_sha256)) = load_session_for_import_with_content_sha256(&source_path)? else { return Ok(None); }; Ok(imported_session.cwd.is_dir().then_some(( source_path, imported_session, source_content_sha256, ))) } #[derive(Debug, Clone)] struct ConversationMessage { role: MessageRole, text: String, timestamp: Option, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum MessageRole { Assistant, User, } fn summarize_for_label(text: &str) -> String { let first_line = text.lines().next().unwrap_or_default().trim(); truncate(first_line, SESSION_TITLE_MAX_LEN) } fn truncate(text: &str, max_len: usize) -> String { if text.chars().count() <= max_len { return text.to_string(); } let prefix = text .chars() .take(max_len.saturating_sub(3)) .collect::(); format!("{prefix}...") } fn now_unix_seconds() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_secs() as i64) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; use codex_protocol::ThreadId; use sha2::Digest; use sha2::Sha256; use tempfile::TempDir; #[test] fn skips_session_that_was_already_imported() { 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"); ledger::record_imported_session(&codex_home, &source_path, ThreadId::new()) .expect("record import"); let pending = prepare_validated_session_import(&codex_home, session_migration(&source_path)) .expect("already imported session should be skipped"); assert!(pending.is_none()); } #[test] fn reports_session_preparation_errors() { let root = TempDir::new().expect("tempdir"); let source_path = root.path().join("missing-session.jsonl"); let err = prepare_validated_session_import(root.path(), session_migration(&source_path)) .expect_err("missing session should fail preparation"); assert_eq!(err.kind(), io::ErrorKind::NotFound); } #[test] fn prepares_one_validated_session_import_with_content_hash() { let root = TempDir::new().expect("tempdir"); let source_path = root.path().join("session.jsonl"); let contents = serde_json::json!({ "type": "user", "cwd": root.path(), "timestamp": "2026-06-03T12:00:00Z", "message": { "content": "first request" }, }) .to_string(); std::fs::write(&source_path, &contents).expect("session"); let pending = prepare_validated_session_import(root.path(), session_migration(&source_path)) .expect("prepare session") .expect("pending import"); assert_eq!( pending.source_content_sha256, format!("{:x}", Sha256::digest(contents)) ); } fn session_migration(path: &Path) -> ExternalAgentSessionMigration { ExternalAgentSessionMigration { path: path.to_path_buf(), cwd: path .parent() .expect("source path should have parent") .to_path_buf(), title: None, } } }