Skip to main content

agent_code_lib/services/
session.rs

1//! Session persistence.
2//!
3//! Saves and restores conversation state across sessions. Each session
4//! gets a unique ID and is stored as a JSON file in the sessions
5//! directory (`~/.config/agent-code/sessions/`).
6
7use std::path::PathBuf;
8
9use serde::{Deserialize, Serialize};
10use tracing::{debug, info};
11use uuid::Uuid;
12
13use crate::llm::message::Message;
14
15/// Serializable session state.
16#[derive(Debug, Serialize, Deserialize)]
17pub struct SessionData {
18    /// Unique session identifier.
19    pub id: String,
20    /// Timestamp when the session was created.
21    pub created_at: String,
22    /// Timestamp of the last update.
23    pub updated_at: String,
24    /// Working directory at session start.
25    pub cwd: String,
26    /// Model used in this session.
27    pub model: String,
28    /// Conversation messages.
29    pub messages: Vec<Message>,
30    /// Total turns completed.
31    pub turn_count: usize,
32}
33
34/// Sessions directory path.
35fn sessions_dir() -> Option<PathBuf> {
36    dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
37}
38
39/// Save the current session to disk.
40pub 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
71/// Load a session from disk by ID.
72pub 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
94/// List recent sessions, sorted by last update (most recent first).
95pub 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/// Brief summary of a session for listing.
127#[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
137/// Generate a new session ID.
138pub 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('-')); // Should be first segment only.
157        assert!(id.len() == 8); // UUID first segment is 8 hex chars.
158    }
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        // Override sessions dir to a temp directory.
170        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        // Save manually to temp dir.
177        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        // Load it back.
191        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}