ytil_agents/agent/session_loader/
cursor.rs1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use crate::agent::Agent;
6use crate::agent::session::Session;
7use crate::agent::session::SessionKey;
8
9pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
16 let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
17 let session_paths = crate::agent::session_loader::find_session_paths(&chats_root, is_session_file, |_| false)?;
18
19 Ok(load_sessions_from_paths(&session_paths, None))
20}
21
22pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
29 let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Cursor);
30 if requested_ids.is_empty() {
31 return Ok(Vec::new());
32 }
33 let chats_root = ytil_sys::dir::build_home_path(Agent::Cursor.sessions_root_path())?;
34 let session_paths = crate::agent::session_loader::find_session_paths(&chats_root, is_session_file, |_| false)?;
35
36 Ok(load_sessions_from_paths(&session_paths, Some(&requested_ids)))
37}
38
39pub(crate) fn is_session_file(entry: &std::fs::DirEntry) -> bool {
40 entry.path().file_name().is_some_and(|name| name == "meta.json")
41}
42
43fn load_sessions_from_paths(session_paths: &[PathBuf], requested_ids: Option<&HashSet<&str>>) -> Vec<Session> {
44 session_paths
45 .iter()
46 .filter_map(|meta_path| load_session_from_meta_json(meta_path, requested_ids))
47 .collect()
48}
49
50fn load_session_from_meta_json(meta_path: &Path, requested_ids: Option<&HashSet<&str>>) -> Option<Session> {
51 let session_dir = meta_path
52 .parent()
53 .map_or_else(|| meta_path.to_path_buf(), Path::to_path_buf);
54 let session_id = session_dir.file_name().and_then(|name| name.to_str())?;
55 if requested_ids.is_some_and(|ids| !ids.contains(session_id)) {
56 return None;
57 }
58
59 let json = std::fs::read_to_string(meta_path).ok()?;
60 let mut cursor_session =
61 crate::agent::session_parser::cursor::parse_chat_meta(&json, session_id.to_owned()).ok()?;
62 if !cursor_session.has_conversation || !cursor_session.workspace.is_dir() {
63 return None;
64 }
65
66 let prompts = read_prompt_history(&session_dir);
67 cursor_session.search_text =
68 crate::agent::session_parser::cursor::build_search_text_from_prompts(&cursor_session.name, &prompts);
69 let mut session = cursor_session.into_session(session_dir);
70 session.last_user_prompt = prompts.iter().find(|prompt| !prompt.trim().is_empty()).cloned();
72 Some(session)
73}
74
75fn read_prompt_history(session_dir: &Path) -> Vec<String> {
76 let path = session_dir.join("prompt_history.json");
77 let Ok(content) = std::fs::read_to_string(path) else {
78 return Vec::new();
79 };
80 serde_json::from_str(&content).unwrap_or_default()
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_paths_by_key_when_invoked_loads_only_matching_cursor_meta() {
92 let dir = tempdir().expect("tempdir should be created");
93 let workspace = dir.path().join("workspace");
94 std::fs::create_dir_all(&workspace).expect("workspace should be created");
95 let target_meta = dir.path().join("target").join("meta.json");
96 let other_meta = dir.path().join("other").join("meta.json");
97 write_meta_json(&target_meta, &workspace, true, "Target");
98 write_meta_json(&other_meta, &workspace, true, "Other");
99 let keys = vec![SessionKey::new(Agent::Cursor, "target")];
100 let requested_ids = crate::agent::session_loader::requested_ids(&keys, Agent::Cursor);
101
102 let sessions = load_sessions_from_paths(&[target_meta, other_meta], Some(&requested_ids));
103
104 assert_that!(sessions.len(), eq(1));
105 assert_that!(sessions[0].id, eq("target"));
106 assert_that!(sessions[0].name, eq("Target"));
107 }
108
109 #[test]
110 fn test_load_sessions_from_paths_when_meta_json_has_cwd_uses_workspace() {
111 let dir = tempdir().expect("tempdir should be created");
112 let workspace = dir.path().join("work").join("pws-api");
113 std::fs::create_dir_all(&workspace).expect("workspace should be created");
114 let meta_path = dir
115 .path()
116 .join("chats")
117 .join("hash")
118 .join("session-id")
119 .join("meta.json");
120 write_meta_json(&meta_path, &workspace, true, "Status Line");
121 std::fs::write(
122 meta_path
123 .parent()
124 .expect("meta.json should have a parent")
125 .join("prompt_history.json"),
126 r#"["first prompt"]"#,
127 )
128 .expect("prompt history should be written");
129
130 let sessions = load_sessions_from_paths(&[meta_path], None);
131
132 assert_that!(sessions.len(), eq(1));
133 assert_that!(sessions[0].id, eq("session-id"));
134 assert_that!(sessions[0].name, eq("Status Line"));
135 assert_that!(sessions[0].workspace, eq(workspace));
136 assert_that!(sessions[0].last_user_prompt.as_deref(), eq(Some("first prompt")));
137 }
138
139 #[test]
140 fn test_load_sessions_from_paths_when_prompt_history_is_newest_first_sets_last_user_prompt_to_newest() {
141 let dir = tempdir().expect("tempdir should be created");
142 let workspace = dir.path().join("work").join("pws-api");
143 std::fs::create_dir_all(&workspace).expect("workspace should be created");
144 let meta_path = dir.path().join("session-id").join("meta.json");
145 write_meta_json(&meta_path, &workspace, true, "Status Line");
146 std::fs::write(
147 meta_path
148 .parent()
149 .expect("meta.json should have a parent")
150 .join("prompt_history.json"),
151 r#"["newest", "oldest"]"#,
152 )
153 .expect("prompt history should be written");
154
155 let sessions = load_sessions_from_paths(&[meta_path], None);
156
157 assert_that!(sessions.len(), eq(1));
158 assert_that!(sessions[0].last_user_prompt.as_deref(), eq(Some("newest")));
159 }
160
161 #[test]
162 fn test_load_sessions_from_paths_when_meta_json_has_no_conversation_skips_session() {
163 let dir = tempdir().expect("tempdir should be created");
164 let workspace = dir.path().join("work").join("pws-api");
165 std::fs::create_dir_all(&workspace).expect("workspace should be created");
166 let meta_path = dir
167 .path()
168 .join("chats")
169 .join("hash")
170 .join("session-id")
171 .join("meta.json");
172 write_meta_json(&meta_path, &workspace, false, "Empty");
173
174 let sessions = load_sessions_from_paths(&[meta_path], None);
175
176 assert_that!(sessions.len(), eq(0));
177 }
178
179 #[test]
180 fn test_load_sessions_from_paths_when_one_meta_json_is_invalid_skips_and_loads_valid() {
181 let dir = tempdir().expect("tempdir should be created");
182 let workspace = dir.path().join("work").join("pws-api");
183 std::fs::create_dir_all(&workspace).expect("workspace should be created");
184 let invalid_meta = dir.path().join("invalid").join("meta.json");
185 let valid_meta = dir.path().join("valid").join("meta.json");
186 let parent = invalid_meta.parent().expect("invalid meta.json should have a parent");
187 std::fs::create_dir_all(parent).expect("invalid session dir should be created");
188 std::fs::write(&invalid_meta, r#"{"hasConversation":false}"#).expect("invalid meta.json should be written");
189 write_meta_json(&valid_meta, &workspace, true, "Valid");
190
191 let sessions = load_sessions_from_paths(&[invalid_meta, valid_meta], None);
192
193 assert_that!(sessions.len(), eq(1));
194 assert_that!(sessions[0].id, eq("valid"));
195 assert_that!(sessions[0].name, eq("Valid"));
196 }
197
198 fn write_meta_json(path: &Path, workspace: &Path, has_conversation: bool, title: &str) {
199 let parent = path.parent().expect("meta.json should have a parent");
200 std::fs::create_dir_all(parent).expect("session dir should be created");
201 std::fs::write(
202 path,
203 format!(
204 r#"{{"schemaVersion":1,"createdAtMs":1774877738013,"hasConversation":{has_conversation},"title":"{title}","updatedAtMs":1774877739013,"cwd":"{}"}}"#,
205 workspace.display()
206 ),
207 )
208 .expect("meta.json should be written");
209 }
210}