1use 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)]
20pub struct SessionData {
21 pub id: String,
23 pub created_at: String,
25 pub updated_at: String,
27 pub cwd: String,
29 pub model: String,
31 pub messages: Vec<Message>,
33 pub turn_count: usize,
35 #[serde(default)]
37 pub total_cost_usd: f64,
38 #[serde(default)]
40 pub total_input_tokens: u64,
41 #[serde(default)]
43 pub total_output_tokens: u64,
44 #[serde(default)]
46 pub plan_mode: bool,
47}
48
49fn sessions_dir() -> Option<PathBuf> {
51 dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
52}
53
54pub fn save_session(
56 session_id: &str,
57 messages: &[Message],
58 cwd: &str,
59 model: &str,
60 turn_count: usize,
61) -> Result<PathBuf, String> {
62 save_session_full(
63 session_id, messages, cwd, model, turn_count, 0.0, 0, 0, false,
64 )
65}
66
67#[allow(clippy::too_many_arguments)]
69pub fn save_session_full(
70 session_id: &str,
71 messages: &[Message],
72 cwd: &str,
73 model: &str,
74 turn_count: usize,
75 total_cost_usd: f64,
76 total_input_tokens: u64,
77 total_output_tokens: u64,
78 plan_mode: bool,
79) -> Result<PathBuf, String> {
80 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
81 std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create sessions dir: {e}"))?;
82
83 let path = dir.join(format!("{session_id}.json"));
84
85 let created_at = if path.exists() {
87 std::fs::read_to_string(&path)
88 .ok()
89 .and_then(|c| serde_json::from_str::<SessionData>(&c).ok())
90 .map(|d| d.created_at)
91 .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
92 } else {
93 chrono::Utc::now().to_rfc3339()
94 };
95
96 let data = SessionData {
97 id: session_id.to_string(),
98 created_at,
99 updated_at: chrono::Utc::now().to_rfc3339(),
100 cwd: cwd.to_string(),
101 model: model.to_string(),
102 messages: messages.to_vec(),
103 turn_count,
104 total_cost_usd,
105 total_input_tokens,
106 total_output_tokens,
107 plan_mode,
108 };
109
110 let json = serde_json::to_string_pretty(&data)
111 .map_err(|e| format!("Failed to serialize session: {e}"))?;
112
113 std::fs::write(&path, json).map_err(|e| format!("Failed to write session file: {e}"))?;
114
115 debug!("Session saved: {}", path.display());
116 Ok(path)
117}
118
119pub fn load_session(session_id: &str) -> Result<SessionData, String> {
121 let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
122 let path = dir.join(format!("{session_id}.json"));
123
124 if !path.exists() {
125 return Err(format!("Session '{session_id}' not found"));
126 }
127
128 let content =
129 std::fs::read_to_string(&path).map_err(|e| format!("Failed to read session: {e}"))?;
130
131 let data: SessionData =
132 serde_json::from_str(&content).map_err(|e| format!("Failed to parse session: {e}"))?;
133
134 info!(
135 "Session loaded: {} ({} messages)",
136 session_id,
137 data.messages.len()
138 );
139 Ok(data)
140}
141
142pub fn list_sessions(limit: usize) -> Vec<SessionSummary> {
144 let dir = match sessions_dir() {
145 Some(d) if d.is_dir() => d,
146 _ => return Vec::new(),
147 };
148
149 let mut sessions: Vec<SessionSummary> = std::fs::read_dir(&dir)
150 .ok()
151 .into_iter()
152 .flatten()
153 .flatten()
154 .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
155 .filter_map(|entry| {
156 let content = std::fs::read_to_string(entry.path()).ok()?;
157 let data: SessionData = serde_json::from_str(&content).ok()?;
158 Some(SessionSummary {
159 id: data.id,
160 cwd: data.cwd,
161 model: data.model,
162 turn_count: data.turn_count,
163 message_count: data.messages.len(),
164 updated_at: data.updated_at,
165 })
166 })
167 .collect();
168
169 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
170 sessions.truncate(limit);
171 sessions
172}
173
174#[derive(Debug)]
176pub struct SessionSummary {
177 pub id: String,
178 pub cwd: String,
179 pub model: String,
180 pub turn_count: usize,
181 pub message_count: usize,
182 pub updated_at: String,
183}
184
185pub fn new_session_id() -> String {
187 Uuid::new_v4()
188 .to_string()
189 .split('-')
190 .next()
191 .unwrap_or("session")
192 .to_string()
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use crate::llm::message::user_message;
199
200 #[test]
201 fn test_new_session_id_format() {
202 let id = new_session_id();
203 assert!(!id.is_empty());
204 assert!(!id.contains('-')); assert!(id.len() == 8); }
207
208 #[test]
209 fn test_new_session_id_unique() {
210 let id1 = new_session_id();
211 let id2 = new_session_id();
212 assert_ne!(id1, id2);
213 }
214
215 #[test]
216 fn test_save_and_load_session() {
217 let dir = tempfile::tempdir().unwrap();
219 let session_id = "test-save-load";
220 let session_file = dir.path().join(format!("{session_id}.json"));
221
222 let messages = vec![user_message("hello"), user_message("world")];
223
224 let data = SessionData {
226 id: session_id.to_string(),
227 created_at: chrono::Utc::now().to_rfc3339(),
228 updated_at: chrono::Utc::now().to_rfc3339(),
229 cwd: "/tmp".to_string(),
230 model: "test-model".to_string(),
231 messages: messages.clone(),
232 turn_count: 5,
233 total_cost_usd: 0.0,
234 total_input_tokens: 0,
235 total_output_tokens: 0,
236 plan_mode: false,
237 };
238 let json = serde_json::to_string_pretty(&data).unwrap();
239 std::fs::create_dir_all(dir.path()).unwrap();
240 std::fs::write(&session_file, &json).unwrap();
241
242 let loaded: SessionData = serde_json::from_str(&json).unwrap();
244 assert_eq!(loaded.id, session_id);
245 assert_eq!(loaded.cwd, "/tmp");
246 assert_eq!(loaded.model, "test-model");
247 assert_eq!(loaded.turn_count, 5);
248 assert_eq!(loaded.messages.len(), 2);
249 }
250
251 #[test]
252 fn test_session_data_serialization_roundtrip() {
253 let data = SessionData {
254 id: "abc123".to_string(),
255 created_at: "2026-01-01T00:00:00Z".to_string(),
256 updated_at: "2026-01-01T00:00:00Z".to_string(),
257 cwd: "/home/user/project".to_string(),
258 model: "claude-sonnet-4".to_string(),
259 messages: vec![user_message("test")],
260 turn_count: 3,
261 total_cost_usd: 0.05,
262 total_input_tokens: 1000,
263 total_output_tokens: 500,
264 plan_mode: false,
265 };
266
267 let json = serde_json::to_string(&data).unwrap();
268 let loaded: SessionData = serde_json::from_str(&json).unwrap();
269 assert_eq!(loaded.id, data.id);
270 assert_eq!(loaded.model, data.model);
271 assert_eq!(loaded.turn_count, data.turn_count);
272 }
273
274 #[test]
275 fn test_session_summary_fields() {
276 let summary = SessionSummary {
277 id: "xyz".to_string(),
278 cwd: "/tmp".to_string(),
279 model: "gpt-4".to_string(),
280 turn_count: 10,
281 message_count: 20,
282 updated_at: "2026-03-31".to_string(),
283 };
284 assert_eq!(summary.id, "xyz");
285 assert_eq!(summary.turn_count, 10);
286 assert_eq!(summary.message_count, 20);
287 }
288}