Consume ai-title from external sessions and add end marker (#20261)

## Summary
- Support Claude Code `ai-title` / `aiTitle` records when detecting and
importing external agent sessions.
- Preserve existing `custom-title` / `customTitle` precedence; only fall
back to `aiTitle` when no custom title is present.
- Add coverage for both detection and import title selection, including
the custom-title-over-ai-title case.

## Testing
- `cargo test -p codex-external-agent-sessions`
- `just fix -p codex-external-agent-sessions`
This commit is contained in:
alexsong-oai
2026-04-29 17:00:13 -07:00
committed by GitHub
Unverified
parent 8774229a89
commit 7bcd4626c4
4 changed files with 222 additions and 13 deletions
@@ -313,7 +313,17 @@ async fn external_agent_config_import_creates_session_rollouts() -> Result<()> {
.await??;
let response: ThreadReadResponse = to_response(response)?;
assert_eq!(response.thread.turns.len(), 1);
assert_eq!(response.thread.turns[0].items.len(), 2);
let items = &response.thread.turns[0].items;
assert_eq!(items.len(), 3);
assert_eq!(
items.last(),
Some(&ThreadItem::AgentMessage {
id: "item-3".into(),
text: "<EXTERNAL SESSION IMPORTED>".into(),
phase: None,
memory_citation: None,
})
);
let request_id = mcp
.send_thread_resume_request(ThreadResumeParams {
@@ -151,6 +151,61 @@ mod tests {
);
}
#[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_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_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 ignores_old_sessions() {
let root = TempDir::new().expect("tempdir");
@@ -266,6 +321,13 @@ mod tests {
})
}
fn ai_title_record(title: &str) -> JsonValue {
serde_json::json!({
"type": "ai-title",
"aiTitle": title,
})
}
fn jsonl(records: &[JsonValue]) -> String {
records
.iter()
+120 -3
View File
@@ -2,9 +2,9 @@ use crate::ConversationMessage;
use crate::ImportedExternalAgentSession;
use crate::MessageRole;
use crate::records::conversation_messages;
use crate::records::custom_title_from_records;
use crate::records::project_root_from_records;
use crate::records::read_records;
use crate::records::source_title_from_records;
use crate::summarize_for_label;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -21,6 +21,8 @@ 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>";
pub fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExternalAgentSession>> {
let records = read_records(path)?;
let Some(cwd) = project_root_from_records(&records) else {
@@ -31,7 +33,7 @@ pub fn load_session_for_import(path: &Path) -> io::Result<Option<ImportedExterna
if rollout_items.is_empty() {
return Ok(None);
}
let title = custom_title_from_records(&records).or_else(|| {
let title = source_title_from_records(&records).or_else(|| {
messages
.iter()
.find(|message| message.role == MessageRole::User)
@@ -103,6 +105,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec<RolloutI
}
if let Some((turn_id, last_agent_message)) = current_turn {
items.push(external_session_imported_marker_item());
items.push(token_count_item(&response_items));
let completed_at = messages.last().and_then(|message| message.timestamp);
items.push(turn_complete_item(
@@ -115,6 +118,14 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec<RolloutI
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 {
@@ -185,6 +196,7 @@ fn turn_complete_item(
#[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;
@@ -213,7 +225,60 @@ mod tests {
assert_eq!(turns.len(), 2);
assert_eq!(turns[0].items.len(), 2);
assert_eq!(turns[1].items.len(), 1);
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_replacing_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()),
Some("first answer")
);
}
#[test]
@@ -238,6 +303,51 @@ mod tests {
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 emits_token_usage_for_imported_history() {
let root = TempDir::new().expect("tempdir");
@@ -287,6 +397,13 @@ mod tests {
})
}
fn ai_title_record(title: &str) -> JsonValue {
serde_json::json!({
"type": "ai-title",
"aiTitle": title,
})
}
fn jsonl(records: &[JsonValue]) -> String {
records
.iter()
@@ -24,6 +24,7 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
let reader = BufReader::new(file);
let mut cwd = None;
let mut custom_title = None;
let mut ai_title = None;
let mut title = None;
let mut latest_timestamp = None;
let mut saw_message = false;
@@ -46,6 +47,9 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
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_record(&record) else {
continue;
};
@@ -73,17 +77,14 @@ pub fn summarize_session(path: &Path) -> io::Result<Option<SessionSummary>> {
migration: ExternalAgentSessionMigration {
path: path.to_path_buf(),
cwd,
title: custom_title.or(title),
title: custom_title.or(ai_title).or(title),
},
}))
}
pub(super) fn custom_title_from_records(records: &[JsonValue]) -> Option<String> {
records
.iter()
.filter_map(custom_title_from_record)
.next_back()
.map(ToOwned::to_owned)
pub(super) fn source_title_from_records(records: &[JsonValue]) -> Option<String> {
latest_title_from_records(records, custom_title_from_record)
.or_else(|| latest_title_from_records(records, ai_title_from_record))
}
pub(super) fn read_records(path: &Path) -> io::Result<Vec<JsonValue>> {
@@ -120,9 +121,28 @@ pub(super) fn conversation_messages(records: &[JsonValue]) -> Vec<ConversationMe
.collect()
}
fn latest_title_from_records<'a>(
records: &'a [JsonValue],
title_from_record: impl Fn(&'a JsonValue) -> Option<&'a str>,
) -> Option<String> {
records
.iter()
.filter_map(title_from_record)
.next_back()
.map(ToOwned::to_owned)
}
fn custom_title_from_record(record: &JsonValue) -> Option<&str> {
(record.get("type").and_then(JsonValue::as_str) == Some("custom-title"))
.then(|| record.get("customTitle").and_then(JsonValue::as_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())