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 = ""; pub fn load_session_for_import(path: &Path) -> io::Result> { 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 { let mut items = Vec::new(); let mut response_items = Vec::new(); let mut current_turn: Option<(String, Option)> = 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(), ..Default::default() }, ))); 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, completed_at: Option, ) -> 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::>() .join("\n") } }