Move external agent migration into its crate (#33456)

## What changed

- Move migration detection, import orchestration, models, and reporting from
  `codex-app-server` into `codex-external-agent-migration`.
- Keep app-server protocol conversion in a dedicated boundary module.
- Organize source-specific configuration, sessions, hooks, plugins, commands,
  subagents, and memory handling behind shared migration adapters.
- Relocate and split the migration tests alongside the extracted service.

GitOrigin-RevId: 3378ec2064806a28136d6ac98664ca1d75414a4e
This commit is contained in:
charlesgong-openai authored and copyberry committed 2026-07-16 00:34:36 +00:00
1 parent 2edad72de3
commit e7efc5b04b
57 files changed
+7713 -7471

No files matched your search

@@ -1,115 +0,0 @@
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
const SESSION_MANIFESTS_DIR: &str = "claude-code-sessions";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ImportedSessionConnectorAttribution {
pub session_id: String,
pub server_ids: BTreeSet<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SessionManifest {
cli_session_id: Option<String>,
#[serde(default)]
remote_mcp_servers_config: Vec<RemoteMcpServerConfig>,
}
#[derive(Deserialize)]
struct RemoteMcpServerConfig {
name: Option<String>,
uuid: Option<String>,
}
pub fn detect_imported_cla_session_connectors(
session_attributions: &[ImportedSessionConnectorAttribution],
connector_metadata_roots: &[PathBuf],
) -> BTreeMap<String, Vec<String>> {
if session_attributions.is_empty() {
return BTreeMap::new();
}
let attributed_server_ids_by_session = session_attributions
.iter()
.map(|attribution| {
(
attribution.session_id.clone(),
attribution.server_ids.clone(),
)
})
.collect::<BTreeMap<_, _>>();
let mut connector_names_by_session = BTreeMap::<String, BTreeMap<String, String>>::new();
for metadata_root in connector_metadata_roots {
let manifests_root = metadata_root.join(SESSION_MANIFESTS_DIR);
for manifest_path in json_files_recursively(&manifests_root) {
let Some(manifest) = read_session_manifest(&manifest_path) else {
continue;
};
let Some(session_id) = manifest.cli_session_id else {
continue;
};
let Some(attributed_server_ids) = attributed_server_ids_by_session.get(&session_id)
else {
continue;
};
if attributed_server_ids.is_empty() {
continue;
}
let connector_names = connector_names_by_session.entry(session_id).or_default();
for server in manifest.remote_mcp_servers_config {
let Some(uuid) = server.uuid else {
continue;
};
if !attributed_server_ids.contains(&uuid) {
continue;
}
let Some(name) = super::normalized_connector_display_name(server.name.as_deref())
else {
continue;
};
connector_names.entry(name.to_lowercase()).or_insert(name);
}
}
}
connector_names_by_session
.into_iter()
.map(|(session_id, names)| (session_id, names.into_values().collect()))
.collect()
}
fn read_session_manifest(path: &Path) -> Option<SessionManifest> {
let contents = fs::read_to_string(path).ok()?;
serde_json::from_str(&contents).ok()
}
fn json_files_recursively(root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
pending.push(entry.path());
} else if file_type.is_file()
&& entry.path().extension().and_then(|value| value.to_str()) == Some("json")
{
files.push(entry.path());
}
}
}
files
}
@@ -1,491 +0,0 @@
use super::ExternalAgentSessionMigration;
use super::ledger::load_import_ledger;
use super::ledger::save_import_ledger;
use super::now_unix_seconds;
use super::summarize_session;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::fs;
use std::io;
use std::path::Path;
use std::time::Duration;
const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn detect_recent_cla_sessions(
external_agent_home: &Path,
codex_home: &Path,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
let projects_root = external_agent_home.join("projects");
if !projects_root.is_dir() {
return Ok(Vec::new());
}
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut file_candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
};
let project_path = project_entry.path();
if !project_path.is_dir() {
continue;
}
let Ok(entries) = fs::read_dir(project_path) else {
continue;
};
for entry in entries {
let Ok(entry) = entry else {
continue;
};
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("jsonl") {
continue;
}
let Ok(metadata) = entry.metadata() else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
file_candidates.push((Reverse(modified_at_nanos), path));
if file_candidates.len() > SESSION_IMPORT_MAX_COUNT {
file_candidates.pop();
}
}
}
drop(source_states);
let file_candidates = file_candidates.into_sorted_vec();
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) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session(&path) else {
continue;
};
let migration = summary.migration;
if !migration.cwd.is_dir() {
continue;
}
migrations.push(migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
}
#[cfg(test)]
mod tests {
use super::super::ledger::record_imported_session;
use super::*;
use codex_protocol::ThreadId;
use serde_json::Value as JsonValue;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::path::Path;
use std::time::SystemTime;
use tempfile::TempDir;
#[test]
fn detects_recent_sessions_with_existing_roots() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[
record("user", "hello there", project_root.as_path()),
record("assistant", "ack", project_root.as_path()),
],
);
let sessions =
detect_recent_cla_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()),
}]
);
}
#[test]
fn prefers_latest_custom_title_over_first_user_message() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[
record("user", "hello there", project_root.as_path()),
custom_title_record("first title"),
custom_title_record("final title"),
],
);
let sessions =
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: session_path,
cwd: project_root,
title: Some("final title".to_string()),
}]
);
}
#[test]
fn detects_ai_title_over_first_user_message() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[
record("user", "hello there", project_root.as_path()),
ai_title_record("generated by source app"),
],
);
let sessions =
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: session_path,
cwd: project_root,
title: Some("generated by source app".to_string()),
}]
);
}
#[test]
fn prefers_custom_title_over_later_ai_title() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[
record("user", "hello there", project_root.as_path()),
custom_title_record("custom title"),
ai_title_record("generated title"),
],
);
let sessions =
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: session_path,
cwd: project_root,
title: Some("custom title".to_string()),
}]
);
}
#[test]
fn uses_file_modification_time_for_recency() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[record_at(
"user",
"hello",
&project_root,
"2020-01-01T00:00:00Z",
)],
);
let sessions =
detect_recent_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: session_path,
cwd: project_root,
title: Some("hello".to_string()),
}]
);
}
#[test]
fn ignores_sessions_with_old_file_modification_time() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[record("user", "hello", &project_root)],
);
set_modified_at(
&session_path,
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
);
assert!(
detect_recent_cla_sessions(&external_agent_home, root.path())
.expect("detect")
.is_empty()
);
}
#[test]
fn detects_sessions_in_batches() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let modified_at = SystemTime::now();
let mut expected = Vec::new();
for index in 0..=SESSION_IMPORT_MAX_COUNT {
let file_name = format!("{index:02}-session.jsonl");
let title = format!("session {index}");
let path = write_session(
&external_agent_home,
&project_root,
&file_name,
&[record_at("user", &title, &project_root, &timestamp)],
);
set_modified_at(
&path,
modified_at - Duration::from_secs(/*secs*/ index as u64),
);
expected.push(ExternalAgentSessionMigration {
path,
cwd: project_root.clone(),
title: Some(title),
});
}
let oldest_session = expected.pop().expect("oldest session");
let mut all_sessions = expected.clone();
all_sessions.push(oldest_session.clone());
let sessions =
detect_recent_cla_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_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(sessions, vec![oldest_session.clone()]);
for session in sessions {
record_imported_session(root.path(), &session.path, ThreadId::new())
.expect("record import");
}
let changed_at = SystemTime::now()
+ Duration::from_secs(/*secs*/ SESSION_IMPORT_MAX_COUNT as u64 + 1);
for (index, session) in all_sessions.iter().enumerate() {
let title = session.title.as_deref().expect("session title");
std::fs::write(
&session.path,
jsonl(&[
record("user", title, &project_root),
record("assistant", "updated", &project_root),
]),
)
.expect("update session");
set_modified_at(
&session.path,
changed_at - Duration::from_secs(/*secs*/ index as u64),
);
}
let sessions =
detect_recent_cla_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_cla_sessions(&external_agent_home, root.path()).expect("detect");
assert_eq!(sessions, vec![oldest_session]);
}
#[test]
fn skips_already_imported_current_session_versions() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[record("user", "hello there", project_root.as_path())],
);
record_imported_session(root.path(), &session_path, ThreadId::new())
.expect("record import");
assert!(
detect_recent_cla_sessions(&external_agent_home, root.path())
.expect("detect")
.is_empty()
);
}
#[test]
fn redetects_sessions_when_source_contents_change_after_import() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let project_root = root.path().join("repo");
let session_path = write_session(
&external_agent_home,
&project_root,
"session.jsonl",
&[record("user", "hello there", project_root.as_path())],
);
record_imported_session(root.path(), &session_path, ThreadId::new())
.expect("record import");
std::fs::write(
&session_path,
jsonl(&[
record("user", "hello there", project_root.as_path()),
record("assistant", "new reply", project_root.as_path()),
]),
)
.expect("update session");
set_modified_at(
&session_path,
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
);
let sessions =
detect_recent_cla_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()),
}]
);
}
fn write_session(
external_agent_home: &Path,
project_root: &Path,
file_name: &str,
records: &[JsonValue],
) -> std::path::PathBuf {
let projects_dir = external_agent_home.join("projects").join("repo");
std::fs::create_dir_all(project_root).expect("project root");
std::fs::create_dir_all(&projects_dir).expect("projects dir");
let session_path = projects_dir.join(file_name);
std::fs::write(&session_path, jsonl(records)).expect("session");
session_path
}
fn set_modified_at(path: &Path, modified_at: SystemTime) {
OpenOptions::new()
.write(true)
.open(path)
.expect("open session")
.set_times(FileTimes::new().set_modified(modified_at))
.expect("set session modified time");
}
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, &timestamp)
}
fn record_at(role: &str, text: &str, cwd: &Path, timestamp: &str) -> JsonValue {
serde_json::json!({
"type": role,
"cwd": cwd,
"timestamp": timestamp,
"message": { "content": text }
})
}
fn custom_title_record(title: &str) -> JsonValue {
serde_json::json!({
"type": "custom-title",
"customTitle": title,
})
}
fn ai_title_record(title: &str) -> JsonValue {
serde_json::json!({
"type": "ai-title",
"aiTitle": title,
})
}
fn jsonl(records: &[JsonValue]) -> String {
records
.iter()
.map(JsonValue::to_string)
.collect::<Vec<_>>()
.join("\n")
}
}
@@ -1,211 +0,0 @@
use super::ExternalAgentSessionMigration;
use super::ledger::load_import_ledger;
use super::ledger::save_import_ledger;
use super::now_unix_seconds;
use super::records::summarize_session_with_cwd;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
const SESSION_IMPORT_MAX_COUNT: usize = 50;
const SESSION_IMPORT_MAX_AGE: Duration = Duration::from_secs(30 * 24 * 60 * 60);
pub fn detect_recent_cur_sessions(
external_agent_home: &Path,
codex_home: &Path,
) -> io::Result<Vec<ExternalAgentSessionMigration>> {
let projects_root = external_agent_home.join("projects");
if !projects_root.is_dir() {
return Ok(Vec::new());
}
let now = now_unix_seconds();
let mut ledger = load_import_ledger(codex_home)?;
let source_states = ledger.source_states();
let mut candidates = BinaryHeap::with_capacity(SESSION_IMPORT_MAX_COUNT + 1);
for project_entry in fs::read_dir(projects_root)? {
let Ok(project_entry) = project_entry else {
continue;
};
let project_storage = project_entry.path();
if !project_storage.is_dir() {
continue;
}
let fallback_cwd = cur_project_cwd(&project_storage);
for path in cur_transcript_files(&project_storage.join("agent-transcripts")) {
let Ok(metadata) = fs::metadata(&path) else {
continue;
};
let Ok(modified_at) = metadata.modified() else {
continue;
};
let Ok(modified_at) = modified_at.duration_since(std::time::UNIX_EPOCH) else {
continue;
};
if (modified_at.as_secs() as i64)
< now.saturating_sub(SESSION_IMPORT_MAX_AGE.as_secs() as i64)
{
continue;
}
let Ok(modified_at_nanos) = i64::try_from(modified_at.as_nanos()) else {
continue;
};
let Ok(source_path) = fs::canonicalize(&path) else {
continue;
};
if let Some(state) = source_states.get(source_path.as_path())
&& (state.source_modified_at == Some(modified_at_nanos)
|| state.source_modified_at.is_none()
&& modified_at.as_secs() as i64 <= state.imported_at)
{
continue;
}
candidates.push((Reverse(modified_at_nanos), path, fallback_cwd.clone()));
if candidates.len() > SESSION_IMPORT_MAX_COUNT {
candidates.pop();
}
}
}
drop(source_states);
let mut migrations = Vec::new();
let mut ledger_changed = false;
for (modified_at, path, fallback_cwd) in candidates.into_sorted_vec() {
match ledger.refresh_current_source(&path, modified_at.0) {
Ok(false) => {}
Ok(true) => {
ledger_changed = true;
continue;
}
Err(_) => continue,
}
let Ok(Some(summary)) = summarize_session_with_cwd(&path, fallback_cwd.as_deref()) else {
continue;
};
migrations.push(summary.migration);
}
if ledger_changed {
save_import_ledger(codex_home, &ledger)?;
}
Ok(migrations)
}
fn cur_transcript_files(transcripts_root: &Path) -> Vec<PathBuf> {
let mut files = Vec::new();
let mut pending = vec![transcripts_root.to_path_buf()];
while let Some(directory) = pending.pop() {
let Ok(entries) = fs::read_dir(directory) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
let Ok(file_type) = entry.file_type() else {
continue;
};
if file_type.is_dir() {
if entry.file_name() != "subagents" {
pending.push(path);
}
} else if file_type.is_file()
&& path.extension().and_then(|extension| extension.to_str()) == Some("jsonl")
{
files.push(path);
}
}
}
files.sort();
files
}
fn cur_project_cwd(project_storage: &Path) -> Option<PathBuf> {
let encoded = project_storage.file_name()?.to_str()?;
decode_cur_project_path(encoded)
}
#[cfg(not(windows))]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
let root = Path::new("/");
let mut matches = Vec::new();
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
if let Some(encoded) = encoded.strip_prefix('-') {
collect_cur_project_paths(encoded, root, root, /*depth*/ 0, &mut matches);
}
unique_path(matches)
}
#[cfg(windows)]
fn decode_cur_project_path(encoded: &str) -> Option<PathBuf> {
let drive = encoded.as_bytes().first().copied()?;
if !drive.is_ascii_alphabetic() || encoded.as_bytes().get(1) != Some(&b'-') {
return None;
}
let encoded = encoded.get(2..)?;
let base = PathBuf::from(format!("{}:\\", char::from(drive)));
let mut matches = Vec::new();
collect_cur_project_paths(encoded, &base, &base, /*depth*/ 0, &mut matches);
unique_path(matches)
}
fn collect_cur_project_paths(
encoded: &str,
base: &Path,
root: &Path,
depth: usize,
matches: &mut Vec<PathBuf>,
) {
if encoded.is_empty() || depth > 32 || matches.len() > 1 {
return;
}
let Ok(entries) = fs::read_dir(base) else {
return;
};
for entry in entries.flatten() {
if matches.len() > 1 {
break;
}
let candidate = entry.path();
if !candidate.is_dir() {
continue;
}
let Ok(candidate_from_root) = candidate.strip_prefix(root) else {
continue;
};
let candidate_slug = cur_project_path_slug(candidate_from_root);
if candidate_slug == encoded {
if !matches.contains(&candidate) {
matches.push(candidate);
}
} else if encoded
.strip_prefix(&candidate_slug)
.is_some_and(|remaining| remaining.starts_with('-'))
{
collect_cur_project_paths(encoded, &candidate, root, depth + 1, matches);
}
}
}
fn cur_project_path_slug(path: &Path) -> String {
path.to_string_lossy()
.trim_start_matches(['/', '\\'])
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect()
}
fn unique_path(mut matches: Vec<PathBuf>) -> Option<PathBuf> {
(matches.len() == 1).then(|| matches.swap_remove(0))
}
#[cfg(test)]
#[path = "detect_cur_tests.rs"]
mod tests;
@@ -1,299 +0,0 @@
use super::*;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use std::fs::FileTimes;
use std::fs::OpenOptions;
use std::time::SystemTime;
use tempfile::TempDir;
#[test]
fn detects_cur_transcript_with_project_cwd() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace with.dots_and-dashes");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let transcript = write_transcript(
&external_agent_home,
&encoded_project,
"a-session",
"first request",
);
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: project_root,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn detects_cur_transcript_with_embedded_unc_cwd() {
let root = TempDir::new().expect("tempdir");
let external_agent_home = root.path().join(".external");
let encoded_project = "server-share-repo";
let unc_cwd = PathBuf::from(r"\\server\share\repo");
let transcript = external_agent_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts")
.join("unc-session/unc-session.jsonl");
fs::create_dir_all(transcript.parent().expect("transcript parent"))
.expect("transcript directory");
fs::write(
&transcript,
[
serde_json::json!({
"cwd": unc_cwd,
"role": "user",
"timestamp_ms": 1_800_000_000_000_i64,
"message": {
"content": [{
"type": "text",
"text": "<user_query>first request</user_query>",
}],
},
})
.to_string(),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "first answer"}],
},
})
.to_string(),
]
.join("\n"),
)
.expect("transcript");
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: unc_cwd,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn skips_cur_subagent_transcripts() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let transcript = write_transcript(
&external_agent_home,
&encoded_project,
"main-session",
"first request",
);
let subagent_transcript = external_agent_home
.join("projects")
.join(&encoded_project)
.join("agent-transcripts")
.join("main-session/subagents/worker/worker.jsonl");
fs::create_dir_all(
subagent_transcript
.parent()
.expect("subagent transcript parent"),
)
.expect("subagent transcript directory");
fs::write(&subagent_transcript, transcript_contents("first request"))
.expect("subagent transcript");
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(
sessions,
vec![ExternalAgentSessionMigration {
path: transcript,
cwd: project_root,
title: Some("first request".to_string()),
}]
);
}
#[test]
fn rejects_ambiguous_encoded_project_cwd() {
let root = TempDir::new().expect("tempdir");
let nested_project = root.path().join("workspace").join("nested");
let hyphenated_project = root.path().join("workspace-nested");
fs::create_dir_all(&nested_project).expect("nested project");
fs::create_dir_all(&hyphenated_project).expect("hyphenated project");
assert_eq!(
decode_cur_project_path(&encode_project_path(&nested_project)),
None
);
}
#[test]
fn ignores_cur_sessions_older_than_import_window() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let transcript = write_transcript(
&external_agent_home,
&encode_project_path(&project_root),
"old-session",
"old request",
);
set_modified_at(
&transcript,
SystemTime::UNIX_EPOCH + Duration::from_secs(/*secs*/ 1),
);
assert!(
detect_recent_cur_sessions(&external_agent_home, root.path())
.expect("detect sessions")
.is_empty()
);
}
#[test]
fn detects_cur_sessions_in_batches_and_redetects_modified_imports() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("workspace");
fs::create_dir_all(&project_root).expect("project root");
let external_agent_home = root.path().join(".external");
let encoded_project = encode_project_path(&project_root);
let modified_at = SystemTime::now();
let mut expected = Vec::new();
for index in 0..=SESSION_IMPORT_MAX_COUNT {
let session_id = format!("session-{index:02}");
let title = format!("request {index}");
let path = write_transcript(&external_agent_home, &encoded_project, &session_id, &title);
set_modified_at(
&path,
modified_at - Duration::from_secs(/*secs*/ index as u64),
);
expected.push(ExternalAgentSessionMigration {
path,
cwd: project_root.clone(),
title: Some(title),
});
}
let oldest_session = expected.pop().expect("oldest session");
let sessions =
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions");
assert_eq!(sessions, expected);
for session in &sessions {
super::super::ledger::record_imported_session(root.path(), &session.path, ThreadId::new())
.expect("record import");
}
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![oldest_session.clone()]
);
super::super::ledger::record_imported_session(
root.path(),
&oldest_session.path,
ThreadId::new(),
)
.expect("record oldest import");
assert!(
detect_recent_cur_sessions(&external_agent_home, root.path())
.expect("detect sessions")
.is_empty()
);
let modified_session = &expected[0];
let updated_record = serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "updated answer"}],
},
})
.to_string();
fs::write(
&modified_session.path,
format!(
"{}\n{updated_record}",
transcript_contents(modified_session.title.as_deref().expect("session title"))
),
)
.expect("update transcript");
set_modified_at(
&modified_session.path,
SystemTime::now() + Duration::from_secs(/*secs*/ 1),
);
assert_eq!(
detect_recent_cur_sessions(&external_agent_home, root.path()).expect("detect sessions"),
vec![modified_session.clone()]
);
}
fn write_transcript(
external_agent_home: &Path,
encoded_project: &str,
session_id: &str,
first_request: &str,
) -> PathBuf {
let transcript = external_agent_home
.join("projects")
.join(encoded_project)
.join("agent-transcripts")
.join(session_id)
.join(format!("{session_id}.jsonl"));
fs::create_dir_all(transcript.parent().expect("transcript parent"))
.expect("transcript directory");
fs::write(&transcript, transcript_contents(first_request)).expect("transcript");
transcript
}
fn transcript_contents(first_request: &str) -> String {
[
serde_json::json!({
"role": "user",
"message": {
"content": [{
"type": "text",
"text": format!("<user_query>{first_request}</user_query>"),
}],
},
})
.to_string(),
serde_json::json!({
"role": "assistant",
"message": {
"content": [{"type": "text", "text": "first answer"}],
},
})
.to_string(),
]
.join("\n")
}
fn set_modified_at(path: &Path, modified_at: SystemTime) {
OpenOptions::new()
.write(true)
.open(path)
.expect("open transcript")
.set_times(FileTimes::new().set_modified(modified_at))
.expect("set transcript modified time");
}
#[cfg(windows)]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path).replacen("--", "-", 1)
}
#[cfg(not(windows))]
fn encode_project_path(path: &Path) -> String {
cur_project_path_slug(path)
}
@@ -17,7 +17,7 @@ const SESSION_IMPORT_LEDGER_FILE: &str = "external_agent_session_imports.json";
const SESSION_HASH_BUFFER_SIZE: usize = 64 * 1024;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub(super) struct ImportedExternalAgentSessionLedger {
pub(crate) struct ImportedExternalAgentSessionLedger {
records: Vec<ImportedExternalAgentSessionRecord>,
}
@@ -48,7 +48,7 @@ pub struct ImportedConnectorCandidate {
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ImportedSourceState {
pub(crate) struct ImportedSourceState {
pub source_modified_at: Option<i64>,
pub imported_at: i64,
}
@@ -144,7 +144,7 @@ pub fn read_imported_connector_candidates(
}
impl ImportedExternalAgentSessionLedger {
pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
pub(crate) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
let mut states = HashMap::new();
for record in &self.records {
states.insert(
@@ -158,7 +158,7 @@ impl ImportedExternalAgentSessionLedger {
states
}
pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
pub(crate) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
if self.records.is_empty() {
return Ok(false);
}
@@ -176,7 +176,7 @@ impl ImportedExternalAgentSessionLedger {
}))
}
pub(super) fn refresh_current_source(
pub(crate) fn refresh_current_source(
&mut self,
source_path: &Path,
source_modified_at: i64,
@@ -203,7 +203,7 @@ impl ImportedExternalAgentSessionLedger {
}
}
pub(super) fn load_import_ledger(
pub(crate) fn load_import_ledger(
codex_home: &Path,
) -> io::Result<ImportedExternalAgentSessionLedger> {
let path = import_ledger_path(codex_home);
@@ -222,7 +222,7 @@ pub(super) fn load_import_ledger(
})
}
pub(super) fn save_import_ledger(
pub(crate) fn save_import_ledger(
codex_home: &Path,
ledger: &ImportedExternalAgentSessionLedger,
) -> io::Result<()> {
@@ -1,11 +1,8 @@
//! Parsing and export helpers for external-agent session histories.
mod connectors_cla;
mod detect_cla;
mod detect_cur;
mod export;
mod ledger;
mod records;
pub(crate) mod ledger;
pub(crate) mod records;
mod title;
use codex_protocol::protocol::RolloutItem;
@@ -14,10 +11,10 @@ use std::io;
use std::path::Path;
use std::path::PathBuf;
pub use connectors_cla::ImportedSessionConnectorAttribution;
pub use connectors_cla::detect_imported_cla_session_connectors;
pub use detect_cla::detect_recent_cla_sessions;
pub use detect_cur::detect_recent_cur_sessions;
pub use crate::detect::sessions::ImportedSessionConnectorAttribution;
pub use crate::detect::sessions::detect_imported_cla_session_connectors;
pub use crate::detect::sessions::detect_recent_cla_sessions;
pub use crate::detect::sessions::detect_recent_cur_sessions;
use export::load_session_for_import_with_content_sha256;
pub use ledger::CompletedExternalAgentSessionImport;
pub use ledger::ImportedConnectorCandidate;
@@ -29,7 +26,7 @@ pub use records::summarize_session;
const SESSION_TITLE_MAX_LEN: usize = 120;
fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
pub(crate) fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
name.map(str::trim)
.filter(|name| !name.is_empty())
.map(ToOwned::to_owned)
@@ -145,7 +142,7 @@ fn truncate(text: &str, max_len: usize) -> String {
format!("{prefix}...")
}
fn now_unix_seconds() -> i64 {
pub(crate) fn now_unix_seconds() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|duration| duration.as_secs() as i64)