Skip to main content

ytil_agents/agent/session_loader/
codex.rs

1use 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
13/// Load Codex sessions from the local Codex session store.
14///
15/// # Errors
16/// Returns an error when the Codex sessions directory cannot be read or a
17/// session file cannot be parsed.
18pub 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
29/// Load only requested Codex sessions from the local Codex session store.
30///
31/// # Errors
32/// Returns an error when a matching Codex session file cannot be read or parsed.
33pub 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}