Prepare external agent migration for source adapters (#32884)

## What changed

- Add an optional `source` selector to `externalAgentConfig/detect`, while preserving the existing behavior for omitted, `null`, and unrecognized values.
- Pass `claude-code` as the source for TUI detection and import requests.
- Move source-specific migration behavior behind an adapter boundary and consolidate session migration into `codex-external-agent-migration`.

## Testing

- Cover explicit, omitted, and unknown detection source values with app-server protocol tests.
- Add unit coverage for the extracted migration and hook helpers.

GitOrigin-RevId: 63bd3dcb485f3ad7bd996022db4e351506aeea77
This commit is contained in:
charlesgong-openai authored and copyberry committed 2026-07-13 22:15:55 +00:00
1 parent 0877afbe85
commit bc8222b8d9
42 files changed
+2713 -1850

No files matched your search

@@ -0,0 +1,491 @@
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")
}
}
@@ -0,0 +1,549 @@
use super::ConversationMessage;
use super::ImportedExternalAgentSession;
use super::MessageRole;
use super::records::read_session_import;
use super::summarize_for_label;
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
use super::title::SessionTitleCandidates;
use super::title::fallback_title_from_user_message;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AgentMessageEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::TokenCountEvent;
use codex_protocol::protocol::TokenUsage;
use codex_protocol::protocol::TokenUsageInfo;
use codex_protocol::protocol::TurnCompleteEvent;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::protocol::UserMessageEvent;
use codex_utils_output_truncation::approx_tokens_from_byte_count_i64;
use std::io;
use std::path::Path;
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)| session),
)
}
pub(crate) fn load_session_for_import_with_content_sha256(
path: &Path,
) -> io::Result<Option<(ImportedExternalAgentSession, String)>> {
let parsed = read_session_import(path)?;
let Some(cwd) = parsed.cwd else {
return Ok(None);
};
let messages = parsed.messages;
let first_user_message_text = messages
.iter()
.find(|message| message.role == MessageRole::User)
.map(|message| message.text.as_str());
let first_user_message = first_user_message_text.map(summarize_for_label);
let fallback_title = messages
.iter()
.filter(|message| message.role == MessageRole::User)
.find_map(|message| fallback_title_from_user_message(&message.text))
.or_else(|| first_user_message_text.map(|_| IMPORTED_SESSION_FALLBACK_TITLE.to_string()));
let title = SessionTitleCandidates {
custom_title: parsed.custom_title,
ai_title: parsed.ai_title,
fallback_title,
}
.select();
let rollout_items = rollout_items_from_messages(messages);
if rollout_items.is_empty() {
return Ok(None);
}
Ok(Some((
ImportedExternalAgentSession {
cwd,
title,
first_user_message,
rollout_items,
},
parsed.content_sha256,
)))
}
fn rollout_items_from_messages(messages: Vec<ConversationMessage>) -> Vec<RolloutItem> {
let mut items = Vec::new();
let mut current_turn = None;
let mut response_item_bytes = 0i64;
let mut last_model_visible_tokens = 0i64;
let mut user_turn_count = 0usize;
let completed_at = messages.last().and_then(|message| message.timestamp);
for message in messages {
match message.role {
MessageRole::User => {
let started_at = message.timestamp;
if let Some((turn_id, previous_started_at)) = current_turn.take() {
items.push(turn_complete_item(
turn_id,
previous_started_at,
/*completed_at*/ None,
));
}
user_turn_count += 1;
let turn_id = format!("external-import-turn-{user_turn_count}");
items.push(RolloutItem::EventMsg(EventMsg::TurnStarted(
TurnStartedEvent {
turn_id: turn_id.clone(),
trace_id: None,
started_at,
model_context_window: None,
collaboration_mode_kind: Default::default(),
},
)));
items.push(RolloutItem::EventMsg(EventMsg::UserMessage(
UserMessageEvent {
message: message.text.clone(),
..Default::default()
},
)));
response_item_bytes =
response_item_bytes.saturating_add(message_byte_count(&message));
items.push(RolloutItem::ResponseItem(response_item(message)));
current_turn = Some((turn_id, started_at));
}
MessageRole::Assistant => {
if current_turn.is_none() {
continue;
}
response_item_bytes =
response_item_bytes.saturating_add(message_byte_count(&message));
last_model_visible_tokens = approx_tokens_from_byte_count_i64(response_item_bytes);
items.push(RolloutItem::EventMsg(EventMsg::AgentMessage(
AgentMessageEvent {
message: message.text.clone(),
phase: None,
memory_citation: None,
},
)));
items.push(RolloutItem::ResponseItem(response_item(message)));
}
}
}
if let Some((turn_id, started_at)) = current_turn {
items.push(external_session_imported_marker_item());
items.push(token_count_item(last_model_visible_tokens));
items.push(turn_complete_item(turn_id, started_at, completed_at));
}
items
}
fn external_session_imported_marker_item() -> RolloutItem {
RolloutItem::EventMsg(EventMsg::AgentMessage(AgentMessageEvent {
message: EXTERNAL_SESSION_IMPORTED_MARKER.to_string(),
phase: None,
memory_citation: None,
}))
}
fn response_item(message: ConversationMessage) -> ResponseItem {
let content = match message.role {
MessageRole::Assistant => ContentItem::OutputText { text: message.text },
MessageRole::User => ContentItem::InputText { text: message.text },
};
ResponseItem::Message {
id: None,
role: match message.role {
MessageRole::Assistant => "assistant".to_string(),
MessageRole::User => "user".to_string(),
},
content: vec![content],
phase: None,
internal_chat_message_metadata_passthrough: None,
}
}
fn message_byte_count(message: &ConversationMessage) -> i64 {
i64::try_from(message.text.len()).unwrap_or(i64::MAX)
}
fn token_count_item(last_model_visible_tokens: i64) -> RolloutItem {
let usage = TokenUsage {
total_tokens: last_model_visible_tokens,
..TokenUsage::default()
};
RolloutItem::EventMsg(EventMsg::TokenCount(TokenCountEvent {
info: Some(TokenUsageInfo {
total_token_usage: usage.clone(),
last_token_usage: usage,
model_context_window: None,
}),
rate_limits: None,
}))
}
fn turn_complete_item(
turn_id: String,
started_at: Option<i64>,
completed_at: Option<i64>,
) -> RolloutItem {
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
turn_id,
last_agent_message: None,
error: None,
started_at,
completed_at,
duration_ms: None,
time_to_first_token_ms: None,
}))
}
#[cfg(test)]
mod tests {
use super::*;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::build_turns_from_rollout_items;
use serde_json::Value as JsonValue;
use std::path::Path;
use tempfile::TempDir;
#[test]
fn builds_visible_turns_for_imported_history() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
record("assistant", "first answer", &project_root),
record("user", "second request", &project_root),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
let turns = build_turns_from_rollout_items(&imported.rollout_items);
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].items.len(), 2);
assert_eq!(turns[1].items.len(), 2);
assert_eq!(
turns[1].items[1],
ThreadItem::AgentMessage {
id: "item-4".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
memory_citation: None,
}
);
}
#[test]
fn adds_import_marker_without_copying_last_agent_message() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
record("assistant", "first answer", &project_root),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
let turns = build_turns_from_rollout_items(&imported.rollout_items);
assert_eq!(turns.len(), 1);
assert_eq!(
turns[0].items.last(),
Some(&ThreadItem::AgentMessage {
id: "item-3".into(),
text: EXTERNAL_SESSION_IMPORTED_MARKER.into(),
phase: None,
memory_citation: None,
})
);
let last_turn_complete = imported
.rollout_items
.iter()
.rev()
.find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::TurnComplete(event)) => Some(event),
_ => None,
});
assert_eq!(
last_turn_complete.and_then(|event| event.last_agent_message.as_deref()),
None
);
}
#[test]
fn stores_imported_messages_as_response_items_and_visible_events() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
let request = "r".repeat(1_000);
let answer = "a".repeat(1_000);
std::fs::write(
&path,
jsonl(&[
record("user", &request, &project_root),
record("assistant", &answer, &project_root),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
let response_message_count = imported
.rollout_items
.iter()
.filter(|item| {
matches!(
item,
RolloutItem::ResponseItem(ResponseItem::Message { .. })
)
})
.count();
let visible_message_event_count = imported
.rollout_items
.iter()
.filter(|item| match item {
RolloutItem::EventMsg(EventMsg::UserMessage(event)) => event.message == request,
RolloutItem::EventMsg(EventMsg::AgentMessage(event)) => event.message == answer,
_ => false,
})
.count();
assert_eq!(response_message_count, 2);
assert_eq!(visible_message_event_count, 2);
}
#[test]
fn loads_custom_title_for_imported_session() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
custom_title_record("named by source app"),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
assert_eq!(imported.title.as_deref(), Some("named by source app"));
}
#[test]
fn loads_ai_title_for_imported_session() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
ai_title_record("generated by source app"),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
assert_eq!(imported.title.as_deref(), Some("generated by source app"));
}
#[test]
fn loads_custom_title_over_later_ai_title_for_imported_session() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
custom_title_record("named by source app"),
ai_title_record("generated by source app"),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
assert_eq!(imported.title.as_deref(), Some("named by source app"));
}
#[test]
fn sanitizes_only_the_imported_session_fallback_title() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
let message = "<system-reminder>\ncontrol context\n</system-reminder>\nFix auth flow";
std::fs::write(&path, jsonl(&[record("user", message, &project_root)])).expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
let imported_user_message = imported.rollout_items.iter().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::UserMessage(event)) => Some(event.message.as_str()),
_ => None,
});
assert_eq!(imported.title.as_deref(), Some("Fix auth flow"));
assert_eq!(
imported.first_user_message.as_deref(),
Some("<system-reminder>")
);
assert_eq!(imported_user_message, Some(message));
}
#[test]
fn skips_control_only_user_messages_when_choosing_fallback_title() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
let control_message = "<ide_selection>src/auth.rs:1-5</ide_selection>";
std::fs::write(
&path,
jsonl(&[
record("user", control_message, &project_root),
record("user", "Fix auth flow", &project_root),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
assert_eq!(imported.title.as_deref(), Some("Fix auth flow"));
assert_eq!(
imported.first_user_message.as_deref(),
Some(control_message)
);
}
#[test]
fn uses_safe_fallback_after_all_user_messages_are_control_only() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record(
"user",
"<ide_selection>src/auth.rs:1-5</ide_selection>",
&project_root,
),
record(
"user",
"<local-command-stderr>tests failed</local-command-stderr>",
&project_root,
),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
assert_eq!(
imported.title.as_deref(),
Some(IMPORTED_SESSION_FALLBACK_TITLE)
);
}
#[test]
fn emits_token_usage_for_imported_history() {
let root = TempDir::new().expect("tempdir");
let project_root = root.path().join("repo");
std::fs::create_dir_all(&project_root).expect("project root");
let path = root.path().join("session.jsonl");
std::fs::write(
&path,
jsonl(&[
record("user", "first request", &project_root),
record("assistant", "first answer", &project_root),
record("user", "second request", &project_root),
]),
)
.expect("session");
let imported = load_session_for_import(&path)
.expect("load")
.expect("session");
let token_count = imported
.rollout_items
.iter()
.find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::TokenCount(event)) => event.info.clone(),
_ => None,
})
.expect("token count event");
assert!(token_count.last_token_usage.total_tokens > 0);
assert_eq!(token_count.total_token_usage, token_count.last_token_usage);
}
fn record(role: &str, text: &str, cwd: &Path) -> JsonValue {
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
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")
}
}
@@ -0,0 +1,225 @@
use super::now_unix_seconds;
use codex_protocol::ThreadId;
use serde::Deserialize;
use serde::Serialize;
use sha2::Digest;
use sha2::Sha256;
use std::collections::HashMap;
use std::fs;
use std::fs::File;
use std::io;
use std::io::Read;
use std::path::Path;
use std::path::PathBuf;
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 {
records: Vec<ImportedExternalAgentSessionRecord>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct ImportedExternalAgentSessionRecord {
source_path: PathBuf,
content_sha256: String,
imported_thread_id: ThreadId,
imported_at: i64,
#[serde(default)]
source_modified_at: Option<i64>,
}
#[derive(Debug, PartialEq, Eq)]
pub struct CompletedExternalAgentSessionImport {
pub source_path: PathBuf,
pub source_content_sha256: String,
pub imported_thread_id: ThreadId,
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ImportedSourceState {
pub source_modified_at: Option<i64>,
pub imported_at: i64,
}
pub fn has_current_session_been_imported(
codex_home: &Path,
source_path: &Path,
) -> io::Result<bool> {
load_import_ledger(codex_home)?.contains_current_source(source_path)
}
#[cfg(test)]
pub(crate) fn record_imported_session(
codex_home: &Path,
source_path: &Path,
imported_thread_id: ThreadId,
) -> io::Result<()> {
let source_path = canonical_source_path(source_path)?;
record_completed_session_imports(
codex_home,
vec![CompletedExternalAgentSessionImport {
source_content_sha256: session_content_sha256(&source_path)?,
source_path,
imported_thread_id,
}],
)
}
pub fn record_completed_session_imports(
codex_home: &Path,
imports: Vec<CompletedExternalAgentSessionImport>,
) -> io::Result<()> {
if imports.is_empty() {
return Ok(());
}
let mut ledger = load_import_ledger(codex_home)?;
let imported_at = now_unix_seconds();
for import in imports {
let source_modified_at = session_modified_at(&import.source_path).ok().flatten();
if let Some(index) = ledger.records.iter().rposition(|record| {
record.source_path == import.source_path
&& record.content_sha256 == import.source_content_sha256
}) {
let mut record = ledger.records.remove(index);
record.imported_thread_id = import.imported_thread_id;
record.imported_at = imported_at;
record.source_modified_at = source_modified_at.or(record.source_modified_at);
ledger.records.push(record);
continue;
}
ledger.records.push(ImportedExternalAgentSessionRecord {
source_path: import.source_path,
content_sha256: import.source_content_sha256,
imported_thread_id: import.imported_thread_id,
imported_at,
source_modified_at,
});
}
save_import_ledger(codex_home, &ledger)
}
impl ImportedExternalAgentSessionLedger {
pub(super) fn source_states(&self) -> HashMap<&Path, ImportedSourceState> {
let mut states = HashMap::new();
for record in &self.records {
states.insert(
record.source_path.as_path(),
ImportedSourceState {
source_modified_at: record.source_modified_at,
imported_at: record.imported_at,
},
);
}
states
}
pub(super) fn contains_current_source(&self, source_path: &Path) -> io::Result<bool> {
if self.records.is_empty() {
return Ok(false);
}
let source_path = canonical_source_path(source_path)?;
if !self
.records
.iter()
.any(|record| record.source_path == source_path)
{
return Ok(false);
}
let content_sha256 = session_content_sha256(&source_path)?;
Ok(self.records.iter().any(|record| {
record.source_path == source_path && record.content_sha256 == content_sha256
}))
}
pub(super) fn refresh_current_source(
&mut self,
source_path: &Path,
source_modified_at: i64,
) -> io::Result<bool> {
let source_path = canonical_source_path(source_path)?;
if !self
.records
.iter()
.any(|record| record.source_path == source_path)
{
return Ok(false);
}
let content_sha256 = session_content_sha256(&source_path)?;
let Some(index) = self.records.iter().rposition(|record| {
record.source_path == source_path && record.content_sha256 == content_sha256
}) else {
return Ok(false);
};
let mut record = self.records.remove(index);
record.imported_at = now_unix_seconds();
record.source_modified_at = Some(source_modified_at);
self.records.push(record);
Ok(true)
}
}
pub(super) fn load_import_ledger(
codex_home: &Path,
) -> io::Result<ImportedExternalAgentSessionLedger> {
let path = import_ledger_path(codex_home);
let raw = match fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(ImportedExternalAgentSessionLedger::default());
}
Err(err) => return Err(err),
};
serde_json::from_str(&raw).map_err(|err| {
io::Error::new(
io::ErrorKind::InvalidData,
format!("invalid external agent session import ledger: {err}"),
)
})
}
pub(super) fn save_import_ledger(
codex_home: &Path,
ledger: &ImportedExternalAgentSessionLedger,
) -> io::Result<()> {
fs::create_dir_all(codex_home)?;
let path = import_ledger_path(codex_home);
let raw = serde_json::to_vec_pretty(ledger).map_err(io::Error::other)?;
fs::write(path, raw)
}
fn import_ledger_path(codex_home: &Path) -> PathBuf {
codex_home.join(SESSION_IMPORT_LEDGER_FILE)
}
fn canonical_source_path(path: &Path) -> io::Result<PathBuf> {
fs::canonicalize(path)
}
fn session_content_sha256(path: &Path) -> io::Result<String> {
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut buffer = [0; SESSION_HASH_BUFFER_SIZE];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
hasher.update(&buffer[..read]);
}
let digest = hasher.finalize();
Ok(format!("{digest:x}"))
}
fn session_modified_at(path: &Path) -> io::Result<Option<i64>> {
Ok(fs::metadata(path)?
.modified()?
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|duration| i64::try_from(duration.as_nanos()).ok()))
}
#[cfg(test)]
#[path = "ledger_tests.rs"]
mod tests;
@@ -0,0 +1,85 @@
use super::CompletedExternalAgentSessionImport;
use super::ImportedExternalAgentSessionLedger;
use super::record_completed_session_imports;
use codex_protocol::ThreadId;
use sha2::Digest;
use sha2::Sha256;
use tempfile::TempDir;
#[test]
fn empty_ledger_does_not_read_source() {
let root = TempDir::new().expect("tempdir");
let missing_source = root.path().join("missing-session.jsonl");
assert!(
!ImportedExternalAgentSessionLedger::default()
.contains_current_source(&missing_source)
.expect("empty ledger cannot contain sources")
);
}
#[test]
fn completed_imports_do_not_read_source_files() {
let root = TempDir::new().expect("tempdir");
let codex_home = root.path().join("codex-home");
let source_path = root.path().join("session.jsonl");
let contents = b"session contents";
std::fs::write(&source_path, contents).expect("source");
let source_path = std::fs::canonicalize(&source_path).expect("canonical source");
std::fs::remove_file(&source_path).expect("remove source");
let imported_thread_id = ThreadId::new();
record_completed_session_imports(
&codex_home,
vec![CompletedExternalAgentSessionImport {
source_path: source_path.clone(),
source_content_sha256: format!("{:x}", Sha256::digest(contents)),
imported_thread_id,
}],
)
.expect("record completed imports");
let ledger = super::load_import_ledger(&codex_home).expect("ledger");
assert_eq!(ledger.records.len(), 1);
assert_eq!(ledger.records[0].source_path, source_path);
assert_eq!(ledger.records[0].imported_thread_id, imported_thread_id);
assert_eq!(ledger.records[0].source_modified_at, None);
}
#[test]
fn completed_import_refreshes_existing_record_metadata() {
let root = TempDir::new().expect("tempdir");
let codex_home = root.path().join("codex-home");
let source_path = root.path().join("session.jsonl");
let contents = b"session contents";
std::fs::write(&source_path, contents).expect("source");
let source_path = std::fs::canonicalize(source_path).expect("canonical source");
let content_sha256 = format!("{:x}", Sha256::digest(contents));
let first_thread_id = ThreadId::new();
let second_thread_id = ThreadId::new();
record_completed_session_imports(
&codex_home,
vec![CompletedExternalAgentSessionImport {
source_path: source_path.clone(),
source_content_sha256: content_sha256.clone(),
imported_thread_id: first_thread_id,
}],
)
.expect("record first import");
record_completed_session_imports(
&codex_home,
vec![CompletedExternalAgentSessionImport {
source_path: source_path.clone(),
source_content_sha256: content_sha256,
imported_thread_id: second_thread_id,
}],
)
.expect("record replacement import");
let ledger = super::load_import_ledger(&codex_home).expect("ledger");
assert_eq!(ledger.records.len(), 1);
assert_eq!(ledger.records[0].source_path, source_path);
assert_eq!(ledger.records[0].imported_thread_id, second_thread_id);
assert!(ledger.records[0].source_modified_at.is_some());
}
@@ -0,0 +1,187 @@
//! Parsing and export helpers for external-agent session histories.
mod detect_cla;
mod export;
mod ledger;
mod records;
mod title;
use codex_protocol::protocol::RolloutItem;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub use detect_cla::detect_recent_cla_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<String>,
}
#[derive(Debug, Clone)]
pub struct ImportedExternalAgentSession {
pub cwd: PathBuf,
pub title: Option<String>,
pub first_user_message: Option<String>,
pub rollout_items: Vec<RolloutItem>,
}
#[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<Option<PendingSessionImport>> {
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<Option<(PathBuf, ImportedExternalAgentSession, String)>> {
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<i64>,
}
#[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::<String>();
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,
}
}
}
@@ -0,0 +1,451 @@
use super::ConversationMessage;
use super::ExternalAgentSessionMigration;
use super::MessageRole;
use super::title::IMPORTED_SESSION_FALLBACK_TITLE;
use super::title::SessionTitleCandidates;
use super::title::fallback_title_from_user_message;
use super::truncate;
use serde_json::Value as JsonValue;
use sha2::Digest;
use sha2::Sha256;
use std::fs::File;
use std::io;
use std::io::BufRead;
use std::io::BufReader;
use std::path::Path;
use std::path::PathBuf;
const NOTE_MAX_LEN: usize = 2_000;
const TOOL_RESULT_MAX_LEN: usize = 4_000;
const EXTERNAL_AGENT_TOOL_CALL_TAG: &str = "external_agent_tool_call";
const EXTERNAL_AGENT_TOOL_RESULT_TAG: &str = "external_agent_tool_result";
pub struct SessionSummary {
pub latest_timestamp: i64,
pub migration: ExternalAgentSessionMigration,
}
pub(super) struct ParsedSessionImport {
pub cwd: Option<PathBuf>,
pub custom_title: Option<String>,
pub ai_title: Option<String>,
pub messages: Vec<ConversationMessage>,
pub content_sha256: String,
}
pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
let file = File::open(path)?;
let reader = BufReader::new(file);
let mut cwd = None;
let mut custom_title = None;
let mut ai_title = None;
let mut fallback_title = None;
let mut saw_user_message = false;
let mut latest_timestamp = None;
let mut saw_message = false;
for line in reader.lines() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(mut record) = serde_json::from_str::<JsonValue>(trimmed) else {
continue;
};
if cwd.is_none() {
cwd = record
.get("cwd")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
}
if let Some(title) = custom_title_from_record(&record) {
custom_title = Some(title.to_string());
}
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 {
continue;
};
saw_message = true;
if message.role == MessageRole::User {
saw_user_message = true;
if fallback_title.is_none() {
fallback_title = fallback_title_from_user_message(&message.text);
}
}
if let Some(timestamp) = message.timestamp {
latest_timestamp =
Some(latest_timestamp.map_or(timestamp, |current: i64| current.max(timestamp)));
}
}
let Some(cwd) = cwd else {
return Ok(None);
};
if !saw_message {
return Ok(None);
}
let Some(latest_timestamp) = latest_timestamp else {
return Ok(None);
};
Ok(Some(SessionSummary {
latest_timestamp,
migration: ExternalAgentSessionMigration {
path: path.to_path_buf(),
cwd,
title: SessionTitleCandidates {
custom_title,
ai_title,
fallback_title: fallback_title.or_else(|| {
saw_user_message.then(|| IMPORTED_SESSION_FALLBACK_TITLE.to_string())
}),
}
.select(),
},
}))
}
pub(super) fn read_session_import(path: &Path) -> io::Result<ParsedSessionImport> {
let file = File::open(path)?;
let mut reader = BufReader::new(file);
let mut cwd = None;
let mut custom_title = None;
let mut ai_title = None;
let mut messages = Vec::new();
let mut line = String::new();
let mut hasher = Sha256::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
break;
}
hasher.update(line.as_bytes());
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let Ok(mut record) = serde_json::from_str::<JsonValue>(trimmed) else {
continue;
};
if cwd.is_none() {
cwd = record
.get("cwd")
.and_then(JsonValue::as_str)
.map(PathBuf::from);
}
if let Some(title) = custom_title_from_record(&record) {
custom_title = Some(title.to_string());
}
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) {
messages.push(message);
}
}
Ok(ParsedSessionImport {
cwd,
custom_title,
ai_title,
messages,
content_sha256: format!("{:x}", hasher.finalize()),
})
}
fn custom_title_from_record(record: &JsonValue) -> Option<&str> {
title_from_record(record, "custom-title", "customTitle")
}
fn ai_title_from_record(record: &JsonValue) -> Option<&str> {
title_from_record(record, "ai-title", "aiTitle")
}
fn title_from_record<'a>(record: &'a JsonValue, record_type: &str, field: &str) -> Option<&'a str> {
(record.get("type").and_then(JsonValue::as_str) == Some(record_type))
.then(|| record.get(field).and_then(JsonValue::as_str))
.flatten()
.map(str::trim)
.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" {
return None;
}
if record.get("isMeta").and_then(JsonValue::as_bool) == Some(true)
|| record.get("isSidechain").and_then(JsonValue::as_bool) == Some(true)
{
return None;
}
let is_assistant = record_type == "assistant";
let timestamp = record
.get("timestamp")
.and_then(JsonValue::as_str)
.and_then(parse_timestamp);
let content = record.get_mut("message")?.get_mut("content")?.take();
let extracted = match content {
JsonValue::String(text) => {
if text.trim().is_empty() {
return None;
}
ExtractedMessage {
text,
only_tool_result: false,
}
}
content => extract_message_text(&content)?,
};
Some(ConversationMessage {
role: if is_assistant || extracted.only_tool_result {
MessageRole::Assistant
} else {
MessageRole::User
},
text: extracted.text,
timestamp,
})
}
struct ExtractedMessage {
text: String,
only_tool_result: bool,
}
fn extract_message_text(content: &JsonValue) -> Option<ExtractedMessage> {
let blocks = content_blocks(content);
let mut parts = Vec::new();
let mut only_tool_result = !blocks.is_empty();
for block in &blocks {
let block_type = block.get("type").and_then(JsonValue::as_str);
match block_type {
Some("text") => {
if let Some(text) = block.get("text").and_then(JsonValue::as_str)
&& !text.is_empty()
{
parts.push(text.to_string());
only_tool_result = false;
}
}
Some("tool_use") => {
parts.push(tool_call_note(block));
only_tool_result = false;
}
Some("tool_result") => {
parts.push(tool_result_note(block));
}
Some("thinking") => {}
Some(other) => {
parts.push(format!("[external unsupported block: {other}]"));
only_tool_result = false;
}
None => {}
}
}
let text = parts
.into_iter()
.filter(|part| !part.trim().is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if text.is_empty() {
None
} else {
Some(ExtractedMessage {
text,
only_tool_result,
})
}
}
fn content_blocks(content: &JsonValue) -> Vec<JsonValue> {
if let Some(text) = content.as_str() {
return vec![serde_json::json!({
"type": "text",
"text": text,
})];
}
content
.as_array()
.map(|items| {
items
.iter()
.filter(|item| item.is_object())
.cloned()
.collect()
})
.unwrap_or_default()
}
fn tool_call_note(block: &JsonValue) -> String {
let name = block
.get("name")
.and_then(JsonValue::as_str)
.unwrap_or("unknown");
let mut lines = vec![format!("[{EXTERNAL_AGENT_TOOL_CALL_TAG}: {name}]")];
if let Some(input) = block.get("input").and_then(JsonValue::as_object) {
if let Some(description) = input.get("description").and_then(JsonValue::as_str) {
lines.push(format!("description: {description}"));
}
if let Some(command) = input.get("command").and_then(JsonValue::as_str) {
lines.push(format!("command: {command}"));
}
if let Some(file) = input
.get("file_path")
.or_else(|| input.get("file"))
.and_then(JsonValue::as_str)
{
lines.push(format!("file: {file}"));
}
if lines.len() == 1 {
lines.push(format!(
"input: {}",
truncate(&JsonValue::Object(input.clone()).to_string(), NOTE_MAX_LEN)
));
}
} else if let Some(input) = block.get("input") {
lines.push(format!(
"input: {}",
truncate(&input.to_string(), NOTE_MAX_LEN)
));
}
lines.push(format!("[/{EXTERNAL_AGENT_TOOL_CALL_TAG}]"));
lines.join("\n")
}
fn tool_result_note(block: &JsonValue) -> String {
let label = if block.get("is_error").and_then(JsonValue::as_bool) == Some(true) {
format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}: error]")
} else {
format!("[{EXTERNAL_AGENT_TOOL_RESULT_TAG}]")
};
let text = tool_result_text(block.get("content"));
if text.is_empty() {
format!("{label}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]")
} else {
format!(
"{label}\n{}\n[/{EXTERNAL_AGENT_TOOL_RESULT_TAG}]",
truncate(&text, TOOL_RESULT_MAX_LEN)
)
}
}
fn tool_result_text(content: Option<&JsonValue>) -> String {
match content {
Some(JsonValue::String(text)) => text.clone(),
Some(JsonValue::Array(items)) => items
.iter()
.filter_map(|item| item.get("text").and_then(JsonValue::as_str))
.filter(|text| !text.is_empty())
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
fn parse_timestamp(timestamp: &str) -> Option<i64> {
chrono::DateTime::parse_from_rfc3339(timestamp)
.ok()
.map(|value| value.timestamp())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn reads_session_import_in_one_pass() {
let root = TempDir::new().expect("tempdir");
let 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(),
"not json".to_string(),
serde_json::json!({
"type": "ai-title",
"aiTitle": "generated title",
})
.to_string(),
serde_json::json!({
"type": "custom-title",
"customTitle": "custom title",
})
.to_string(),
]
.join("\n");
std::fs::write(&path, &contents).expect("session");
let parsed = read_session_import(&path).expect("parse session");
assert_eq!(parsed.cwd.as_deref(), Some(root.path()));
assert_eq!(parsed.custom_title.as_deref(), Some("custom title"));
assert_eq!(parsed.ai_title.as_deref(), Some("generated title"));
assert_eq!(parsed.messages.len(), 1);
assert_eq!(parsed.messages[0].text, "first request");
assert_eq!(
parsed.content_sha256,
format!("{:x}", Sha256::digest(contents))
);
}
#[test]
fn converts_tool_use_blocks_to_bounded_external_agent_tags() {
let block = serde_json::json!({
"type": "tool_use",
"name": "Bash",
"input": {
"description": "Check repo status",
"command": "git status --short"
}
});
assert_eq!(
tool_call_note(&block),
"[external_agent_tool_call: Bash]\n\
description: Check repo status\n\
command: git status --short\n\
[/external_agent_tool_call]"
);
}
#[test]
fn converts_tool_result_blocks_to_bounded_external_agent_tags() {
let block = serde_json::json!({
"type": "tool_result",
"content": "codex-rs/external-agent-migration/src/sessions/records.rs"
});
assert_eq!(
tool_result_note(&block),
"[external_agent_tool_result]\n\
codex-rs/external-agent-migration/src/sessions/records.rs\n\
[/external_agent_tool_result]"
);
}
#[test]
fn converts_error_tool_result_blocks_to_bounded_external_agent_tags() {
let block = serde_json::json!({
"type": "tool_result",
"is_error": true,
"content": "command failed"
});
assert_eq!(
tool_result_note(&block),
"[external_agent_tool_result: error]\n\
command failed\n\
[/external_agent_tool_result]"
);
}
}
@@ -0,0 +1,94 @@
use super::SESSION_TITLE_MAX_LEN;
use super::truncate;
pub(super) const IMPORTED_SESSION_FALLBACK_TITLE: &str = "Imported session";
const RECOGNIZED_CONTROL_WRAPPERS: [(&str, &str); 10] = [
("<command-message>", "</command-message>"),
("<command-name>", "</command-name>"),
("<command-args>", "</command-args>"),
("<local-command-caveat>", "</local-command-caveat>"),
("<local-command-stderr>", "</local-command-stderr>"),
("<local-command-stdout>", "</local-command-stdout>"),
("<task-notification>", "</task-notification>"),
("<system-reminder>", "</system-reminder>"),
("<ide_opened_file>", "</ide_opened_file>"),
("<ide_selection>", "</ide_selection>"),
];
pub(super) struct SessionTitleCandidates {
pub custom_title: Option<String>,
pub ai_title: Option<String>,
pub fallback_title: Option<String>,
}
impl SessionTitleCandidates {
pub fn select(self) -> Option<String> {
self.custom_title.or(self.ai_title).or(self.fallback_title)
}
}
pub(super) fn fallback_title_from_user_message(message: &str) -> Option<String> {
let message = strip_leading_control_wrappers(message);
message
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(|line| truncate(line, SESSION_TITLE_MAX_LEN))
}
fn strip_leading_control_wrappers(message: &str) -> &str {
let mut remainder = message.trim_start();
while let Some(wrapper_end) = leading_control_wrapper_end(remainder) {
remainder = remainder[wrapper_end..].trim_start();
}
remainder
}
fn leading_control_wrapper_end(text: &str) -> Option<usize> {
let (outer_tag, opening_len) = recognized_opening_tag(text)?;
let mut open_tags = vec![outer_tag];
let mut cursor = opening_len;
while !open_tags.is_empty() {
cursor += text.get(cursor..)?.find('<')?;
let candidate = text.get(cursor..)?;
if let Some((tag, token_len)) = recognized_opening_tag(candidate) {
open_tags.push(tag);
cursor += token_len;
continue;
}
if let Some((tag, token_len)) = recognized_closing_tag(candidate) {
if open_tags.last().copied() != Some(tag) {
return None;
}
open_tags.pop();
cursor += token_len;
continue;
}
cursor += 1;
}
Some(cursor)
}
fn recognized_opening_tag(text: &str) -> Option<(usize, usize)> {
RECOGNIZED_CONTROL_WRAPPERS
.iter()
.enumerate()
.find_map(|(index, (opening, _closing))| {
text.starts_with(opening).then_some((index, opening.len()))
})
}
fn recognized_closing_tag(text: &str) -> Option<(usize, usize)> {
RECOGNIZED_CONTROL_WRAPPERS
.iter()
.enumerate()
.find_map(|(index, (_opening, closing))| {
text.starts_with(closing).then_some((index, closing.len()))
})
}
#[cfg(test)]
#[path = "title_tests.rs"]
mod tests;
@@ -0,0 +1,123 @@
use super::*;
#[test]
fn preserves_valid_custom_title_unchanged() {
let custom_title = "<system-reminder>Keep this custom title</system-reminder>";
assert_eq!(
SessionTitleCandidates {
custom_title: Some(custom_title.to_string()),
ai_title: Some("AI title".to_string()),
fallback_title: Some("fallback title".to_string()),
}
.select(),
Some(custom_title.to_string())
);
}
#[test]
fn preserves_valid_ai_title_unchanged_without_custom_title() {
let ai_title = "<command-message>Keep this AI title</command-message>";
assert_eq!(
SessionTitleCandidates {
custom_title: None,
ai_title: Some(ai_title.to_string()),
fallback_title: Some("fallback title".to_string()),
}
.select(),
Some(ai_title.to_string())
);
}
#[test]
fn strips_nested_repeated_and_multiline_leading_control_wrappers() {
let message = "\
<system-reminder>\n\
outer context\n\
<command-message>\n\
nested context\n\
</command-message>\n\
</system-reminder>\n\
<ide_opened_file>\n\
src/auth.rs\n\
</ide_opened_file>\n\
\n\
Fix auth flow\n\
Additional details";
assert_eq!(
fallback_title_from_user_message(message),
Some("Fix auth flow".to_string())
);
}
#[test]
fn strips_observed_external_agent_control_wrapper_families() {
let cases = [
"<task-notification>\n\
<task-id>abc123</task-id>\n\
<status>completed</status>\n\
</task-notification>\n\
Fix auth flow",
"<command-message>review</command-message>\n\
<command-name>/review</command-name>\n\
<command-args>src/auth.rs</command-args>\n\
Fix auth flow",
"<local-command-caveat>Command output follows</local-command-caveat>\n\
<local-command-stdout>tests passed</local-command-stdout>\n\
Fix auth flow",
"<local-command-stderr>tests failed</local-command-stderr>\n\
Fix auth flow",
"<ide_selection>src/auth.rs:1-5</ide_selection>\n\
Fix auth flow",
];
for message in cases {
assert_eq!(
fallback_title_from_user_message(message),
Some("Fix auth flow".to_string())
);
}
}
#[test]
fn returns_no_candidate_for_empty_or_control_only_messages() {
assert_eq!(fallback_title_from_user_message(""), None);
assert_eq!(
fallback_title_from_user_message(
"<command-message>review</command-message>\n\
<system-reminder>context</system-reminder>"
),
None
);
}
#[test]
fn uses_first_meaningful_line_from_ordinary_messages() {
assert_eq!(
fallback_title_from_user_message("\n \n Fix auth flow \nAdditional details"),
Some("Fix auth flow".to_string())
);
}
#[test]
fn preserves_unknown_and_user_authored_angle_bracket_text() {
assert_eq!(
fallback_title_from_user_message("<user-note>Keep this text</user-note> Fix auth flow"),
Some("<user-note>Keep this text</user-note> Fix auth flow".to_string())
);
assert_eq!(
fallback_title_from_user_message("Explain <system-reminder> tags"),
Some("Explain <system-reminder> tags".to_string())
);
}
#[test]
fn bounds_fallback_titles_to_120_characters() {
let message = "x".repeat(121);
let title = fallback_title_from_user_message(&message).expect("title");
assert_eq!(title.chars().count(), SESSION_TITLE_MAX_LEN);
assert_eq!(title, format!("{}...", "x".repeat(117)));
}