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