mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
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:
committed by
GitHub
Unverified
parent
8774229a89
commit
7bcd4626c4
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user