ytil_agents/agent/session_loader/
claude.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::Claude.sessions_root_path())?;
18 let session_paths =
19 crate::agent::session_loader::find_session_paths(&root, |entry| claude_session_path(&entry.path()), |_| false)?;
20
21 load_sessions_from_paths(session_paths, |_| true)
22}
23
24pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
29 let root = ytil_sys::dir::build_home_path(Agent::Claude.sessions_root_path())?;
30 load_sessions_from_root_by_key(&root, keys)
31}
32
33fn load_sessions_from_root_by_key(root: &Path, keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
34 let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Claude);
35 if requested_ids.is_empty() {
36 return Ok(Vec::new());
37 }
38 let session_paths = crate::agent::session_loader::find_session_paths(
39 root,
40 |entry| claude_session_path_matches_requested_id(&entry.path(), &requested_ids),
41 |_| false,
42 )?;
43
44 load_sessions_from_paths(session_paths, |session| requested_ids.contains(session.id.as_str()))
45}
46
47fn load_sessions_from_paths(
48 session_paths: Vec<PathBuf>,
49 keep_session: impl Fn(&Session) -> bool,
50) -> rootcause::Result<Vec<Session>> {
51 let mut sessions = Vec::new();
52 for session_path in session_paths {
53 let content = std::fs::read_to_string(&session_path)
54 .context("failed to read Claude session file")
55 .attach_with(|| format!("path={}", session_path.display()))?;
56 let claude_session = crate::agent::session_parser::claude::parse(&content)
57 .attach_with(|| format!("path={}", session_path.display()))?;
58 let session = claude_session.into_session(session_path);
59 if session.workspace.is_dir() && keep_session(&session) {
60 sessions.push(session);
61 }
62 }
63
64 Ok(sessions)
65}
66
67fn claude_session_path(path: &Path) -> bool {
68 path.extension().is_some_and(|ext| ext == "jsonl")
69 && path
70 .file_name()
71 .and_then(|name| name.to_str())
72 .is_some_and(|name| !matches!(name, "sessions-index.json" | "session.json"))
73}
74
75fn claude_session_path_matches_requested_id(path: &Path, requested_ids: &HashSet<&str>) -> bool {
76 claude_session_path(path)
77 && path
78 .file_stem()
79 .and_then(|name| name.to_str())
80 .is_some_and(|stem| requested_ids.contains(stem))
81}
82
83#[cfg(test)]
84mod tests {
85 use tempfile::tempdir;
86 use test_that::prelude::*;
87
88 use super::*;
89
90 #[test]
91 fn test_load_sessions_from_root_by_key_only_parses_matching_claude_files() {
92 let dir = tempdir().expect("tempdir should be created");
93 let root = dir.path().join("projects");
94 let workspace = dir.path().join("workspace");
95 std::fs::create_dir_all(&root).expect("session root should be created");
96 std::fs::create_dir_all(&workspace).expect("workspace should be created");
97 std::fs::write(root.join("target.jsonl"), claude_content("target", &workspace))
98 .expect("target session should be written");
99 std::fs::write(root.join("other.jsonl"), "not json\n").expect("nonmatching session should be written");
100
101 let sessions_result = load_sessions_from_root_by_key(&root, &[SessionKey::new(Agent::Claude, "target")]);
102 assert_that!(sessions_result.as_ref().map(|_| ()), ok(eq(())));
103 let sessions = sessions_result.expect("target Claude session should load");
104
105 assert_that!(sessions.len(), eq(1));
106 assert_that!(sessions[0].id, eq("target"));
107 }
108
109 fn claude_content(id: &str, workspace: &Path) -> String {
110 format!(
111 "{{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"{}\",\"sessionId\":\"{id}\"}}\n",
112 workspace.display()
113 )
114 }
115}