| use super::*; |
| #[cfg(test)] |
| use chrono::DateTime; |
| #[cfg(test)] |
| use chrono::Utc; |
| use codex_protocol::config_types::MultiAgentMode; |
|
|
| #[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 = strip_user_message_prefix(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().map(String::from), |
| } |
| } |
|
|
| 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_settings_from_config_snapshot( |
| config_snapshot: &ThreadConfigSnapshot, |
| ) -> ThreadSettings { |
| ThreadSettings { |
| disabled_plugin_ids: config_snapshot.disabled_plugin_ids.clone(), |
| cwd: config_snapshot.cwd().clone(), |
| approval_policy: config_snapshot.approval_policy.into(), |
| approvals_reviewer: config_snapshot.approvals_reviewer.into(), |
| sandbox_policy: config_snapshot.sandbox_policy().into(), |
| 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(), |
| multi_agent_mode: MultiAgentMode::ExplicitRequestOnly, |
| personality: config_snapshot.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 { |
| originator: None, |
| environments: None, |
| id: thread_id.clone(), |
| extra: None, |
| session_id: thread_id, |
| forked_from_id: None, |
| parent_thread_id: None, |
| preview, |
| ephemeral: false, |
| section: None, |
| section_entered_at: None, |
| project_id: None, |
| history_mode: ThreadHistoryMode::Legacy, |
| model_provider, |
| created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), |
| model: None, |
| reasoning_effort: None, |
| updated_at: updated_at.map(|dt| dt.timestamp()).unwrap_or(0), |
| recency_at: updated_at.map(|dt| dt.timestamp()), |
| 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(), |
| can_accept_direct_input: None, |
| thread_source: None, |
| git_info, |
| name: None, |
| daybreak_enabled: None, |
| turns: Vec::new(), |
| } |
| } |
|
|
| #[cfg(test)] |
| #[path = "thread_summary_tests.rs"] |
| mod thread_summary_tests; |
|
|