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 persisted to disk.
16///
17/// Auto-saved on exit, restored via `/resume <id>`. Stored as JSON
18/// in `~/.config/agent-code/sessions/`.
19#[derive(Debug, Serialize, Deserialize)]
20pub struct SessionData {
21    /// Unique session identifier.
22    pub id: String,
23    /// Timestamp when the session was created.
24    pub created_at: String,
25    /// Timestamp of the last update.
26    pub updated_at: String,
27    /// Working directory at session start.
28    pub cwd: String,
29    /// Model used in this session.
30    pub model: String,
31    /// Conversation messages.
32    pub messages: Vec<Message>,
33    /// Total turns completed.
34    pub turn_count: usize,
35    /// Total cost in USD.
36    #[serde(default)]
37    pub total_cost_usd: f64,
38    /// Total input tokens.
39    #[serde(default)]
40    pub total_input_tokens: u64,
41    /// Total output tokens.
42    #[serde(default)]
43    pub total_output_tokens: u64,
44    /// Whether plan mode was active.
45    #[serde(default)]
46    pub plan_mode: bool,
47}
48
49/// Sessions directory path.
50fn sessions_dir() -> Option<PathBuf> {
51    dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
52}
53
54/// Save the current session to disk.
55pub 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/// Save the full session state to disk (including cost and token tracking).
68#[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    // Preserve original created_at if file exists.
86    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
119/// Load a session from disk by ID.
120pub 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
142/// List recent sessions, sorted by last update (most recent first).
143pub 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/// Brief summary of a session for listing.
175#[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
185/// Generate a new session ID.
186pub 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('-')); // Should be first segment only.
205        assert!(id.len() == 8); // UUID first segment is 8 hex chars.
206    }
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        // Override sessions dir to a temp directory.
218        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        // Save manually to temp dir.
225        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        // Load it back.
243        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}