Skip to main content

phi_agent/
session.rs

1use std::fs::File;
2use std::path::{Path, PathBuf};
3use std::sync::LazyLock;
4
5use agent_base::{AgentError, AgentResult};
6use fs2::FileExt;
7use regex::Regex;
8
9static SESSION_ID_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
10static SNAPSHOT_NAME_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"^[a-zA-Z0-9\-_]+$").unwrap());
11
12/// Session context — holds the session ID, directory, base directory, and file lock.
13///
14/// Created via [`resolve_session`]. The lock is released when the struct is dropped.
15#[derive(Debug)]
16pub struct SessionContext {
17    /// Session identifier (user-provided or auto-generated).
18    pub session_id: String,
19    /// Path to the session directory on disk.
20    pub session_dir: PathBuf,
21    /// Base directory for all session data.
22    pub base_dir: PathBuf,
23    /// Whether this session was just created (vs reused).
24    pub is_new_session: bool,
25    _lock: Option<File>,
26}
27
28impl SessionContext {
29    /// Path to the session ID marker file.
30    #[allow(dead_code)]
31    pub fn session_id_path(&self) -> PathBuf {
32        self.session_dir.join("session_id")
33    }
34
35    /// Path to the session metadata JSON file.
36    pub fn metadata_path(&self) -> PathBuf {
37        self.session_dir.join("session_meta.json")
38    }
39
40    /// Path to the session log file (human-readable).
41    pub fn log_path(&self) -> PathBuf {
42        self.session_dir.join("session.log")
43    }
44
45    /// Path to the per-turn JSONL event log. `turn` is 1-indexed.
46    pub fn turn_path(&self, turn: usize) -> PathBuf {
47        self.session_dir.join(format!("turn_{:03}.jsonl", turn))
48    }
49}
50
51/// Validate session ID format.
52///
53/// Allowed: alphanumerics, hyphens, underscores. 1–128 characters.
54pub fn validate_session_id(session_id: &str) -> AgentResult<()> {
55    if session_id.is_empty() || session_id.len() > 128 {
56        return Err(AgentError::config_error(format!("Session ID must be 1-128 characters, got {}", session_id.len())));
57    }
58
59    if !SESSION_ID_RE.is_match(session_id) {
60        return Err(AgentError::config_error(format!(
61            "Invalid session_id format '{}'. Only alphanumeric, hyphens, and underscores allowed.",
62            session_id
63        )));
64    }
65
66    Ok(())
67}
68
69/// Validate snapshot name format.
70///
71/// Allowed: alphanumerics, hyphens, underscores. 1–64 characters.
72/// No path separators or traversal sequences allowed.
73pub fn validate_snapshot_name(name: &str) -> AgentResult<()> {
74    if name.is_empty() || name.len() > 64 {
75        return Err(AgentError::config_error(format!("Snapshot name must be 1-64 characters, got {}", name.len())));
76    }
77
78    if name.contains('/') || name.contains('\\') || name.contains("..") {
79        return Err(AgentError::config_error(format!(
80            "Invalid snapshot name '{}': path separators and '..' are not allowed",
81            name
82        )));
83    }
84
85    if !SNAPSHOT_NAME_RE.is_match(name) {
86        return Err(AgentError::config_error(format!(
87            "Invalid snapshot name '{}'. Only alphanumeric, hyphens, and underscores allowed.",
88            name
89        )));
90    }
91
92    Ok(())
93}
94
95/// Resolve session ID (priority: CLI arg → `PHI_SESSION_ID` env var → auto-generate).
96pub fn resolve_session_id(cli_session_id: Option<&str>) -> AgentResult<String> {
97    if let Some(id) = cli_session_id {
98        validate_session_id(id)?;
99        return Ok(id.to_string());
100    }
101
102    if let Ok(id) = std::env::var("PHI_SESSION_ID")
103        && !id.is_empty()
104    {
105        validate_session_id(&id)?;
106        return Ok(id);
107    }
108
109    Ok(generate_session_id())
110}
111
112/// Generate a new session_id (format: YYYYMMDD_first8ofUuid)
113pub fn generate_session_id() -> String {
114    let now = chrono::Local::now();
115    let uuid = uuid::Uuid::new_v4().to_string();
116    let uuid_short = &uuid[..8.min(uuid.len())];
117    format!("{}_{}", now.format("%Y%m%d"), uuid_short)
118}
119
120/// Get or create a session directory under `base_dir/sessions/<session_id>`.
121///
122/// Returns the directory path and whether it was newly created.
123pub fn get_or_create_session_dir(session_id: &str, base_dir: &Path) -> AgentResult<(PathBuf, bool)> {
124    let session_dir = base_dir.join("sessions").join(session_id);
125    let is_new = !session_dir.exists();
126
127    if is_new {
128        std::fs::create_dir_all(&session_dir)?;
129        tracing::info!(session_id = %session_id, path = %session_dir.display(), "created new session directory");
130    } else {
131        tracing::info!(session_id = %session_id, path = %session_dir.display(), "reusing existing session directory");
132    }
133
134    // Write session_id file
135    std::fs::write(session_dir.join("session_id"), session_id)?;
136
137    // Update session_meta.json
138    update_session_meta(&session_dir, session_id)?;
139
140    Ok((session_dir, is_new))
141}
142
143/// Acquire an exclusive file lock on the session directory.
144///
145/// Prevents concurrent access from other processes. Returns an error if the
146/// session is already in use.
147pub fn acquire_session_lock(session_dir: &Path) -> AgentResult<File> {
148    let lock_path = session_dir.join("session.lock");
149    let file = File::create(&lock_path)?;
150
151    file.try_lock_exclusive().map_err(|_| {
152        AgentError::resource_unavailable(format!(
153            "Session '{}' is currently in use by another process",
154            session_dir.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default()
155        ))
156    })?;
157
158    Ok(file)
159}
160
161/// Update session_meta.json
162fn update_session_meta(session_dir: &Path, session_id: &str) -> AgentResult<()> {
163    let meta_path = session_dir.join("session_meta.json");
164
165    let mut meta = if meta_path.exists() {
166        let content = std::fs::read_to_string(&meta_path)?;
167        serde_json::from_str::<serde_json::Value>(&content)?
168    } else {
169        serde_json::json!({
170            "session_id": session_id,
171            "created_at": chrono::Utc::now().to_rfc3339(),
172        })
173    };
174
175    meta["last_active_at"] = serde_json::json!(chrono::Utc::now().to_rfc3339());
176
177    std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?;
178    Ok(())
179}
180
181/// Clean up expired sessions.
182///
183/// Sessions inactive for more than `max_age_days` are removed from disk.
184/// Active (locked) sessions are skipped.
185pub fn cleanup_expired_sessions(base_dir: &Path, max_age_days: i64) -> AgentResult<u32> {
186    let sessions_dir = base_dir.join("sessions");
187    if !sessions_dir.exists() {
188        return Ok(0);
189    }
190
191    let now = chrono::Utc::now();
192    let mut cleaned = 0;
193
194    for entry in std::fs::read_dir(&sessions_dir)? {
195        let entry = entry?;
196        let path = entry.path();
197
198        if !path.is_dir() {
199            continue;
200        }
201
202        let lock_path = path.join("session.lock");
203        if lock_path.exists()
204            && let Ok(file) = File::open(&lock_path)
205            && file.try_lock_shared().is_err()
206        {
207            continue; // locked, skip
208        }
209
210        let meta_path = path.join("session_meta.json");
211        if !meta_path.exists() {
212            std::fs::remove_dir_all(&path)?;
213            cleaned += 1;
214            continue;
215        }
216
217        let content = std::fs::read_to_string(&meta_path)?;
218        let meta: serde_json::Value = serde_json::from_str(&content)?;
219
220        if let Some(last_active) = meta["last_active_at"].as_str()
221            && let Ok(last_active) = chrono::DateTime::parse_from_rfc3339(last_active)
222        {
223            let age = now - last_active.with_timezone(&chrono::Utc);
224            if age.num_days() > max_age_days {
225                tracing::info!(path = %path.display(), age_days = age.num_days(), "removing expired session");
226                std::fs::remove_dir_all(&path)?;
227                cleaned += 1;
228            }
229        }
230    }
231
232    if cleaned > 0 {
233        tracing::info!(count = cleaned, "cleaned up expired sessions");
234    }
235
236    Ok(cleaned)
237}
238
239// ── Session snapshots (Phase 6.3) ──
240
241/// Create a named snapshot of a session.
242///
243/// Copies the session directory to `base_dir/snapshots/<name>/`.
244/// Returns the snapshot path.
245pub fn create_snapshot(session_ctx: &SessionContext, name: &str, base_dir: &Path) -> AgentResult<PathBuf> {
246    validate_snapshot_name(name)?;
247    let snapshot_dir = base_dir.join("snapshots").join(name);
248
249    if snapshot_dir.exists() {
250        std::fs::remove_dir_all(&snapshot_dir)?;
251    }
252    std::fs::create_dir_all(&snapshot_dir)?;
253
254    // Copy session metadata
255    let meta_path = session_ctx.metadata_path();
256    if meta_path.exists() {
257        std::fs::copy(&meta_path, snapshot_dir.join("session_meta.json"))?;
258    }
259
260    // Copy all turn logs
261    let mut turn_count = 0u32;
262    for entry in std::fs::read_dir(&session_ctx.session_dir)? {
263        let entry = entry?;
264        let name = entry.file_name();
265        let name_str = name.to_string_lossy();
266        if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
267            std::fs::copy(entry.path(), snapshot_dir.join(&*name_str))?;
268            turn_count += 1;
269        }
270    }
271
272    // Write snapshot info
273    let info = serde_json::json!({
274        "session_id": session_ctx.session_id,
275        "snapshot_name": name,
276        "created_at": chrono::Utc::now().to_rfc3339(),
277        "turn_count": turn_count,
278    });
279    std::fs::write(snapshot_dir.join("snapshot_info.json"), serde_json::to_string_pretty(&info)?)?;
280
281    tracing::info!(
282        session_id = %session_ctx.session_id,
283        name = name,
284        turns = turn_count,
285        "session snapshot created"
286    );
287
288    Ok(snapshot_dir)
289}
290
291/// List all saved snapshots under `base_dir/snapshots/`.
292///
293/// Returns a vector of (name, snapshot_info_json, turn_count).
294pub fn list_snapshots(base_dir: &Path) -> AgentResult<Vec<SnapshotInfo>> {
295    let snapshots_dir = base_dir.join("snapshots");
296    if !snapshots_dir.exists() {
297        return Ok(Vec::new());
298    }
299
300    let mut snapshots = Vec::new();
301    for entry in std::fs::read_dir(&snapshots_dir)? {
302        let entry = entry?;
303        let path = entry.path();
304        if !path.is_dir() {
305            continue;
306        }
307
308        let info_path = path.join("snapshot_info.json");
309        if !info_path.exists() {
310            continue;
311        }
312
313        let content = std::fs::read_to_string(&info_path)?;
314        let info: serde_json::Value = serde_json::from_str(&content)?;
315
316        let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
317        let session_id = info["session_id"].as_str().unwrap_or("-").to_string();
318        let created_at = info["created_at"].as_str().unwrap_or("-").to_string();
319        let turn_count = info["turn_count"].as_u64().unwrap_or(0) as u32;
320
321        snapshots.push(SnapshotInfo { name, session_id, created_at, turn_count, path });
322    }
323
324    snapshots.sort_by(|a, b| b.created_at.cmp(&a.created_at));
325    Ok(snapshots)
326}
327
328/// Info about a saved snapshot.
329pub struct SnapshotInfo {
330    /// Snapshot name.
331    pub name: String,
332    /// Original session ID this snapshot was taken from.
333    pub session_id: String,
334    /// ISO-8601 timestamp of when the snapshot was created.
335    pub created_at: String,
336    /// Number of turns captured in the snapshot.
337    pub turn_count: u32,
338    /// Path to the snapshot directory on disk.
339    pub path: PathBuf,
340}
341
342/// Restore a session from a snapshot.
343///
344/// Creates a new session from the snapshot data. Returns the new session context.
345pub fn restore_snapshot(name: &str, base_dir: &Path) -> AgentResult<SessionContext> {
346    validate_snapshot_name(name)?;
347    let snapshot_dir = base_dir.join("snapshots").join(name);
348    if !snapshot_dir.exists() {
349        return Err(AgentError::config_error(format!("Snapshot '{}' not found at {}", name, snapshot_dir.display())));
350    }
351
352    // Generate new session ID and directory
353    let new_session_id = generate_session_id();
354    let (new_session_dir, _) = get_or_create_session_dir(&new_session_id, base_dir)?;
355
356    // Copy turn logs from snapshot
357    for entry in std::fs::read_dir(&snapshot_dir)? {
358        let entry = entry?;
359        let fname = entry.file_name();
360        let name_str = fname.to_string_lossy();
361        if name_str.starts_with("turn_") && name_str.ends_with(".jsonl") {
362            std::fs::copy(entry.path(), new_session_dir.join(&*name_str))?;
363        }
364    }
365
366    let lock = acquire_session_lock(&new_session_dir)?;
367    tracing::info!(from = name, to = %new_session_id, "session restored from snapshot");
368
369    Ok(SessionContext {
370        session_id: new_session_id,
371        session_dir: new_session_dir,
372        base_dir: base_dir.to_path_buf(),
373        is_new_session: false,
374        _lock: Some(lock),
375    })
376}
377
378/// Delete a saved snapshot.
379pub fn delete_snapshot(name: &str, base_dir: &Path) -> AgentResult<()> {
380    validate_snapshot_name(name)?;
381    let snapshot_dir = base_dir.join("snapshots").join(name);
382    if !snapshot_dir.exists() {
383        return Err(AgentError::config_error(format!("Snapshot '{}' not found", name)));
384    }
385    std::fs::remove_dir_all(&snapshot_dir)?;
386    tracing::info!(name = name, "snapshot deleted");
387    Ok(())
388}
389
390/// Resolve and create a session context — session ID, directory, and file lock.
391///
392/// This is the primary entry point for session setup. It combines ID resolution,
393/// directory creation, and lock acquisition into a single call.
394pub fn resolve_session(cli_session_id: Option<&str>, base_dir: &Path) -> AgentResult<SessionContext> {
395    let session_id = resolve_session_id(cli_session_id)?;
396    let (session_dir, is_new) = get_or_create_session_dir(&session_id, base_dir)?;
397    let lock = acquire_session_lock(&session_dir)?;
398
399    Ok(SessionContext {
400        session_id,
401        session_dir,
402        base_dir: base_dir.to_path_buf(),
403        is_new_session: is_new,
404        _lock: Some(lock),
405    })
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411    use tempfile::TempDir;
412
413    #[test]
414    fn test_validate_session_id_valid() {
415        assert!(validate_session_id("my-session-123").is_ok());
416        assert!(validate_session_id("test_456").is_ok());
417        assert!(validate_session_id("a").is_ok());
418    }
419
420    #[test]
421    fn test_validate_session_id_invalid() {
422        assert!(validate_session_id("").is_err());
423        assert!(validate_session_id("my session").is_err());
424        assert!(validate_session_id("../etc").is_err());
425        assert!(validate_session_id("path/traversal").is_err());
426    }
427
428    #[test]
429    fn test_generate_session_id() {
430        let id = generate_session_id();
431        assert!(id.contains('_'));
432        let parts: Vec<&str> = id.split('_').collect();
433        assert_eq!(parts.len(), 2);
434        assert_eq!(parts[0].len(), 8);
435        assert_eq!(parts[1].len(), 8);
436    }
437
438    #[test]
439    fn test_session_context_methods() {
440        let tmp = TempDir::new().unwrap();
441        let ctx = resolve_session(Some("test-ctx"), tmp.path()).unwrap();
442
443        assert_eq!(ctx.session_id, "test-ctx");
444        assert!(ctx.session_id_path().exists());
445        assert!(ctx.metadata_path().exists());
446        assert_eq!(ctx.log_path(), ctx.session_dir.join("session.log"));
447        assert_eq!(ctx.turn_path(1), ctx.session_dir.join("turn_001.jsonl"));
448    }
449
450    #[test]
451    fn test_cleanup_expired_sessions() {
452        let tmp = TempDir::new().unwrap();
453        let (dir, _) = get_or_create_session_dir("old-session", tmp.path()).unwrap();
454
455        let meta_path = dir.join("session_meta.json");
456        let mut meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(&meta_path).unwrap()).unwrap();
457        let old = (chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339();
458        meta["last_active_at"] = serde_json::json!(old);
459        std::fs::write(&meta_path, serde_json::to_string_pretty(&meta).unwrap()).unwrap();
460
461        get_or_create_session_dir("new-session", tmp.path()).unwrap();
462        let cleaned = cleanup_expired_sessions(tmp.path(), 7).unwrap();
463        assert_eq!(cleaned, 1);
464        assert!(!dir.exists());
465    }
466
467    // ── Phase 1: AgentError contract tests ──
468
469    #[test]
470    fn test_validate_session_id_returns_config_error() {
471        // Empty ID
472        let err = validate_session_id("").unwrap_err();
473        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for empty session ID, got {:?}", err);
474
475        // Invalid characters
476        let err = validate_session_id("../etc").unwrap_err();
477        assert!(
478            matches!(err, AgentError::ConfigError(_)),
479            "expected ConfigError for invalid session ID, got {:?}",
480            err
481        );
482    }
483
484    #[test]
485    fn test_resolve_session_returns_config_error_for_invalid_id() {
486        let tmp = TempDir::new().unwrap();
487        let result = resolve_session(Some("bad id with spaces"), tmp.path());
488        assert!(result.is_err());
489        let err = result.err().unwrap();
490        assert!(
491            matches!(err, AgentError::ConfigError(_)),
492            "expected ConfigError for invalid session ID, got {:?}",
493            err
494        );
495    }
496
497    #[test]
498    fn test_acquire_session_lock_returns_resource_unavailable_when_locked() {
499        let tmp = TempDir::new().unwrap();
500        let (_dir, _) = get_or_create_session_dir("lock-test", tmp.path()).unwrap();
501        let session_dir = tmp.path().join("sessions").join("lock-test");
502
503        // First lock succeeds
504        let _lock1 = acquire_session_lock(&session_dir).unwrap();
505
506        // Second lock should fail with ResourceUnavailable
507        let err = acquire_session_lock(&session_dir).unwrap_err();
508        assert!(
509            matches!(err, AgentError::ResourceUnavailable(_)),
510            "expected ResourceUnavailable for locked session, got {:?}",
511            err
512        );
513    }
514
515    // ── Snapshot name validation tests ──
516
517    #[test]
518    fn test_validate_snapshot_name_valid() {
519        assert!(validate_snapshot_name("my-snapshot").is_ok());
520        assert!(validate_snapshot_name("test_123").is_ok());
521        assert!(validate_snapshot_name("a").is_ok());
522        assert!(validate_snapshot_name("snapshot-2024-01-01").is_ok());
523    }
524
525    #[test]
526    fn test_validate_snapshot_name_invalid() {
527        assert!(validate_snapshot_name("").is_err());
528        assert!(validate_snapshot_name("../etc").is_err());
529        assert!(validate_snapshot_name("path/traversal").is_err());
530        assert!(validate_snapshot_name("back\\slash").is_err());
531        assert!(validate_snapshot_name("dot..dot").is_err());
532        assert!(validate_snapshot_name("has space").is_err());
533        assert!(validate_snapshot_name(&"x".repeat(65)).is_err()); // too long
534    }
535
536    #[test]
537    fn test_validate_snapshot_name_returns_config_error() {
538        let err = validate_snapshot_name("").unwrap_err();
539        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError, got {:?}", err);
540
541        let err = validate_snapshot_name("../etc").unwrap_err();
542        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for path traversal, got {:?}", err);
543    }
544
545    // ── Snapshot create / list tests ──
546
547    #[test]
548    fn test_create_snapshot_and_list() {
549        let tmp = TempDir::new().unwrap();
550        let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
551
552        // Have at least one turn log so we can verify it is copied
553        let turn_path = ctx.turn_path(1);
554        std::fs::write(&turn_path, "{}").unwrap();
555
556        // Create a snapshot
557        let snap_path = create_snapshot(&ctx, "test-snap", tmp.path()).unwrap();
558        assert!(snap_path.exists());
559        assert!(snap_path.join("snapshot_info.json").exists());
560        assert!(snap_path.join("turn_001.jsonl").exists());
561
562        // List — should contain exactly one
563        let snaps = list_snapshots(tmp.path()).unwrap();
564        assert_eq!(snaps.len(), 1);
565        assert_eq!(snaps[0].name, "test-snap");
566        assert_eq!(snaps[0].session_id, "snap-session");
567        assert_eq!(snaps[0].turn_count, 1);
568    }
569
570    #[test]
571    fn test_create_snapshot_overwrites_existing() {
572        let tmp = TempDir::new().unwrap();
573        let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
574
575        create_snapshot(&ctx, "dup-snap", tmp.path()).unwrap();
576        // Second create with same name should succeed (overwrite)
577        let result = create_snapshot(&ctx, "dup-snap", tmp.path());
578        assert!(result.is_ok());
579
580        let snaps = list_snapshots(tmp.path()).unwrap();
581        assert_eq!(snaps.len(), 1);
582    }
583
584    #[test]
585    fn test_list_snapshots_empty_dir() {
586        let tmp = TempDir::new().unwrap();
587        let snaps = list_snapshots(tmp.path()).unwrap();
588        assert!(snaps.is_empty());
589    }
590
591    #[test]
592    fn test_list_snapshots_sorted_by_date_desc() {
593        let tmp = TempDir::new().unwrap();
594        let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
595
596        create_snapshot(&ctx, "first", tmp.path()).unwrap();
597        std::thread::sleep(std::time::Duration::from_millis(10));
598        create_snapshot(&ctx, "second", tmp.path()).unwrap();
599
600        let snaps = list_snapshots(tmp.path()).unwrap();
601        assert_eq!(snaps.len(), 2);
602        // Most recent first
603        assert_eq!(snaps[0].name, "second");
604        assert_eq!(snaps[1].name, "first");
605    }
606
607    // ── Snapshot restore tests ──
608
609    #[test]
610    fn test_restore_snapshot_success() {
611        let tmp = TempDir::new().unwrap();
612        let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
613
614        // Write a turn log
615        std::fs::write(ctx.turn_path(1), r#"{"type":"text_delta","text":"hello"}"#).unwrap();
616        create_snapshot(&ctx, "restore-me", tmp.path()).unwrap();
617
618        // Restore into a new session
619        let restored = restore_snapshot("restore-me", tmp.path()).unwrap();
620        assert_ne!(restored.session_id, "snap-session");
621        assert!(!restored.is_new_session);
622        assert!(restored.turn_path(1).exists());
623
624        // Verify turn log content was copied
625        let content = std::fs::read_to_string(restored.turn_path(1)).unwrap();
626        assert!(content.contains("hello"));
627    }
628
629    #[test]
630    fn test_restore_snapshot_not_found() {
631        let tmp = TempDir::new().unwrap();
632        let err = restore_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
633        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
634    }
635
636    // ── Snapshot delete tests ──
637
638    #[test]
639    fn test_delete_snapshot_success() {
640        let tmp = TempDir::new().unwrap();
641        let ctx = resolve_session(Some("snap-session"), tmp.path()).unwrap();
642
643        create_snapshot(&ctx, "del-me", tmp.path()).unwrap();
644        assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 1);
645
646        delete_snapshot("del-me", tmp.path()).unwrap();
647        assert_eq!(list_snapshots(tmp.path()).unwrap().len(), 0);
648    }
649
650    #[test]
651    fn test_delete_snapshot_not_found() {
652        let tmp = TempDir::new().unwrap();
653        let err = delete_snapshot("no-such-snapshot", tmp.path()).unwrap_err();
654        assert!(matches!(err, AgentError::ConfigError(_)), "expected ConfigError for missing snapshot, got {:?}", err);
655    }
656
657    // ── SessionContext base_dir test ──
658
659    #[test]
660    fn test_session_context_stores_base_dir() {
661        let tmp = TempDir::new().unwrap();
662        let ctx = resolve_session(Some("base-test"), tmp.path()).unwrap();
663        assert_eq!(ctx.base_dir, tmp.path());
664        assert!(ctx.session_dir.starts_with(&ctx.base_dir));
665    }
666}