mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Add Cursor support to setup import (#33426)
## What changed - Detect and import supported Cursor settings, sandbox permissions, MCP servers, project instructions, hooks, agents, commands, plugins, and recent chat sessions. - Update the `/import` flow to check Claude Code and Cursor, prompting for a source when both have importable data. - Add `migrationSource` to the app-server detect and import requests so each operation uses the selected adapter, while retaining `source` for import attribution and backwards compatibility. ## Testing - Add coverage for Cursor config conversion, hooks, plugins, session discovery and conversion, app-server imports, and TUI source selection. GitOrigin-RevId: a65a1511b6c1c2f9e3e0cfe4f816bb435807282e
This commit is contained in:
1 parent
0f2321f244
commit
1c4af96394
47 files changed
+2962
-247
No files matched your search
@@ -0,0 +1,211 @@
|
||||
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;
|
||||
@@ -0,0 +1,299 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::ConversationMessage;
|
||||
use super::ImportedExternalAgentSession;
|
||||
use super::MessageRole;
|
||||
use super::records::read_session_import;
|
||||
use super::records::read_session_import_with_cwd;
|
||||
use super::summarize_for_label;
|
||||
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
|
||||
use super::title::SessionTitleCandidates;
|
||||
@@ -26,14 +26,17 @@ const EXTERNAL_SESSION_IMPORTED_MARKER: &str = "<EXTERNAL SESSION IMPORTED>";
|
||||
|
||||
#[cfg(test)]
|
||||
fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
|
||||
Ok(load_session_for_import_with_content_sha256(path)?
|
||||
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session))
|
||||
Ok(
|
||||
load_session_for_import_with_content_sha256(path, /*fallback_cwd*/ None)?
|
||||
.map(|(session, _content_sha256, _attributed_mcp_server_ids)| session),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn load_session_for_import_with_content_sha256(
|
||||
path: &Path,
|
||||
fallback_cwd: Option<&Path>,
|
||||
) -> io::Result<Option<(ImportedExternalAgentSession, String, BTreeSet<String>)>> {
|
||||
let parsed = read_session_import(path)?;
|
||||
let parsed = read_session_import_with_cwd(path, fallback_cwd)?;
|
||||
let Some(cwd) = parsed.cwd else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
mod connectors_cla;
|
||||
mod detect_cla;
|
||||
mod detect_cur;
|
||||
mod export;
|
||||
mod ledger;
|
||||
mod records;
|
||||
@@ -16,6 +17,7 @@ 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;
|
||||
use export::load_session_for_import_with_content_sha256;
|
||||
pub use ledger::CompletedExternalAgentSessionImport;
|
||||
pub use ledger::ImportedConnectorCandidate;
|
||||
@@ -33,6 +35,15 @@ fn normalized_connector_display_name(name: Option<&str>) -> Option<String> {
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
/// Selects whether session records must carry their own project metadata.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SessionMetadataMode {
|
||||
/// Read the project path only from the session records.
|
||||
Embedded,
|
||||
/// Use the detected migration path when the session records omit a project path.
|
||||
MigrationFallback,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalAgentSessionMigration {
|
||||
pub path: PathBuf,
|
||||
@@ -59,18 +70,38 @@ pub struct PendingSessionImport {
|
||||
pub fn prepare_validated_session_import(
|
||||
codex_home: &Path,
|
||||
session: ExternalAgentSessionMigration,
|
||||
) -> io::Result<Option<PendingSessionImport>> {
|
||||
prepare_validated_session_import_with_metadata_mode(
|
||||
codex_home,
|
||||
session,
|
||||
SessionMetadataMode::Embedded,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn prepare_validated_session_import_with_metadata_mode(
|
||||
codex_home: &Path,
|
||||
session: ExternalAgentSessionMigration,
|
||||
metadata_mode: SessionMetadataMode,
|
||||
) -> io::Result<Option<PendingSessionImport>> {
|
||||
let has_been_imported = has_current_session_been_imported(codex_home, &session.path)?;
|
||||
if has_been_imported {
|
||||
return Ok(None);
|
||||
}
|
||||
load_importable_session(&session.path)
|
||||
load_importable_session(&session.path, &session.cwd, metadata_mode)
|
||||
}
|
||||
|
||||
fn load_importable_session(path: &Path) -> io::Result<Option<PendingSessionImport>> {
|
||||
fn load_importable_session(
|
||||
path: &Path,
|
||||
fallback_cwd: &Path,
|
||||
metadata_mode: SessionMetadataMode,
|
||||
) -> io::Result<Option<PendingSessionImport>> {
|
||||
let source_path = std::fs::canonicalize(path)?;
|
||||
let fallback_cwd = match metadata_mode {
|
||||
SessionMetadataMode::Embedded => None,
|
||||
SessionMetadataMode::MigrationFallback => Some(fallback_cwd),
|
||||
};
|
||||
let Some((imported_session, source_content_sha256, attributed_mcp_server_ids)) =
|
||||
load_session_for_import_with_content_sha256(&source_path)?
|
||||
load_session_for_import_with_content_sha256(&source_path, fallback_cwd)?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
@@ -180,6 +211,48 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_fallback_metadata_is_opt_in() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let source_path = root.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&source_path,
|
||||
serde_json::json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"timestamp_ms": 1_782_817_200_000_i64,
|
||||
"message": {"content": "first request"},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("session");
|
||||
let migration = session_migration(&source_path);
|
||||
|
||||
assert!(
|
||||
prepare_validated_session_import_with_metadata_mode(
|
||||
root.path(),
|
||||
migration.clone(),
|
||||
SessionMetadataMode::Embedded,
|
||||
)
|
||||
.expect("embedded metadata mode")
|
||||
.is_none()
|
||||
);
|
||||
let pending = prepare_validated_session_import_with_metadata_mode(
|
||||
root.path(),
|
||||
migration,
|
||||
SessionMetadataMode::MigrationFallback,
|
||||
)
|
||||
.expect("fallback metadata mode")
|
||||
.expect("pending import");
|
||||
|
||||
assert_eq!(pending.session.cwd, root.path());
|
||||
assert_eq!(
|
||||
pending.session.first_user_message.as_deref(),
|
||||
Some("first request")
|
||||
);
|
||||
assert!(!pending.session.rollout_items.is_empty());
|
||||
}
|
||||
|
||||
fn session_migration(path: &Path) -> ExternalAgentSessionMigration {
|
||||
ExternalAgentSessionMigration {
|
||||
path: path.to_path_buf(),
|
||||
|
||||
@@ -36,7 +36,15 @@ pub(super) struct ParsedSessionImport {
|
||||
}
|
||||
|
||||
pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
||||
summarize_session_with_cwd(path, /*fallback_cwd*/ None)
|
||||
}
|
||||
|
||||
pub(crate) fn summarize_session_with_cwd(
|
||||
path: &Path,
|
||||
fallback_cwd: Option<&Path>,
|
||||
) -> io::Result<Option<SessionSummary>> {
|
||||
let file = File::open(path)?;
|
||||
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
|
||||
let reader = BufReader::new(file);
|
||||
let mut cwd = None;
|
||||
let mut custom_title = None;
|
||||
@@ -67,7 +75,8 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
||||
if let Some(title) = ai_title_from_record(&record) {
|
||||
ai_title = Some(title.to_string());
|
||||
}
|
||||
let Some(message) = conversation_message_from_owned_record(&mut record) else {
|
||||
let Some(message) = conversation_message_from_owned_record(&mut record, fallback_timestamp)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
saw_message = true;
|
||||
@@ -83,7 +92,7 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
||||
}
|
||||
}
|
||||
|
||||
let Some(cwd) = cwd else {
|
||||
let Some(cwd) = cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !saw_message {
|
||||
@@ -109,8 +118,12 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
|
||||
}))
|
||||
}
|
||||
|
||||
pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport> {
|
||||
pub(super) fn read_session_import_with_cwd(
|
||||
path: &Path,
|
||||
fallback_cwd: Option<&Path>,
|
||||
) -> io::Result<ParsedSessionImport> {
|
||||
let file = File::open(path)?;
|
||||
let fallback_timestamp = fallback_cwd.and_then(|_| file_modified_at_seconds(&file));
|
||||
let mut reader = BufReader::new(file);
|
||||
let mut cwd = None;
|
||||
let mut custom_title = None;
|
||||
@@ -152,12 +165,14 @@ pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport
|
||||
if let Some(title) = ai_title_from_record(&record) {
|
||||
ai_title = Some(title.to_string());
|
||||
}
|
||||
if let Some(message) = conversation_message_from_owned_record(&mut record) {
|
||||
if let Some(message) =
|
||||
conversation_message_from_owned_record(&mut record, fallback_timestamp)
|
||||
{
|
||||
messages.push(message);
|
||||
}
|
||||
}
|
||||
Ok(ParsedSessionImport {
|
||||
cwd,
|
||||
cwd: cwd.or_else(|| fallback_cwd.map(Path::to_path_buf)),
|
||||
custom_title,
|
||||
ai_title,
|
||||
messages,
|
||||
@@ -182,9 +197,16 @@ fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str)
|
||||
.filter(|title| !title.is_empty())
|
||||
}
|
||||
|
||||
fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<ConversationMessage> {
|
||||
let record_type = record.get("type")?.as_str()?;
|
||||
if record_type != "assistant" && record_type != "user" {
|
||||
fn conversation_message_from_owned_record(
|
||||
record: &mut JsonValue,
|
||||
fallback_timestamp: Option<i64>,
|
||||
) -> Option<ConversationMessage> {
|
||||
let record_type = record
|
||||
.get("type")
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|record_type| matches!(*record_type, "assistant" | "user"))
|
||||
.or_else(|| record.get("role").and_then(JsonValue::as_str))?;
|
||||
if !matches!(record_type, "assistant" | "user") {
|
||||
return None;
|
||||
}
|
||||
if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true)
|
||||
@@ -197,7 +219,14 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
|
||||
let timestamp = record
|
||||
.get("timestamp")
|
||||
.and_then(JsonValue::as_str)
|
||||
.and_then(parse_timestamp);
|
||||
.and_then(parse_timestamp)
|
||||
.or_else(|| {
|
||||
record
|
||||
.get("timestamp_ms")
|
||||
.and_then(JsonValue::as_i64)
|
||||
.map(|value| value / 1_000)
|
||||
})
|
||||
.or(fallback_timestamp);
|
||||
let content = record.get_mut("message")?.get_mut("content")?.take();
|
||||
let extracted = match content {
|
||||
JsonValue::String(text) => {
|
||||
@@ -211,17 +240,46 @@ fn conversation_message_from_owned_record(record: &mut JsonValue) -> Option<Conv
|
||||
}
|
||||
content => extract_message_text(&content)?,
|
||||
};
|
||||
let role = if is_assistant || extracted.only_tool_result {
|
||||
MessageRole::Assistant
|
||||
} else {
|
||||
MessageRole::User
|
||||
};
|
||||
let text = if role == MessageRole::User {
|
||||
unwrap_user_query(extracted.text)
|
||||
} else {
|
||||
extracted.text
|
||||
};
|
||||
Some(ConversationMessage {
|
||||
role: if is_assistant || extracted.only_tool_result {
|
||||
MessageRole::Assistant
|
||||
} else {
|
||||
MessageRole::User
|
||||
},
|
||||
text: extracted.text,
|
||||
role,
|
||||
text,
|
||||
timestamp,
|
||||
})
|
||||
}
|
||||
|
||||
fn unwrap_user_query(text: String) -> String {
|
||||
let trimmed = text.trim();
|
||||
let Some(inner) = trimmed
|
||||
.strip_prefix("<user_query>")
|
||||
.and_then(|inner| inner.strip_suffix("</user_query>"))
|
||||
.map(str::trim)
|
||||
.filter(|inner| !inner.is_empty())
|
||||
else {
|
||||
return text;
|
||||
};
|
||||
inner.to_string()
|
||||
}
|
||||
|
||||
fn file_modified_at_seconds(file: &File) -> Option<i64> {
|
||||
file.metadata()
|
||||
.ok()?
|
||||
.modified()
|
||||
.ok()?
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.ok()
|
||||
.and_then(|duration| i64::try_from(duration.as_secs()).ok())
|
||||
}
|
||||
|
||||
struct ExtractedMessage {
|
||||
text: String,
|
||||
only_tool_result: bool,
|
||||
@@ -379,7 +437,7 @@ mod tests {
|
||||
"type": "user",
|
||||
"cwd": root.path(),
|
||||
"timestamp": "2026-06-03T12:00:00Z",
|
||||
"message": { "content": "first request" },
|
||||
"message": { "content": "<user_query>\nfirst request\n</user_query>" },
|
||||
})
|
||||
.to_string(),
|
||||
"not json".to_string(),
|
||||
@@ -397,7 +455,8 @@ mod tests {
|
||||
.join("\n");
|
||||
std::fs::write(&path, &contents).expect("session");
|
||||
|
||||
let parsed = read_session_import(&path).expect("parse session");
|
||||
let parsed =
|
||||
read_session_import_with_cwd(&path, /*fallback_cwd*/ None).expect("parse session");
|
||||
|
||||
assert_eq!(parsed.cwd.as_deref(), Some(root.path()));
|
||||
assert_eq!(parsed.custom_title.as_deref(), Some("custom title"));
|
||||
@@ -410,6 +469,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn embedded_cwd_overrides_migration_fallback() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let embedded_cwd = root.path().join("embedded");
|
||||
let fallback_cwd = root.path().join("fallback");
|
||||
let path = root.path().join("session.jsonl");
|
||||
std::fs::write(
|
||||
&path,
|
||||
serde_json::json!({
|
||||
"cwd": embedded_cwd,
|
||||
"role": "user",
|
||||
"message": {"content": "first request"},
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.expect("session");
|
||||
|
||||
let parsed =
|
||||
read_session_import_with_cwd(&path, Some(&fallback_cwd)).expect("parse session");
|
||||
let summary = summarize_session_with_cwd(&path, Some(&fallback_cwd))
|
||||
.expect("summarize session")
|
||||
.expect("session summary");
|
||||
|
||||
assert_eq!(parsed.cwd.as_deref(), Some(embedded_cwd.as_path()));
|
||||
assert_eq!(summary.migration.cwd, embedded_cwd);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
|
||||
let block = serde_json::json!({
|
||||
|
||||
Reference in new issue
Block a user