mirror of
https://github.com/openai/codex.git
synced 2026-05-16 17:23:57 +00:00
## 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`
415 lines
14 KiB
Rust
415 lines
14 KiB
Rust
use crate::ConversationMessage;
|
|
use crate::ImportedExternalAgentSession;
|
|
use crate::MessageRole;
|
|
use crate::records::conversation_messages;
|
|
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;
|
|
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>";
|
|
|
|
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 {
|
|
return Ok(None);
|
|
};
|
|
let messages = conversation_messages(&records);
|
|
let rollout_items = rollout_items_from_messages(&messages);
|
|
if rollout_items.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
let title = source_title_from_records(&records).or_else(|| {
|
|
messages
|
|
.iter()
|
|
.find(|message| message.role == MessageRole::User)
|
|
.map(|message| summarize_for_label(&message.text))
|
|
});
|
|
Ok(Some(ImportedExternalAgentSession {
|
|
cwd,
|
|
title,
|
|
rollout_items,
|
|
}))
|
|
}
|
|
|
|
fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec<RolloutItem> {
|
|
let mut items = Vec::new();
|
|
let mut response_items = Vec::new();
|
|
let mut current_turn: Option<(String, Option<String>)> = None;
|
|
let mut user_turn_count = 0usize;
|
|
|
|
for message in messages {
|
|
match message.role {
|
|
MessageRole::User => {
|
|
if let Some((turn_id, last_agent_message)) = current_turn.take() {
|
|
items.push(turn_complete_item(
|
|
turn_id,
|
|
last_agent_message,
|
|
/*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(),
|
|
started_at: message.timestamp,
|
|
model_context_window: None,
|
|
collaboration_mode_kind: Default::default(),
|
|
},
|
|
)));
|
|
let response_item = response_item(message);
|
|
response_items.push(response_item.clone());
|
|
items.push(RolloutItem::ResponseItem(response_item));
|
|
items.push(RolloutItem::EventMsg(EventMsg::UserMessage(
|
|
UserMessageEvent {
|
|
message: message.text.clone(),
|
|
images: None,
|
|
local_images: Vec::new(),
|
|
text_elements: Vec::new(),
|
|
},
|
|
)));
|
|
current_turn = Some((turn_id, None));
|
|
}
|
|
MessageRole::Assistant => {
|
|
let Some((_, last_agent_message)) = current_turn.as_mut() else {
|
|
continue;
|
|
};
|
|
let response_item = response_item(message);
|
|
response_items.push(response_item.clone());
|
|
items.push(RolloutItem::ResponseItem(response_item));
|
|
items.push(RolloutItem::EventMsg(EventMsg::AgentMessage(
|
|
AgentMessageEvent {
|
|
message: message.text.clone(),
|
|
phase: None,
|
|
memory_citation: None,
|
|
},
|
|
)));
|
|
*last_agent_message = Some(message.text.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
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(
|
|
turn_id,
|
|
last_agent_message,
|
|
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.clone(),
|
|
},
|
|
MessageRole::User => ContentItem::InputText {
|
|
text: message.text.clone(),
|
|
},
|
|
};
|
|
ResponseItem::Message {
|
|
id: None,
|
|
role: match message.role {
|
|
MessageRole::Assistant => "assistant".to_string(),
|
|
MessageRole::User => "user".to_string(),
|
|
},
|
|
content: vec![content],
|
|
phase: None,
|
|
}
|
|
}
|
|
|
|
fn token_count_item(response_items: &[ResponseItem]) -> RolloutItem {
|
|
let last_model_generated = response_items.iter().rposition(
|
|
|item| matches!(item, ResponseItem::Message { role, .. } if role == "assistant"),
|
|
);
|
|
let last_model_visible_tokens = last_model_generated
|
|
.map(|index| estimate_response_items_token_count(&response_items[..=index]))
|
|
.unwrap_or_default();
|
|
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 estimate_response_items_token_count(response_items: &[ResponseItem]) -> i64 {
|
|
response_items
|
|
.iter()
|
|
.map(|item| {
|
|
serde_json::to_string(item)
|
|
.map(|serialized| i64::try_from(serialized.len()).unwrap_or(i64::MAX))
|
|
.map(approx_tokens_from_byte_count_i64)
|
|
.unwrap_or_default()
|
|
})
|
|
.fold(0i64, i64::saturating_add)
|
|
}
|
|
|
|
fn turn_complete_item(
|
|
turn_id: String,
|
|
last_agent_message: Option<String>,
|
|
completed_at: Option<i64>,
|
|
) -> RolloutItem {
|
|
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
|
turn_id,
|
|
last_agent_message,
|
|
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_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]
|
|
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 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")
|
|
}
|
|
}
|