agent_code_lib/services/
session.rs1use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info};
11use uuid::Uuid;
12
13use crate::llm::message::Message;
14
15#[derive(Debug, Serialize, Deserialize)]
17pub struct SessionData {
18 pub id: String,
20 pub created_at: String,
22 pub updated_at: String,
24 pub cwd: String,
26 pub model: String,
28 pub messages: Vec<Message>,
30 pub turn_count: usize,
32}
33
34fn sessions_dir() -> Option<PathBuf> {
36 dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
37}
38
39pub fn save_session(
41 session_id: &str,
42 messages: &[Message],
43 cwd: &str,
44 model: &str,
45 turn_count: usize,
46) -> Result<PathBuf, String> {
47 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
48 std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create sessions dir: {e}"))?;
49
50 let path = dir.join(format!("{session_id}.json"));
51
52 let data = SessionData {
53 id: session_id.to_string(),
54 created_at: chrono::Utc::now().to_rfc3339(),
55 updated_at: chrono::Utc::now().to_rfc3339(),
56 cwd: cwd.to_string(),
57 model: model.to_string(),
58 messages: messages.to_vec(),
59 turn_count,
60 };
61
62 let json = serde_json::to_string_pretty(&data)
63 .map_err(|e| format!("Failed to serialize session: {e}"))?;
64
65 std::fs::write(&path, json).map_err(|e| format!("Failed to write session file: {e}"))?;
66
67 debug!("Session saved: {}", path.display());
68 Ok(path)
69}
70
71pub fn load_session(session_id: &str) -> Result<SessionData, String> {
73 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
74 let path = dir.join(format!("{session_id}.json"));
75
76 if !path.exists() {
77 return Err(format!("Session '{session_id}' not found"));
78 }
79
80 let content =
81 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read session: {e}"))?;
82
83 let data: SessionData =
84 serde_json::from_str(&content).map_err(|e| format!("Failed to parse session: {e}"))?;
85
86 info!(
87 "Session loaded: {} ({} messages)",
88 session_id,
89 data.messages.len()
90 );
91 Ok(data)
92}
93
94pub fn list_sessions(limit: usize) -> Vec<SessionSummary> {
96 let dir = match sessions_dir() {
97 Some(d) if d.is_dir() => d,
98 _ => return Vec::new(),
99 };
100
101 let mut sessions: Vec<SessionSummary> = std::fs::read_dir(&dir)
102 .ok()
103 .into_iter()
104 .flatten()
105 .flatten()
106 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
107 .filter_map(|entry| {
108 let content = std::fs::read_to_string(entry.path()).ok()?;
109 let data: SessionData = serde_json::from_str(&content).ok()?;
110 Some(SessionSummary {
111 id: data.id,
112 cwd: data.cwd,
113 model: data.model,
114 turn_count: data.turn_count,
115 message_count: data.messages.len(),
116 updated_at: data.updated_at,
117 })
118 })
119 .collect();
120
121 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
122 sessions.truncate(limit);
123 sessions
124}
125
126#[derive(Debug)]
128pub struct SessionSummary {
129 pub id: String,
130 pub cwd: String,
131 pub model: String,
132 pub turn_count: usize,
133 pub message_count: usize,
134 pub updated_at: String,
135}
136
137pub fn new_session_id() -> String {
139 Uuid::new_v4()
140 .to_string()
141 .split('-')
142 .next()
143 .unwrap_or("session")
144 .to_string()
145}
146
147#[cfg(test)]
148mod tests {
149 use super::*;
150 use crate::llm::message::user_message;
151
152 #[test]
153 fn test_new_session_id_format() {
154 let id = new_session_id();
155 assert!(!id.is_empty());
156 assert!(!id.contains('-')); assert!(id.len() == 8); }
159
160 #[test]
161 fn test_new_session_id_unique() {
162 let id1 = new_session_id();
163 let id2 = new_session_id();
164 assert_ne!(id1, id2);
165 }
166
167 #[test]
168 fn test_save_and_load_session() {
169 let dir = tempfile::tempdir().unwrap();
171 let session_id = "test-save-load";
172 let session_file = dir.path().join(format!("{session_id}.json"));
173
174 let messages = vec![user_message("hello"), user_message("world")];
175
176 let data = SessionData {
178 id: session_id.to_string(),
179 created_at: chrono::Utc::now().to_rfc3339(),
180 updated_at: chrono::Utc::now().to_rfc3339(),
181 cwd: "/tmp".to_string(),
182 model: "test-model".to_string(),
183 messages: messages.clone(),
184 turn_count: 5,
185 };
186 let json = serde_json::to_string_pretty(&data).unwrap();
187 std::fs::create_dir_all(dir.path()).unwrap();
188 std::fs::write(&session_file, &json).unwrap();
189
190 let loaded: SessionData = serde_json::from_str(&json).unwrap();
192 assert_eq!(loaded.id, session_id);
193 assert_eq!(loaded.cwd, "/tmp");
194 assert_eq!(loaded.model, "test-model");
195 assert_eq!(loaded.turn_count, 5);
196 assert_eq!(loaded.messages.len(), 2);
197 }
198
199 #[test]
200 fn test_session_data_serialization_roundtrip() {
201 let data = SessionData {
202 id: "abc123".to_string(),
203 created_at: "2026-01-01T00:00:00Z".to_string(),
204 updated_at: "2026-01-01T00:00:00Z".to_string(),
205 cwd: "/home/user/project".to_string(),
206 model: "claude-sonnet-4".to_string(),
207 messages: vec![user_message("test")],
208 turn_count: 3,
209 };
210
211 let json = serde_json::to_string(&data).unwrap();
212 let loaded: SessionData = serde_json::from_str(&json).unwrap();
213 assert_eq!(loaded.id, data.id);
214 assert_eq!(loaded.model, data.model);
215 assert_eq!(loaded.turn_count, data.turn_count);
216 }
217
218 #[test]
219 fn test_session_summary_fields() {
220 let summary = SessionSummary {
221 id: "xyz".to_string(),
222 cwd: "/tmp".to_string(),
223 model: "gpt-4".to_string(),
224 turn_count: 10,
225 message_count: 20,
226 updated_at: "2026-03-31".to_string(),
227 };
228 assert_eq!(summary.id, "xyz");
229 assert_eq!(summary.turn_count, 10);
230 assert_eq!(summary.message_count, 20);
231 }
232}