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:
charlesgong-openai authored and copyberry committed 2026-07-15 21:03:49 +00:00
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!({