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    /// Total cost in USD.
33    #[serde(default)]
34    pub total_cost_usd: f64,
35    /// Total input tokens.
36    #[serde(default)]
37    pub total_input_tokens: u64,
38    /// Total output tokens.
39    #[serde(default)]
40    pub total_output_tokens: u64,
41    /// Whether plan mode was active.
42    #[serde(default)]
43    pub plan_mode: bool,
44}
45
46/// Sessions directory path.
47fn sessions_dir() -> Option<PathBuf> {
48    dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
49}
50
51/// Save the current session to disk.
52pub 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/// Save the full session state to disk (including cost and token tracking).
65#[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    // Preserve original created_at if file exists.
83    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
116/// Load a session from disk by ID.
117pub 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
139/// List recent sessions, sorted by last update (most recent first).
140pub 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/// Brief summary of a session for listing.
172#[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
182/// Generate a new session ID.
183pub 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('-')); // Should be first segment only.
202        assert!(id.len() == 8); // UUID first segment is 8 hex chars.
203    }
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        // Override sessions dir to a temp directory.
215        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        // Save manually to temp dir.
222        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        // Load it back.
240        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}