Skip to main content

a3s_code_core/
store.rs

1//! Session persistence layer
2//!
3//! Provides pluggable session storage via the `SessionStore` trait.
4//!
5//! ## Default Implementation
6//!
7//! `FileSessionStore` stores each session as a JSON file:
8//! - Session metadata (id, name, timestamps)
9//! - Configuration (system prompt, policies)
10//! - Conversation history (messages)
11//! - Context usage statistics
12//!
13//! ## Custom Backends
14//!
15//! Implement `SessionStore` trait for custom backends (Redis, PostgreSQL, etc.):
16//!
17//! ```ignore
18//! use a3s_code::store::{SessionStore, SessionData};
19//!
20//! struct RedisStore { /* ... */ }
21//!
22//! #[async_trait::async_trait]
23//! impl SessionStore for RedisStore {
24//!     async fn save(&self, session: &SessionData) -> Result<()> { /* ... */ }
25//!     async fn load(&self, id: &str) -> Result<Option<SessionData>> { /* ... */ }
26//!     async fn delete(&self, id: &str) -> Result<()> { /* ... */ }
27//!     async fn list(&self) -> Result<Vec<String>> { /* ... */ }
28//!     async fn exists(&self, id: &str) -> Result<bool> { /* ... */ }
29//! }
30//! ```
31
32use crate::llm::{Message, TokenUsage, ToolDefinition};
33use crate::planning::Task;
34use crate::session::{ContextUsage, SessionConfig, SessionState};
35use anyhow::{Context, Result};
36use serde::{Deserialize, Serialize};
37use std::collections::HashMap;
38use std::path::{Path, PathBuf};
39use tokio::fs;
40use tokio::io::AsyncWriteExt;
41
42// ============================================================================
43// Serializable Session Data
44// ============================================================================
45
46/// Serializable session data for persistence
47///
48/// Contains only the fields that can be serialized.
49/// Non-serializable fields (event_tx, command_queue, etc.) are rebuilt on load.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SessionData {
52    /// Session ID
53    pub id: String,
54
55    /// Session configuration
56    pub config: SessionConfig,
57
58    /// Current state
59    pub state: SessionState,
60
61    /// Conversation history
62    pub messages: Vec<Message>,
63
64    /// Context usage statistics
65    pub context_usage: ContextUsage,
66
67    /// Total token usage
68    pub total_usage: TokenUsage,
69
70    /// Cumulative dollar cost for this session
71    #[serde(default)]
72    pub total_cost: f64,
73
74    /// Model name for cost calculation
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub model_name: Option<String>,
77
78    /// LLM cost records for this session
79    #[serde(default)]
80    pub cost_records: Vec<crate::telemetry::LlmCostRecord>,
81
82    /// Tool definitions (names only, rebuilt from executor on load)
83    pub tool_names: Vec<String>,
84
85    /// Whether thinking mode is enabled
86    pub thinking_enabled: bool,
87
88    /// Thinking budget if set
89    pub thinking_budget: Option<usize>,
90
91    /// Creation timestamp (Unix epoch seconds)
92    pub created_at: i64,
93
94    /// Last update timestamp (Unix epoch seconds)
95    pub updated_at: i64,
96
97    /// LLM configuration for per-session client (if set)
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub llm_config: Option<LlmConfigData>,
100
101    /// Task list for tracking
102    #[serde(default, alias = "todos")]
103    pub tasks: Vec<Task>,
104
105    /// Parent session ID (for subagent sessions)
106    #[serde(skip_serializing_if = "Option::is_none")]
107    pub parent_id: Option<String>,
108}
109
110/// Serializable LLM configuration
111#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct LlmConfigData {
113    pub provider: String,
114    pub model: String,
115    /// API key is NOT stored - must be provided on session resume
116    #[serde(skip_serializing, default)]
117    pub api_key: Option<String>,
118    pub base_url: Option<String>,
119}
120
121impl SessionData {
122    /// Extract tool names from definitions
123    pub fn tool_names_from_definitions(tools: &[ToolDefinition]) -> Vec<String> {
124        tools.iter().map(|t| t.name.clone()).collect()
125    }
126}
127
128// ============================================================================
129// Session Store Trait
130// ============================================================================
131
132/// Session storage trait
133#[async_trait::async_trait]
134pub trait SessionStore: Send + Sync {
135    /// Save session data
136    async fn save(&self, session: &SessionData) -> Result<()>;
137
138    /// Load session data by ID
139    async fn load(&self, id: &str) -> Result<Option<SessionData>>;
140
141    /// Delete session data
142    async fn delete(&self, id: &str) -> Result<()>;
143
144    /// List all session IDs
145    async fn list(&self) -> Result<Vec<String>>;
146
147    /// Check if session exists
148    async fn exists(&self, id: &str) -> Result<bool>;
149
150    /// Health check — verify the store backend is reachable and operational
151    async fn health_check(&self) -> Result<()> {
152        Ok(())
153    }
154
155    /// Backend name for diagnostics
156    fn backend_name(&self) -> &str {
157        "unknown"
158    }
159}
160
161// ============================================================================
162// File-based Session Store
163// ============================================================================
164
165/// File-based session store
166///
167/// Stores each session as a JSON file in a directory:
168/// ```text
169/// sessions/
170///   session-1.json
171///   session-2.json
172/// ```
173pub struct FileSessionStore {
174    /// Directory to store session files
175    dir: PathBuf,
176}
177
178impl FileSessionStore {
179    /// Create a new file session store
180    ///
181    /// Creates the directory if it doesn't exist.
182    pub async fn new<P: AsRef<Path>>(dir: P) -> Result<Self> {
183        let dir = dir.as_ref().to_path_buf();
184
185        // Create directory if it doesn't exist
186        fs::create_dir_all(&dir)
187            .await
188            .with_context(|| format!("Failed to create session directory: {}", dir.display()))?;
189
190        Ok(Self { dir })
191    }
192
193    /// Get the file path for a session
194    fn session_path(&self, id: &str) -> PathBuf {
195        // Sanitize ID to prevent path traversal
196        let safe_id = id.replace(['/', '\\'], "_").replace("..", "_");
197        self.dir.join(format!("{}.json", safe_id))
198    }
199}
200
201#[async_trait::async_trait]
202impl SessionStore for FileSessionStore {
203    async fn save(&self, session: &SessionData) -> Result<()> {
204        let path = self.session_path(&session.id);
205
206        // Serialize to JSON with pretty printing for readability
207        let json = serde_json::to_string_pretty(session)
208            .with_context(|| format!("Failed to serialize session: {}", session.id))?;
209
210        // Write atomically: write to temp file with unique name, then rename
211        // Use timestamp + process ID to ensure uniqueness for concurrent saves
212        let unique_suffix = format!(
213            "{}.{}",
214            std::time::SystemTime::now()
215                .duration_since(std::time::UNIX_EPOCH)
216                .unwrap()
217                .as_nanos(),
218            std::process::id()
219        );
220        let temp_path = path.with_extension(format!("json.{}.tmp", unique_suffix));
221
222        let mut file = fs::File::create(&temp_path)
223            .await
224            .with_context(|| format!("Failed to create temp file: {}", temp_path.display()))?;
225
226        file.write_all(json.as_bytes())
227            .await
228            .with_context(|| format!("Failed to write session data: {}", session.id))?;
229
230        file.sync_all()
231            .await
232            .with_context(|| format!("Failed to sync session file: {}", session.id))?;
233
234        // Rename temp file to final path (atomic on most filesystems)
235        fs::rename(&temp_path, &path)
236            .await
237            .with_context(|| format!("Failed to rename session file: {}", session.id))?;
238
239        tracing::debug!("Saved session {} to {}", session.id, path.display());
240        Ok(())
241    }
242
243    async fn load(&self, id: &str) -> Result<Option<SessionData>> {
244        let path = self.session_path(id);
245
246        if !path.exists() {
247            return Ok(None);
248        }
249
250        let json = fs::read_to_string(&path)
251            .await
252            .with_context(|| format!("Failed to read session file: {}", path.display()))?;
253
254        let session: SessionData = serde_json::from_str(&json)
255            .with_context(|| format!("Failed to parse session file: {}", path.display()))?;
256
257        tracing::debug!("Loaded session {} from {}", id, path.display());
258        Ok(Some(session))
259    }
260
261    async fn delete(&self, id: &str) -> Result<()> {
262        let path = self.session_path(id);
263
264        if path.exists() {
265            fs::remove_file(&path)
266                .await
267                .with_context(|| format!("Failed to delete session file: {}", path.display()))?;
268
269            tracing::debug!("Deleted session {} from {}", id, path.display());
270        }
271
272        Ok(())
273    }
274
275    async fn list(&self) -> Result<Vec<String>> {
276        let mut session_ids = Vec::new();
277
278        let mut entries = fs::read_dir(&self.dir)
279            .await
280            .with_context(|| format!("Failed to read session directory: {}", self.dir.display()))?;
281
282        while let Some(entry) = entries.next_entry().await? {
283            let path = entry.path();
284
285            if path.extension().is_some_and(|ext| ext == "json") {
286                if let Some(stem) = path.file_stem() {
287                    if let Some(id) = stem.to_str() {
288                        session_ids.push(id.to_string());
289                    }
290                }
291            }
292        }
293
294        Ok(session_ids)
295    }
296
297    async fn exists(&self, id: &str) -> Result<bool> {
298        let path = self.session_path(id);
299        Ok(path.exists())
300    }
301
302    async fn health_check(&self) -> Result<()> {
303        // Verify directory exists and is writable
304        let probe = self.dir.join(".health_check");
305        fs::write(&probe, b"ok")
306            .await
307            .with_context(|| format!("Store directory not writable: {}", self.dir.display()))?;
308        let _ = fs::remove_file(&probe).await;
309        Ok(())
310    }
311
312    fn backend_name(&self) -> &str {
313        "file"
314    }
315}
316
317// ============================================================================
318// In-Memory Session Store (for testing)
319// ============================================================================
320
321/// In-memory session store for testing
322pub struct MemorySessionStore {
323    sessions: tokio::sync::RwLock<HashMap<String, SessionData>>,
324}
325
326impl MemorySessionStore {
327    pub fn new() -> Self {
328        Self {
329            sessions: tokio::sync::RwLock::new(HashMap::new()),
330        }
331    }
332}
333
334impl Default for MemorySessionStore {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340#[async_trait::async_trait]
341impl SessionStore for MemorySessionStore {
342    async fn save(&self, session: &SessionData) -> Result<()> {
343        let mut sessions = self.sessions.write().await;
344        sessions.insert(session.id.clone(), session.clone());
345        Ok(())
346    }
347
348    async fn load(&self, id: &str) -> Result<Option<SessionData>> {
349        let sessions = self.sessions.read().await;
350        Ok(sessions.get(id).cloned())
351    }
352
353    async fn delete(&self, id: &str) -> Result<()> {
354        let mut sessions = self.sessions.write().await;
355        sessions.remove(id);
356        Ok(())
357    }
358
359    async fn list(&self) -> Result<Vec<String>> {
360        let sessions = self.sessions.read().await;
361        Ok(sessions.keys().cloned().collect())
362    }
363
364    async fn exists(&self, id: &str) -> Result<bool> {
365        let sessions = self.sessions.read().await;
366        Ok(sessions.contains_key(id))
367    }
368
369    fn backend_name(&self) -> &str {
370        "memory"
371    }
372}
373
374// ============================================================================
375// Tests
376// ============================================================================
377
378#[cfg(test)]
379mod tests {
380    use super::*;
381    use crate::hitl::ConfirmationPolicy;
382    use crate::permissions::PermissionPolicy;
383    use crate::prompts::PlanningMode;
384    use crate::queue::SessionQueueConfig;
385    use tempfile::tempdir;
386
387    fn create_test_session_data() -> SessionData {
388        SessionData {
389            id: "test-session-1".to_string(),
390            config: SessionConfig {
391                name: "Test Session".to_string(),
392                workspace: "/tmp/workspace".to_string(),
393                system_prompt: Some("You are helpful.".to_string()),
394                max_context_length: 200000,
395                auto_compact: false,
396                auto_compact_threshold: crate::session::DEFAULT_AUTO_COMPACT_THRESHOLD,
397                storage_type: crate::config::StorageBackend::File,
398                queue_config: None,
399                confirmation_policy: None,
400                permission_policy: None,
401                parent_id: None,
402                security_config: None,
403                hook_engine: None,
404                planning_mode: PlanningMode::default(),
405                goal_tracking: false,
406            },
407            state: SessionState::Active,
408            messages: vec![
409                Message::user("Hello"),
410                Message {
411                    role: "assistant".to_string(),
412                    content: vec![crate::llm::ContentBlock::Text {
413                        text: "Hi there!".to_string(),
414                    }],
415                    reasoning_content: None,
416                },
417            ],
418            context_usage: ContextUsage {
419                used_tokens: 100,
420                max_tokens: 200000,
421                percent: 0.0005,
422                turns: 2,
423            },
424            total_usage: TokenUsage {
425                prompt_tokens: 50,
426                completion_tokens: 50,
427                total_tokens: 100,
428                cache_read_tokens: None,
429                cache_write_tokens: None,
430            },
431            tool_names: vec!["bash".to_string(), "read".to_string()],
432            thinking_enabled: false,
433            thinking_budget: None,
434            created_at: 1700000000,
435            updated_at: 1700000100,
436            llm_config: None,
437            tasks: vec![],
438            parent_id: None,
439            total_cost: 0.0,
440            model_name: None,
441            cost_records: Vec::new(),
442        }
443    }
444
445    // ========================================================================
446    // FileSessionStore Tests
447    // ========================================================================
448
449    #[tokio::test]
450    async fn test_file_store_save_and_load() {
451        let dir = tempdir().unwrap();
452        let store = FileSessionStore::new(dir.path()).await.unwrap();
453
454        let session = create_test_session_data();
455
456        // Save
457        store.save(&session).await.unwrap();
458
459        // Load
460        let loaded = store.load(&session.id).await.unwrap();
461        assert!(loaded.is_some());
462
463        let loaded = loaded.unwrap();
464        assert_eq!(loaded.id, session.id);
465        assert_eq!(loaded.config.name, session.config.name);
466        assert_eq!(loaded.messages.len(), 2);
467        assert_eq!(loaded.state, SessionState::Active);
468    }
469
470    #[tokio::test]
471    async fn test_file_store_load_nonexistent() {
472        let dir = tempdir().unwrap();
473        let store = FileSessionStore::new(dir.path()).await.unwrap();
474
475        let loaded = store.load("nonexistent").await.unwrap();
476        assert!(loaded.is_none());
477    }
478
479    #[tokio::test]
480    async fn test_file_store_delete() {
481        let dir = tempdir().unwrap();
482        let store = FileSessionStore::new(dir.path()).await.unwrap();
483
484        let session = create_test_session_data();
485        store.save(&session).await.unwrap();
486
487        // Verify exists
488        assert!(store.exists(&session.id).await.unwrap());
489
490        // Delete
491        store.delete(&session.id).await.unwrap();
492
493        // Verify gone
494        assert!(!store.exists(&session.id).await.unwrap());
495        assert!(store.load(&session.id).await.unwrap().is_none());
496    }
497
498    #[tokio::test]
499    async fn test_file_store_list() {
500        let dir = tempdir().unwrap();
501        let store = FileSessionStore::new(dir.path()).await.unwrap();
502
503        // Initially empty
504        let list = store.list().await.unwrap();
505        assert!(list.is_empty());
506
507        // Add sessions
508        for i in 1..=3 {
509            let mut session = create_test_session_data();
510            session.id = format!("session-{}", i);
511            store.save(&session).await.unwrap();
512        }
513
514        // List should have 3 sessions
515        let list = store.list().await.unwrap();
516        assert_eq!(list.len(), 3);
517        assert!(list.contains(&"session-1".to_string()));
518        assert!(list.contains(&"session-2".to_string()));
519        assert!(list.contains(&"session-3".to_string()));
520    }
521
522    #[tokio::test]
523    async fn test_file_store_overwrite() {
524        let dir = tempdir().unwrap();
525        let store = FileSessionStore::new(dir.path()).await.unwrap();
526
527        let mut session = create_test_session_data();
528        store.save(&session).await.unwrap();
529
530        // Modify and save again
531        session.messages.push(Message::user("Another message"));
532        session.updated_at = 1700000200;
533        store.save(&session).await.unwrap();
534
535        // Load and verify
536        let loaded = store.load(&session.id).await.unwrap().unwrap();
537        assert_eq!(loaded.messages.len(), 3);
538        assert_eq!(loaded.updated_at, 1700000200);
539    }
540
541    #[tokio::test]
542    async fn test_file_store_path_traversal_prevention() {
543        let dir = tempdir().unwrap();
544        let store = FileSessionStore::new(dir.path()).await.unwrap();
545
546        // Attempt path traversal - should be sanitized
547        let mut session = create_test_session_data();
548        session.id = "../../../etc/passwd".to_string();
549        store.save(&session).await.unwrap();
550
551        // File should be in the store directory, not /etc/passwd
552        let files: Vec<_> = std::fs::read_dir(dir.path())
553            .unwrap()
554            .filter_map(|e| e.ok())
555            .collect();
556        assert_eq!(files.len(), 1);
557
558        // Should still be loadable with sanitized ID
559        let loaded = store.load(&session.id).await.unwrap();
560        assert!(loaded.is_some());
561    }
562
563    #[tokio::test]
564    async fn test_file_store_with_policies() {
565        let dir = tempdir().unwrap();
566        let store = FileSessionStore::new(dir.path()).await.unwrap();
567
568        let mut session = create_test_session_data();
569        session.config.confirmation_policy = Some(ConfirmationPolicy::enabled());
570        session.config.permission_policy = Some(PermissionPolicy::new().allow("Bash(cargo:*)"));
571        session.config.queue_config = Some(SessionQueueConfig::default());
572
573        store.save(&session).await.unwrap();
574
575        let loaded = store.load(&session.id).await.unwrap().unwrap();
576        assert!(loaded.config.confirmation_policy.is_some());
577        assert!(loaded.config.permission_policy.is_some());
578        assert!(loaded.config.queue_config.is_some());
579    }
580
581    #[tokio::test]
582    async fn test_file_store_with_llm_config() {
583        let dir = tempdir().unwrap();
584        let store = FileSessionStore::new(dir.path()).await.unwrap();
585
586        let mut session = create_test_session_data();
587        session.llm_config = Some(LlmConfigData {
588            provider: "anthropic".to_string(),
589            model: "claude-3-5-sonnet-20241022".to_string(),
590            api_key: Some("secret".to_string()), // Should NOT be saved
591            base_url: None,
592        });
593
594        store.save(&session).await.unwrap();
595
596        let loaded = store.load(&session.id).await.unwrap().unwrap();
597        let llm_config = loaded.llm_config.unwrap();
598        assert_eq!(llm_config.provider, "anthropic");
599        assert_eq!(llm_config.model, "claude-3-5-sonnet-20241022");
600        // API key should not be persisted
601        assert!(llm_config.api_key.is_none());
602    }
603
604    // ========================================================================
605    // MemorySessionStore Tests
606    // ========================================================================
607
608    #[tokio::test]
609    async fn test_memory_store_save_and_load() {
610        let store = MemorySessionStore::new();
611        let session = create_test_session_data();
612
613        store.save(&session).await.unwrap();
614
615        let loaded = store.load(&session.id).await.unwrap();
616        assert!(loaded.is_some());
617        assert_eq!(loaded.unwrap().id, session.id);
618    }
619
620    #[tokio::test]
621    async fn test_memory_store_delete() {
622        let store = MemorySessionStore::new();
623        let session = create_test_session_data();
624
625        store.save(&session).await.unwrap();
626        assert!(store.exists(&session.id).await.unwrap());
627
628        store.delete(&session.id).await.unwrap();
629        assert!(!store.exists(&session.id).await.unwrap());
630    }
631
632    #[tokio::test]
633    async fn test_memory_store_list() {
634        let store = MemorySessionStore::new();
635
636        for i in 1..=3 {
637            let mut session = create_test_session_data();
638            session.id = format!("session-{}", i);
639            store.save(&session).await.unwrap();
640        }
641
642        let list = store.list().await.unwrap();
643        assert_eq!(list.len(), 3);
644    }
645
646    // ========================================================================
647    // SessionData Tests
648    // ========================================================================
649
650    #[test]
651    fn test_session_data_serialization() {
652        let session = create_test_session_data();
653        let json = serde_json::to_string(&session).unwrap();
654        let parsed: SessionData = serde_json::from_str(&json).unwrap();
655
656        assert_eq!(parsed.id, session.id);
657        assert_eq!(parsed.messages.len(), session.messages.len());
658    }
659
660    #[test]
661    fn test_tool_names_from_definitions() {
662        let tools = vec![
663            crate::llm::ToolDefinition {
664                name: "bash".to_string(),
665                description: "Execute bash".to_string(),
666                parameters: serde_json::json!({}),
667            },
668            crate::llm::ToolDefinition {
669                name: "read".to_string(),
670                description: "Read file".to_string(),
671                parameters: serde_json::json!({}),
672            },
673        ];
674
675        let names = SessionData::tool_names_from_definitions(&tools);
676        assert_eq!(names, vec!["bash", "read"]);
677    }
678
679    // ========================================================================
680    // Sanitization Tests
681    // ========================================================================
682
683    #[tokio::test]
684    async fn test_file_store_backslash_sanitization() {
685        let dir = tempdir().unwrap();
686        let store = FileSessionStore::new(dir.path()).await.unwrap();
687
688        let mut session = create_test_session_data();
689        session.id = r"foo\bar\baz".to_string();
690        store.save(&session).await.unwrap();
691
692        let loaded = store.load(&session.id).await.unwrap();
693        assert!(loaded.is_some());
694
695        let loaded = loaded.unwrap();
696        assert_eq!(loaded.id, session.id);
697
698        // Verify the file on disk uses sanitized name
699        let expected_path = dir.path().join("foo_bar_baz.json");
700        assert!(expected_path.exists());
701    }
702
703    #[tokio::test]
704    async fn test_file_store_mixed_separator_sanitization() {
705        let dir = tempdir().unwrap();
706        let store = FileSessionStore::new(dir.path()).await.unwrap();
707
708        let mut session = create_test_session_data();
709        session.id = r"foo/bar\baz..qux".to_string();
710        store.save(&session).await.unwrap();
711
712        let loaded = store.load(&session.id).await.unwrap();
713        assert!(loaded.is_some());
714
715        let loaded = loaded.unwrap();
716        assert_eq!(loaded.id, session.id);
717
718        // / -> _, \ -> _, .. -> _
719        let expected_path = dir.path().join("foo_bar_baz_qux.json");
720        assert!(expected_path.exists());
721    }
722
723    // ========================================================================
724    // Error Recovery Tests
725    // ========================================================================
726
727    #[tokio::test]
728    async fn test_file_store_corrupted_json_recovery() {
729        let dir = tempdir().unwrap();
730        let store = FileSessionStore::new(dir.path()).await.unwrap();
731
732        // Manually write invalid JSON to a session file
733        let corrupted_path = dir.path().join("test-id.json");
734        tokio::fs::write(&corrupted_path, b"not valid json {{{")
735            .await
736            .unwrap();
737
738        // Loading should return an error, not panic
739        let result = store.load("test-id").await;
740        assert!(result.is_err());
741    }
742
743    // ========================================================================
744    // Exists Tests
745    // ========================================================================
746
747    #[tokio::test]
748    async fn test_file_store_exists() {
749        let dir = tempdir().unwrap();
750        let store = FileSessionStore::new(dir.path()).await.unwrap();
751
752        let session = create_test_session_data();
753
754        // Not yet saved
755        assert!(!store.exists(&session.id).await.unwrap());
756
757        // Save and verify exists
758        store.save(&session).await.unwrap();
759        assert!(store.exists(&session.id).await.unwrap());
760
761        // Delete and verify gone
762        store.delete(&session.id).await.unwrap();
763        assert!(!store.exists(&session.id).await.unwrap());
764    }
765
766    #[tokio::test]
767    async fn test_memory_store_exists() {
768        let store = MemorySessionStore::new();
769
770        // Unknown id
771        assert!(!store.exists("unknown-id").await.unwrap());
772
773        // Save and verify exists
774        let session = create_test_session_data();
775        store.save(&session).await.unwrap();
776        assert!(store.exists(&session.id).await.unwrap());
777    }
778
779    #[tokio::test]
780    async fn test_file_store_health_check() {
781        let dir = tempfile::tempdir().unwrap();
782        let store = FileSessionStore::new(dir.path()).await.unwrap();
783        assert!(store.health_check().await.is_ok());
784        assert_eq!(store.backend_name(), "file");
785    }
786
787    #[tokio::test]
788    async fn test_file_store_health_check_bad_dir() {
789        let store = FileSessionStore {
790            dir: std::path::PathBuf::from("/nonexistent/path/that/does/not/exist"),
791        };
792        assert!(store.health_check().await.is_err());
793    }
794
795    #[tokio::test]
796    async fn test_memory_store_health_check() {
797        let store = MemorySessionStore::new();
798        assert!(store.health_check().await.is_ok());
799        assert_eq!(store.backend_name(), "memory");
800    }
801
802    // ========================================================================
803    // Session Resume Boundary Tests
804    // ========================================================================
805
806    #[tokio::test]
807    async fn test_file_store_load_empty_file() {
808        let dir = tempdir().unwrap();
809        let store = FileSessionStore::new(dir.path()).await.unwrap();
810
811        // Write an empty file — JSON parse must fail gracefully, not panic
812        let empty_path = dir.path().join("empty-session.json");
813        tokio::fs::write(&empty_path, b"").await.unwrap();
814
815        let result = store.load("empty-session").await;
816        assert!(
817            result.is_err(),
818            "Empty file must return error, not Ok(None)"
819        );
820    }
821
822    #[tokio::test]
823    async fn test_file_store_load_partial_json() {
824        let dir = tempdir().unwrap();
825        let store = FileSessionStore::new(dir.path()).await.unwrap();
826
827        // Truncated JSON — simulates a crash mid-write
828        let partial_path = dir.path().join("partial-session.json");
829        tokio::fs::write(&partial_path, b"{\"id\":\"partial-session\",\"message")
830            .await
831            .unwrap();
832
833        let result = store.load("partial-session").await;
834        assert!(result.is_err(), "Partial JSON must return error");
835    }
836
837    #[tokio::test]
838    async fn test_file_store_concurrent_save() {
839        let dir = tempdir().unwrap();
840        let store = std::sync::Arc::new(FileSessionStore::new(dir.path()).await.unwrap());
841
842        let session = create_test_session_data();
843        let id = session.id.clone();
844
845        // First save to create the file
846        store.save(&session).await.unwrap();
847
848        // Spawn multiple concurrent saves — last write wins, no corruption
849        let mut handles = Vec::new();
850        for _ in 0..5 {
851            let s = store.clone();
852            let sess = session.clone();
853            handles.push(tokio::spawn(async move { s.save(&sess).await }));
854        }
855        for h in handles {
856            h.await.unwrap().unwrap();
857        }
858
859        // File must be loadable after concurrent writes
860        let loaded = store.load(&id).await.unwrap();
861        assert!(loaded.is_some());
862        assert_eq!(loaded.unwrap().id, id);
863    }
864
865    #[tokio::test]
866    async fn test_file_store_load_nonexistent_returns_none() {
867        let dir = tempdir().unwrap();
868        let store = FileSessionStore::new(dir.path()).await.unwrap();
869
870        let result = store.load("does-not-exist-at-all").await.unwrap();
871        assert!(result.is_none(), "Missing session must return Ok(None)");
872    }
873}