mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
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:
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, ×tamp)],
|
||||
);
|
||||
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, ×tamp)
|
||||
}
|
||||
|
||||
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)));
|
||||
}
|
||||
Reference in new issue
Block a user