ytil_agents/agent/session_loader/
codex.rs1use std::collections::HashSet;
2use std::fs::File;
3use std::io::BufReader;
4use std::path::Path;
5use std::path::PathBuf;
6
7use rootcause::prelude::ResultExt;
8
9use crate::agent::Agent;
10use crate::agent::session::Session;
11use crate::agent::session::SessionKey;
12
13pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
19 let root = ytil_sys::dir::build_home_path(Agent::Codex.sessions_root_path())?;
20 let session_paths = crate::agent::session_loader::find_session_paths(
21 &root,
22 |entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"),
23 |_| false,
24 )?;
25
26 load_sessions_from_paths(session_paths, |_| true)
27}
28
29pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
34 let root = ytil_sys::dir::build_home_path(Agent::Codex.sessions_root_path())?;
35 load_sessions_from_root_by_key(&root, keys)
36}
37
38fn load_sessions_from_root_by_key(root: &Path, keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
39 let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Codex);
40 if requested_ids.is_empty() {
41 return Ok(Vec::new());
42 }
43 let session_paths = crate::agent::session_loader::find_session_paths(
44 root,
45 |entry| codex_session_path_matches_requested_id(&entry.path(), &requested_ids),
46 |_| false,
47 )?;
48
49 load_sessions_from_paths(session_paths, |session| requested_ids.contains(session.id.as_str()))
50}
51
52fn load_sessions_from_paths(
53 session_paths: Vec<PathBuf>,
54 keep_session: impl Fn(&Session) -> bool,
55) -> rootcause::Result<Vec<Session>> {
56 let mut sessions = Vec::new();
57 for session_path in session_paths {
58 let session_name = session_path
59 .file_stem()
60 .and_then(|name| name.to_str())
61 .unwrap_or_default();
62 let file = File::open(&session_path)
63 .context("failed to open Codex session file")
64 .attach_with(|| format!("path={}", session_path.display()))?;
65 let codex_session = crate::agent::session_parser::codex::parse_preview(BufReader::new(file), session_name)
66 .attach_with(|| format!("path={}", session_path.display()))?;
67 if codex_session.is_subagent {
68 continue;
69 }
70 let mut session = codex_session.into_session(session_path.clone());
71 let last_prompt_file = File::open(&session_path)
72 .context("failed to open Codex session for reverse prompt scan")
73 .attach_with(|| format!("path={}", session_path.display()))?;
74 session.last_user_prompt = crate::agent::session_parser::codex::find_last_user_prompt(last_prompt_file)
75 .attach_with(|| format!("path={}", session_path.display()))?;
76 session.updated_at =
77 crate::agent::session_loader::file_updated_at(&session_path)?.unwrap_or(session.created_at);
78 if keep_session(&session) {
79 sessions.push(session);
80 }
81 }
82
83 Ok(sessions)
84}
85
86fn codex_session_path_matches_requested_id(path: &Path, requested_ids: &HashSet<&str>) -> bool {
87 path.extension().is_some_and(|ext| ext == "jsonl")
88 && path.file_stem().and_then(|name| name.to_str()).is_some_and(|stem| {
89 requested_ids
90 .iter()
91 .any(|id| stem == *id || stem.strip_suffix(id).is_some_and(|prefix| prefix.ends_with('-')))
92 })
93}
94
95#[cfg(test)]
96mod tests {
97 use tempfile::tempdir;
98 use test_that::prelude::*;
99
100 use super::*;
101
102 #[test]
103 fn test_load_sessions_from_root_by_key_only_when_invoked_matching_codex_files() {
104 let dir = tempdir().expect("tempdir should be created");
105 let root = dir.path().join("sessions");
106 let workspace = dir.path().join("workspace");
107 std::fs::create_dir_all(&root).expect("session root should be created");
108 std::fs::create_dir_all(&workspace).expect("workspace should be created");
109 std::fs::write(
110 root.join("rollout-2026-01-01-target.jsonl"),
111 codex_content("target", &workspace),
112 )
113 .expect("target session should be written");
114 std::fs::write(root.join("rollout-2026-01-01-other.jsonl"), "not json\n")
115 .expect("nonmatching session should be written");
116
117 let sessions_result = load_sessions_from_root_by_key(&root, &[SessionKey::new(Agent::Codex, "target")]);
118 assert_that!(sessions_result.as_ref().map(|_| ()), ok(eq(())));
119 let sessions = sessions_result.expect("target Codex session should load");
120
121 assert_that!(sessions.len(), eq(1));
122 assert_that!(sessions[0].id, eq("target"));
123 }
124
125 #[test]
126 fn test_load_sessions_from_paths_when_workspace_is_missing_keeps_session_for_deletion() {
127 let dir = tempdir().expect("tempdir should be created");
128 let session_path = dir.path().join("rollout-2026-01-01-target.jsonl");
129 let missing_workspace = dir.path().join("missing-workspace");
130 std::fs::write(&session_path, codex_content("target", &missing_workspace))
131 .expect("session fixture should be written");
132
133 let sessions = load_sessions_from_paths(vec![session_path], |_| true).expect("session should load");
134
135 assert_that!(sessions.len(), eq(1));
136 assert_that!(sessions[0].workspace, eq(missing_workspace));
137 }
138
139 fn codex_content(id: &str, workspace: &Path) -> String {
140 format!(
141 "{{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{{\"id\":\"{id}\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"{}\"}}}}\n",
142 workspace.display()
143 )
144 }
145}