ytil_agents/agent/session_loader/
codex.rs1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use rootcause::prelude::ResultExt;
6
7use crate::agent::Agent;
8use crate::agent::session::Session;
9use crate::agent::session::SessionKey;
10
11pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
17 let root = ytil_sys::dir::build_home_path(Agent::Codex.sessions_root_path())?;
18 let session_paths = crate::agent::session_loader::find_session_paths(
19 &root,
20 |entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"),
21 |_| false,
22 )?;
23
24 load_sessions_from_paths(session_paths, |_| true)
25}
26
27pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
32 let root = ytil_sys::dir::build_home_path(Agent::Codex.sessions_root_path())?;
33 load_sessions_from_root_by_key(&root, keys)
34}
35
36fn load_sessions_from_root_by_key(root: &Path, keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
37 let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Codex);
38 if requested_ids.is_empty() {
39 return Ok(Vec::new());
40 }
41 let session_paths = crate::agent::session_loader::find_session_paths(
42 root,
43 |entry| codex_session_path_matches_requested_id(&entry.path(), &requested_ids),
44 |_| false,
45 )?;
46
47 load_sessions_from_paths(session_paths, |session| requested_ids.contains(session.id.as_str()))
48}
49
50fn load_sessions_from_paths(
51 session_paths: Vec<PathBuf>,
52 keep_session: impl Fn(&Session) -> bool,
53) -> rootcause::Result<Vec<Session>> {
54 let mut sessions = Vec::new();
55 for session_path in session_paths {
56 let content = std::fs::read_to_string(&session_path)
57 .context("failed to read Codex session file")
58 .attach_with(|| format!("path={}", session_path.display()))?;
59 let session_name = session_path
60 .file_stem()
61 .and_then(|name| name.to_str())
62 .unwrap_or_default();
63 let codex_session = crate::agent::session_parser::codex::parse(&content, session_name)
64 .attach_with(|| format!("path={}", session_path.display()))?;
65 if codex_session.is_subagent {
66 continue;
67 }
68 let session = codex_session.into_session(session_path);
69 if session.workspace.is_dir() && keep_session(&session) {
70 sessions.push(session);
71 }
72 }
73
74 Ok(sessions)
75}
76
77fn codex_session_path_matches_requested_id(path: &Path, requested_ids: &HashSet<&str>) -> bool {
78 path.extension().is_some_and(|ext| ext == "jsonl")
79 && path.file_stem().and_then(|name| name.to_str()).is_some_and(|stem| {
80 requested_ids
81 .iter()
82 .any(|id| stem == *id || stem.strip_suffix(id).is_some_and(|prefix| prefix.ends_with('-')))
83 })
84}
85
86#[cfg(test)]
87mod tests {
88 use tempfile::tempdir;
89
90 use super::*;
91
92 #[test]
93 fn test_load_sessions_from_root_by_key_only_parses_matching_codex_files() {
94 let dir = tempdir().expect("tempdir should be created");
95 let root = dir.path().join("sessions");
96 let workspace = dir.path().join("workspace");
97 std::fs::create_dir_all(&root).expect("session root should be created");
98 std::fs::create_dir_all(&workspace).expect("workspace should be created");
99 std::fs::write(
100 root.join("rollout-2026-01-01-target.jsonl"),
101 codex_content("target", &workspace),
102 )
103 .expect("target session should be written");
104 std::fs::write(root.join("rollout-2026-01-01-other.jsonl"), "not json\n")
105 .expect("nonmatching session should be written");
106
107 assert2::assert!(
108 let Ok(sessions) = load_sessions_from_root_by_key(&root, &[SessionKey::new(Agent::Codex, "target")])
109 );
110
111 pretty_assertions::assert_eq!(sessions.len(), 1);
112 pretty_assertions::assert_eq!(sessions[0].id, "target");
113 }
114
115 fn codex_content(id: &str, workspace: &Path) -> String {
116 format!(
117 "{{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"{}\"}}}}\n",
118 workspace.display()
119 )
120 }
121}