use super::*;#[cfg(test)]use chrono::DateTime;#[cfg(test)]use chrono::Utc;#[cfg(test)]pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str,) -> std::io::Result<ConversationSummary> { let head = read_head_for_summary(path).await?; let Some(first) = head.first() else { return Err(IoError::other(format!( "rollout at {} is empty", path.display() ))); }; let session_meta_line = serde_json::from_value::<SessionMetaLine>(first.clone()).map_err(|_| { IoError::other(format!( "rollout at {} does not start with session metadata", path.display() )) })?; let SessionMetaLine { meta: session_meta, git, } = session_meta_line; let mut session_meta = session_meta; session_meta.source = with_thread_spawn_agent_metadata( session_meta.source.clone(), session_meta.agent_nickname.clone(), session_meta.agent_role.clone(), ); let created_at = if session_meta.timestamp.is_empty() { None } else { Some(session_meta.timestamp.as_str()) }; let updated_at = read_updated_at(path, created_at).await; if let Some(summary) = extract_conversation_summary( path.to_path_buf(), &head, &session_meta, git.as_ref(), fallback_provider, updated_at.clone(), ) { return Ok(summary); } let timestamp = if session_meta.timestamp.is_empty() { None } else { Some(session_meta.timestamp.clone()) }; let model_provider = session_meta .model_provider .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.as_ref().map(map_git_info); let updated_at = updated_at.or_else(|| timestamp.clone()); Ok(ConversationSummary { conversation_id: session_meta.id, timestamp, updated_at, path: path.to_path_buf(), preview: String::new(), model_provider, cwd: session_meta.cwd, cli_version: session_meta.cli_version, source: session_meta.source, git_info, })}#[cfg(test)]fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], session_meta: &SessionMeta, git: Option<&CoreGitInfo>, fallback_provider: &str, updated_at: Option<String>,) -> Option<ConversationSummary> { let preview = head .iter() .filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok()) .find_map(|item| match codex_core::parse_turn_item(&item) { Some(TurnItem::UserMessage(user)) => Some(user.message()), _ => None, })?; let preview = match preview.find(USER_MESSAGE_BEGIN) { Some(idx) => preview[idx + USER_MESSAGE_BEGIN.len()..].trim(), None => preview.as_str(), }; let timestamp = if session_meta.timestamp.is_empty() { None } else { Some(session_meta.timestamp.clone()) }; let conversation_id = session_meta.id; let model_provider = session_meta .model_provider .clone() .unwrap_or_else(|| fallback_provider.to_string()); let git_info = git.map(map_git_info); let updated_at = updated_at.or_else(|| timestamp.clone()); Some(ConversationSummary { conversation_id, timestamp, updated_at, path, preview: preview.to_string(), model_provider, cwd: session_meta.cwd.clone(), cli_version: session_meta.cli_version.clone(), source: session_meta.source.clone(), git_info, })}#[cfg(test)]fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { sha: git_info.commit_hash.as_ref().map(|sha| sha.0.clone()), branch: git_info.branch.clone(), origin_url: git_info.repository_url.clone(), }}pub(super) fn with_thread_spawn_agent_metadata( source: codex_protocol::protocol::SessionSource, agent_nickname: Option<String>, agent_role: Option<String>,) -> codex_protocol::protocol::SessionSource { if agent_nickname.is_none() && agent_role.is_none() { return source; } match source { codex_protocol::protocol::SessionSource::SubAgent( codex_protocol::protocol::SubAgentSource::ThreadSpawn { parent_thread_id, depth, agent_path, agent_nickname: existing_agent_nickname, agent_role: existing_agent_role, }, ) => codex_protocol::protocol::SessionSource::SubAgent( codex_protocol::protocol::SubAgentSource::ThreadSpawn { parent_thread_id, depth, agent_path, agent_nickname: agent_nickname.or(existing_agent_nickname), agent_role: agent_role.or(existing_agent_role), }, ), _ => source, }}pub(crate) fn thread_response_active_permission_profile( active_permission_profile: Option<codex_protocol::models::ActivePermissionProfile>,) -> Option<codex_app_server_protocol::ActivePermissionProfile> { active_permission_profile.map(Into::into)}pub(crate) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, cwd: &Path,) -> codex_app_server_protocol::SandboxPolicy { let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( permission_profile, cwd, ); sandbox_policy.into()}pub(crate) fn thread_settings_from_config_snapshot( config_snapshot: &ThreadConfigSnapshot,) -> ThreadSettings { ThreadSettings { cwd: config_snapshot.cwd().clone(), approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox_policy: thread_response_sandbox_policy( &config_snapshot.permission_profile, config_snapshot.cwd().as_path(), ), active_permission_profile: thread_response_active_permission_profile( config_snapshot.active_permission_profile.clone(), ), model: config_snapshot.model.clone(), model_provider: config_snapshot.model_provider_id.clone(), service_tier: config_snapshot.service_tier.clone(), effort: config_snapshot.reasoning_effort.clone(), summary: config_snapshot.reasoning_summary, collaboration_mode: config_snapshot.collaboration_mode.clone(), personality: config_snapshot.personality, }}pub(crate) fn thread_settings_from_core_snapshot( snapshot: codex_protocol::protocol::ThreadSettingsSnapshot,) -> ThreadSettings { let codex_protocol::protocol::ThreadSettingsSnapshot { model, model_provider_id, service_tier, approval_policy, approvals_reviewer, permission_profile, active_permission_profile, cwd, reasoning_effort, reasoning_summary, personality, collaboration_mode, } = snapshot; let sandbox_policy = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); ThreadSettings { sandbox_policy, cwd, approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), active_permission_profile: thread_response_active_permission_profile( active_permission_profile, ), model, model_provider: model_provider_id, service_tier, effort: reasoning_effort, summary: reasoning_summary, collaboration_mode, personality, }}#[cfg(test)]fn parse_datetime(timestamp: Option<&str>) -> Option<DateTime<Utc>> { timestamp.and_then(|ts| { chrono::DateTime::parse_from_rfc3339(ts) .ok() .map(|dt| dt.with_timezone(&chrono::Utc)) })}#[cfg(test)]async fn read_updated_at(path: &Path, created_at: Option<&str>) -> Option<String> { let updated_at = tokio::fs::metadata(path) .await .ok() .and_then(|meta| meta.modified().ok()) .map(|modified| { let updated_at: DateTime<Utc> = modified.into(); updated_at.to_rfc3339_opts(SecondsFormat::Millis, true) }); updated_at.or_else(|| created_at.map(str::to_string))}pub(super) fn thread_started_notification(mut thread: Thread) -> ThreadStartedNotification { thread.turns.clear(); ThreadStartedNotification { thread }}#[cfg(test)]pub(crate) fn summary_to_thread( summary: ConversationSummary, fallback_cwd: &AbsolutePathBuf,) -> Thread { let ConversationSummary { conversation_id, path, preview, timestamp, updated_at, model_provider, cwd, cli_version, source, git_info, } = summary; let created_at = parse_datetime(timestamp.as_deref()); let updated_at = parse_datetime(updated_at.as_deref()).or(created_at); let git_info = git_info.map(|info| ApiGitInfo { sha: info.sha, branch: info.branch, origin_url: info.origin_url, }); let cwd = AbsolutePathBuf::relative_to_current_dir(path_utils::normalize_for_native_workdir(cwd)) .unwrap_or_else(|err| { warn!( conversation_id = %conversation_id, path = %path.display(), "failed to normalize thread cwd while summarizing thread: {err}" ); fallback_cwd.clone() }); let thread_id = conversation_id.to_string(); Thread { id: thread_id.clone(), session_id: thread_id, forked_from_id: None, parent_thread_id: None, preview, ephemeral: false, model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), status: ThreadStatus::NotLoaded, path: (!path.as_os_str().is_empty()).then_some(path), cwd, cli_version, agent_nickname: source.get_nickname(), agent_role: source.get_agent_role(), source: source.into(), thread_source: None, git_info, name: None, turns: Vec::new(), }}#[cfg(test)]#[path = "thread_summary_tests.rs"]mod thread_summary_tests;