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;
14use crate::services::secret_masker;
15
16/// Serializable session state persisted to disk.
17///
18/// Auto-saved on exit, restored via `/resume <id>`. Stored as JSON
19/// in `~/.config/agent-code/sessions/`.
20#[derive(Debug, Serialize, Deserialize)]
21pub struct SessionData {
22    /// Unique session identifier.
23    pub id: String,
24    /// Timestamp when the session was created.
25    pub created_at: String,
26    /// Timestamp of the last update.
27    pub updated_at: String,
28    /// Working directory at session start.
29    pub cwd: String,
30    /// Model used in this session.
31    pub model: String,
32    /// Conversation messages.
33    pub messages: Vec<Message>,
34    /// Total turns completed.
35    pub turn_count: usize,
36    /// Total cost in USD.
37    #[serde(default)]
38    pub total_cost_usd: f64,
39    /// Total input tokens.
40    #[serde(default)]
41    pub total_input_tokens: u64,
42    /// Total output tokens.
43    #[serde(default)]
44    pub total_output_tokens: u64,
45    /// Whether plan mode was active.
46    #[serde(default)]
47    pub plan_mode: bool,
48}
49
50/// Sessions directory path.
51fn sessions_dir() -> Option<PathBuf> {
52    dirs::config_dir().map(|d| d.join("agent-code").join("sessions"))
53}
54
55/// Serialize session data to pretty JSON and apply the secret masker.
56///
57/// Extracted so wire-up tests can verify the persistence boundary
58/// without touching the real filesystem.
59pub(crate) fn serialize_masked(data: &SessionData) -> Result<String, String> {
60    let json = serde_json::to_string_pretty(data)
61        .map_err(|e| format!("Failed to serialize session: {e}"))?;
62    Ok(secret_masker::mask(&json))
63}
64
65/// Save the current session to disk.
66pub fn save_session(
67    session_id: &str,
68    messages: &[Message],
69    cwd: &str,
70    model: &str,
71    turn_count: usize,
72) -> Result<PathBuf, String> {
73    save_session_full(
74        session_id, messages, cwd, model, turn_count, 0.0, 0, 0, false,
75    )
76}
77
78/// Save the full session state to disk (including cost and token tracking).
79#[allow(clippy::too_many_arguments)]
80pub fn save_session_full(
81    session_id: &str,
82    messages: &[Message],
83    cwd: &str,
84    model: &str,
85    turn_count: usize,
86    total_cost_usd: f64,
87    total_input_tokens: u64,
88    total_output_tokens: u64,
89    plan_mode: bool,
90) -> Result<PathBuf, String> {
91    let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
92    std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create sessions dir: {e}"))?;
93
94    let path = dir.join(format!("{session_id}.json"));
95
96    // Preserve original created_at if file exists.
97    let created_at = if path.exists() {
98        std::fs::read_to_string(&path)
99            .ok()
100            .and_then(|c| serde_json::from_str::<SessionData>(&c).ok())
101            .map(|d| d.created_at)
102            .unwrap_or_else(|| chrono::Utc::now().to_rfc3339())
103    } else {
104        chrono::Utc::now().to_rfc3339()
105    };
106
107    let data = SessionData {
108        id: session_id.to_string(),
109        created_at,
110        updated_at: chrono::Utc::now().to_rfc3339(),
111        cwd: cwd.to_string(),
112        model: model.to_string(),
113        messages: messages.to_vec(),
114        turn_count,
115        total_cost_usd,
116        total_input_tokens,
117        total_output_tokens,
118        plan_mode,
119    };
120
121    // Mask secrets at the persistence boundary. Applied to the fully
122    // serialized JSON so the same regex set covers every text-bearing
123    // field (tool results, user messages, metadata). Escaped JSON
124    // strings still match the same patterns at the byte level.
125    let json = serialize_masked(&data)?;
126
127    std::fs::write(&path, json).map_err(|e| format!("Failed to write session file: {e}"))?;
128
129    debug!("Session saved: {}", path.display());
130    Ok(path)
131}
132
133/// Load a session from disk by ID.
134pub fn load_session(session_id: &str) -> Result<SessionData, String> {
135    let dir = sessions_dir().ok_or("Could not determine sessions directory")?;
136    let path = dir.join(format!("{session_id}.json"));
137
138    if !path.exists() {
139        return Err(format!("Session '{session_id}' not found"));
140    }
141
142    let content =
143        std::fs::read_to_string(&path).map_err(|e| format!("Failed to read session: {e}"))?;
144
145    let data: SessionData =
146        serde_json::from_str(&content).map_err(|e| format!("Failed to parse session: {e}"))?;
147
148    info!(
149        "Session loaded: {} ({} messages)",
150        session_id,
151        data.messages.len()
152    );
153    Ok(data)
154}
155
156/// List recent sessions, sorted by last update (most recent first).
157pub fn list_sessions(limit: usize) -> Vec<SessionSummary> {
158    let dir = match sessions_dir() {
159        Some(d) if d.is_dir() => d,
160        _ => return Vec::new(),
161    };
162
163    let mut sessions: Vec<SessionSummary> = std::fs::read_dir(&dir)
164        .ok()
165        .into_iter()
166        .flatten()
167        .flatten()
168        .filter(|e| e.path().extension().is_some_and(|ext| ext == "json"))
169        .filter_map(|entry| {
170            let content = std::fs::read_to_string(entry.path()).ok()?;
171            let data: SessionData = serde_json::from_str(&content).ok()?;
172            Some(SessionSummary {
173                id: data.id,
174                cwd: data.cwd,
175                model: data.model,
176                turn_count: data.turn_count,
177                message_count: data.messages.len(),
178                updated_at: data.updated_at,
179            })
180        })
181        .collect();
182
183    sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
184    sessions.truncate(limit);
185    sessions
186}
187
188/// Brief summary of a session for listing.
189#[derive(Debug)]
190pub struct SessionSummary {
191    pub id: String,
192    pub cwd: String,
193    pub model: String,
194    pub turn_count: usize,
195    pub message_count: usize,
196    pub updated_at: String,
197}
198
199/// Generate a new session ID.
200pub fn new_session_id() -> String {
201    Uuid::new_v4()
202        .to_string()
203        .split('-')
204        .next()
205        .unwrap_or("session")
206        .to_string()
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::llm::message::{ContentBlock, Message, UserMessage, user_message};
213
214    /// Helper: build a session containing the given messages with
215    /// fixed, deterministic metadata. Used by wire-up tests.
216    fn make_session(messages: Vec<Message>) -> SessionData {
217        SessionData {
218            id: "fixture".into(),
219            created_at: "2026-04-15T00:00:00Z".into(),
220            updated_at: "2026-04-15T00:00:00Z".into(),
221            cwd: "/work".into(),
222            model: "test-model".into(),
223            messages,
224            turn_count: 1,
225            total_cost_usd: 0.0,
226            total_input_tokens: 0,
227            total_output_tokens: 0,
228            plan_mode: false,
229        }
230    }
231
232    /// Helper: a user message whose sole content block is a tool_result
233    /// (simulates the agent receiving tool output that embedded a secret).
234    fn tool_result_user_message(tool_use_id: &str, content: &str) -> Message {
235        Message::User(UserMessage {
236            uuid: uuid::Uuid::new_v4(),
237            timestamp: "2026-04-15T00:00:00Z".to_string(),
238            content: vec![ContentBlock::ToolResult {
239                tool_use_id: tool_use_id.to_string(),
240                content: content.to_string(),
241                is_error: false,
242                extra_content: Vec::new(),
243            }],
244            is_meta: false,
245            is_compact_summary: false,
246        })
247    }
248
249    #[test]
250    fn test_new_session_id_format() {
251        let id = new_session_id();
252        assert!(!id.is_empty());
253        assert!(!id.contains('-')); // Should be first segment only.
254        assert!(id.len() == 8); // UUID first segment is 8 hex chars.
255    }
256
257    #[test]
258    fn test_new_session_id_unique() {
259        let id1 = new_session_id();
260        let id2 = new_session_id();
261        assert_ne!(id1, id2);
262    }
263
264    #[test]
265    fn test_save_and_load_session() {
266        // Override sessions dir to a temp directory.
267        let dir = tempfile::tempdir().unwrap();
268        let session_id = "test-save-load";
269        let session_file = dir.path().join(format!("{session_id}.json"));
270
271        let messages = vec![user_message("hello"), user_message("world")];
272
273        // Save manually to temp dir.
274        let data = SessionData {
275            id: session_id.to_string(),
276            created_at: chrono::Utc::now().to_rfc3339(),
277            updated_at: chrono::Utc::now().to_rfc3339(),
278            cwd: "/tmp".to_string(),
279            model: "test-model".to_string(),
280            messages: messages.clone(),
281            turn_count: 5,
282            total_cost_usd: 0.0,
283            total_input_tokens: 0,
284            total_output_tokens: 0,
285            plan_mode: false,
286        };
287        let json = serde_json::to_string_pretty(&data).unwrap();
288        std::fs::create_dir_all(dir.path()).unwrap();
289        std::fs::write(&session_file, &json).unwrap();
290
291        // Load it back.
292        let loaded: SessionData = serde_json::from_str(&json).unwrap();
293        assert_eq!(loaded.id, session_id);
294        assert_eq!(loaded.cwd, "/tmp");
295        assert_eq!(loaded.model, "test-model");
296        assert_eq!(loaded.turn_count, 5);
297        assert_eq!(loaded.messages.len(), 2);
298    }
299
300    #[test]
301    fn test_session_data_serialization_roundtrip() {
302        let data = SessionData {
303            id: "abc123".to_string(),
304            created_at: "2026-01-01T00:00:00Z".to_string(),
305            updated_at: "2026-01-01T00:00:00Z".to_string(),
306            cwd: "/home/user/project".to_string(),
307            model: "claude-sonnet-4".to_string(),
308            messages: vec![user_message("test")],
309            turn_count: 3,
310            total_cost_usd: 0.05,
311            total_input_tokens: 1000,
312            total_output_tokens: 500,
313            plan_mode: false,
314        };
315
316        let json = serde_json::to_string(&data).unwrap();
317        let loaded: SessionData = serde_json::from_str(&json).unwrap();
318        assert_eq!(loaded.id, data.id);
319        assert_eq!(loaded.model, data.model);
320        assert_eq!(loaded.turn_count, data.turn_count);
321    }
322
323    #[test]
324    fn serialize_masked_redacts_secrets_in_messages() {
325        // A tool result leaked an AWS access key into the message history.
326        // When the session is serialized for disk, the secret must not
327        // survive the persistence boundary.
328        let aws_key = "AKIAIOSFODNN7EXAMPLE";
329        let data = SessionData {
330            id: "sess-1".to_string(),
331            created_at: "2026-04-15T00:00:00Z".to_string(),
332            updated_at: "2026-04-15T00:00:00Z".to_string(),
333            cwd: "/work".to_string(),
334            model: "test-model".to_string(),
335            messages: vec![user_message(format!("here is my key {aws_key}"))],
336            turn_count: 1,
337            total_cost_usd: 0.0,
338            total_input_tokens: 0,
339            total_output_tokens: 0,
340            plan_mode: false,
341        };
342        let out = serialize_masked(&data).unwrap();
343        assert!(
344            !out.contains(aws_key),
345            "raw AWS key survived serialization: {out}",
346        );
347        assert!(out.contains("[REDACTED:aws_access_key]"));
348        // Non-secret metadata must still be present.
349        assert!(out.contains("\"cwd\": \"/work\""));
350        assert!(out.contains("\"model\": \"test-model\""));
351    }
352
353    #[test]
354    fn serialize_masked_redacts_generic_credential_assignments() {
355        let secret_line = "api_key=verylongprovidersecret1234567890";
356        let data = SessionData {
357            id: "sess-2".to_string(),
358            created_at: "2026-04-15T00:00:00Z".to_string(),
359            updated_at: "2026-04-15T00:00:00Z".to_string(),
360            cwd: "/work".to_string(),
361            model: "test-model".to_string(),
362            messages: vec![user_message(secret_line)],
363            turn_count: 1,
364            total_cost_usd: 0.0,
365            total_input_tokens: 0,
366            total_output_tokens: 0,
367            plan_mode: false,
368        };
369        let out = serialize_masked(&data).unwrap();
370        assert!(!out.contains("verylongprovidersecret1234567890"));
371        assert!(out.contains("[REDACTED:credential]"));
372    }
373
374    /// Regression probe: masking must never corrupt JSON structure.
375    /// Previously, the credential regex's trailing `["']?` could consume
376    /// the closing quote of a JSON string value, producing unparseable
377    /// output that would break /resume.
378    #[test]
379    fn serialize_masked_produces_parseable_json_for_unquoted_inner_secret() {
380        let data = SessionData {
381            id: "probe".to_string(),
382            created_at: "2026-04-15T00:00:00Z".to_string(),
383            updated_at: "2026-04-15T00:00:00Z".to_string(),
384            cwd: "/work".to_string(),
385            model: "test-model".to_string(),
386            messages: vec![user_message("api_key=hunter2hunter2")],
387            turn_count: 1,
388            total_cost_usd: 0.0,
389            total_input_tokens: 0,
390            total_output_tokens: 0,
391            plan_mode: false,
392        };
393        let out = serialize_masked(&data).unwrap();
394        // Must still parse back as a SessionData.
395        let parsed: Result<SessionData, _> = serde_json::from_str(&out);
396        assert!(
397            parsed.is_ok(),
398            "masked session JSON failed to round-trip: {}\n---\n{out}",
399            parsed.err().unwrap(),
400        );
401        let loaded = parsed.unwrap();
402        assert_eq!(loaded.id, "probe");
403        assert_eq!(loaded.messages.len(), 1);
404    }
405
406    #[test]
407    fn serialize_masked_produces_parseable_json_for_multiple_secret_shapes() {
408        let shapes = [
409            "my api_key=hunter2hunter2",
410            "password: sup3rs3cr3tv@lue (truncated)",
411            r#"env DATABASE_URL=postgres://user:hunter2hunter2@host/db"#,
412            "auth_token = abcdefghijklmn",
413            "mixed: api_key=abcd1234efgh5678 and token=xyz12345abcd6789",
414        ];
415        for shape in shapes {
416            let data = SessionData {
417                id: "probe".to_string(),
418                created_at: "2026-04-15T00:00:00Z".to_string(),
419                updated_at: "2026-04-15T00:00:00Z".to_string(),
420                cwd: "/work".to_string(),
421                model: "test-model".to_string(),
422                messages: vec![user_message(shape.to_string())],
423                turn_count: 1,
424                total_cost_usd: 0.0,
425                total_input_tokens: 0,
426                total_output_tokens: 0,
427                plan_mode: false,
428            };
429            let out = serialize_masked(&data).unwrap();
430            let parsed: Result<SessionData, _> = serde_json::from_str(&out);
431            assert!(
432                parsed.is_ok(),
433                "shape corrupted JSON: {shape:?}\nerr: {}\nout: {out}",
434                parsed.err().unwrap(),
435            );
436        }
437    }
438
439    #[test]
440    fn serialize_masked_redacts_secret_in_tool_result_block() {
441        // Tool output commonly leaks env vars. When the session is
442        // serialized, secrets inside ToolResult content must be scrubbed
443        // just like those in plain text blocks.
444        let leaked = "export AWS_SECRET_ACCESS_KEY=abcdefghijklmnopqrstuvwxyz1234";
445        let data = make_session(vec![tool_result_user_message("call-1", leaked)]);
446        let out = serialize_masked(&data).unwrap();
447        assert!(
448            !out.contains("abcdefghijklmnopqrstuvwxyz1234"),
449            "tool_result secret survived serialization",
450        );
451        assert!(out.contains("REDACTED"));
452        // Round-trip must still work.
453        let _: SessionData =
454            serde_json::from_str(&out).expect("tool_result session must round-trip");
455    }
456
457    #[test]
458    fn serialize_masked_handles_many_messages_with_mixed_secrets() {
459        // Stress: multiple messages, mixed speakers, multiple secret
460        // shapes. All must be masked and the result must still parse.
461        let messages = vec![
462            user_message("AKIAIOSFODNN7EXAMPLE leaked in user message"),
463            tool_result_user_message(
464                "t1",
465                r#"env dump: DATABASE_URL=postgres://user:hunter2hunter2@host/db"#,
466            ),
467            user_message("auth_token = abcdefghijklmnop"),
468            tool_result_user_message("t2", "config.toml says api_key = \"secretprovidervalue\""),
469        ];
470        let data = make_session(messages);
471        let out = serialize_masked(&data).unwrap();
472
473        // No raw secrets remain.
474        for needle in [
475            "AKIAIOSFODNN7EXAMPLE",
476            "hunter2hunter2",
477            "abcdefghijklmnop",
478            "secretprovidervalue",
479        ] {
480            assert!(!out.contains(needle), "leaked {needle} in: {out}",);
481        }
482        // Multiple REDACTED markers present.
483        assert!(out.matches("REDACTED").count() >= 4);
484        // JSON must round-trip through a real parse.
485        let parsed: SessionData =
486            serde_json::from_str(&out).expect("mixed-secret session must round-trip");
487        assert_eq!(parsed.messages.len(), 4);
488    }
489
490    #[test]
491    fn serialize_masked_is_idempotent_save_load_save() {
492        // Re-saving a loaded session must produce byte-identical JSON
493        // (the masker replaced all secrets on the first save; the
494        // second save should find nothing to mask).
495        let data = make_session(vec![
496            user_message("AKIAIOSFODNN7EXAMPLE and api_key=hunter2hunter2"),
497            tool_result_user_message(
498                "t1",
499                "ghp_abcdefghijklmnopqrstuvwxyz0123456789 then password='firstpassword1234'",
500            ),
501        ]);
502
503        let first = serialize_masked(&data).unwrap();
504        let loaded: SessionData = serde_json::from_str(&first).expect("first save must parse");
505
506        // Mirror production: save_session_full re-uses timestamps from
507        // in-memory state, so clone the loaded data as the next save's
508        // input (keeping everything deterministic for the comparison).
509        let second = serialize_masked(&loaded).unwrap();
510
511        assert_eq!(
512            first, second,
513            "save→load→save is not idempotent\nfirst:\n{first}\nsecond:\n{second}",
514        );
515    }
516
517    #[test]
518    fn serialize_masked_leaves_innocuous_content_intact() {
519        let data = SessionData {
520            id: "sess-3".to_string(),
521            created_at: "2026-04-15T00:00:00Z".to_string(),
522            updated_at: "2026-04-15T00:00:00Z".to_string(),
523            cwd: "/work".to_string(),
524            model: "test-model".to_string(),
525            messages: vec![user_message("fn main() { println!(\"hello\"); }")],
526            turn_count: 1,
527            total_cost_usd: 0.0,
528            total_input_tokens: 0,
529            total_output_tokens: 0,
530            plan_mode: false,
531        };
532        let out = serialize_masked(&data).unwrap();
533        assert!(!out.contains("REDACTED"));
534        assert!(out.contains("fn main()"));
535    }
536
537    #[test]
538    fn test_session_summary_fields() {
539        let summary = SessionSummary {
540            id: "xyz".to_string(),
541            cwd: "/tmp".to_string(),
542            model: "gpt-4".to_string(),
543            turn_count: 10,
544            message_count: 20,
545            updated_at: "2026-03-31".to_string(),
546        };
547        assert_eq!(summary.id, "xyz");
548        assert_eq!(summary.turn_count, 10);
549        assert_eq!(summary.message_count, 20);
550    }
551}