Skip to main content

clawft_core/
session.rs

1//! Session management for conversation persistence.
2//!
3//! Provides [`SessionManager`] which caches active sessions in memory and
4//! persists them to disk as JSONL files using the platform filesystem
5//! abstraction. Each JSONL file has a metadata header line followed by
6//! one line per conversation turn.
7//!
8//! Ported from Python `nanobot/session/manager.py`.
9
10use std::collections::HashMap;
11use std::path::PathBuf;
12use std::sync::Arc;
13
14use chrono::Utc;
15use percent_encoding::{percent_decode_str, percent_encode, NON_ALPHANUMERIC};
16use crate::runtime::Mutex;
17use tracing::{debug, warn};
18
19use clawft_platform::Platform;
20use clawft_types::error::ClawftError;
21use clawft_types::session::Session;
22
23/// Manages conversation sessions with in-memory caching and JSONL persistence.
24///
25/// Sessions are identified by a string key (typically `"{channel}:{chat_id}"`).
26/// The manager uses a write-through cache: reads check the in-memory cache
27/// first, then fall back to loading from disk. Writes update both the cache
28/// and the JSONL file.
29///
30/// # JSONL format
31///
32/// Each session file is named `{sanitized_key}.jsonl` and contains:
33/// - Line 1: metadata object with `_type`, `created_at`, `updated_at`,
34///   `metadata`, and `last_consolidated` fields.
35/// - Lines 2+: message objects with `role`, `content`, and `timestamp` fields.
36///
37/// # Platform abstraction
38///
39/// All filesystem I/O goes through the [`Platform::fs()`] trait, making
40/// SessionManager testable with mock filesystems and WASM-portable.
41pub struct SessionManager<P: Platform> {
42    /// Directory where JSONL session files are stored.
43    sessions_dir: PathBuf,
44
45    /// In-memory cache of active sessions.
46    active_sessions: Arc<Mutex<HashMap<String, Session>>>,
47
48    /// Platform providing filesystem access.
49    platform: Arc<P>,
50}
51
52impl<P: Platform> SessionManager<P> {
53    /// Create a new session manager using the given platform.
54    ///
55    /// Discovers the sessions directory by checking:
56    /// 1. `~/.clawft/workspace/sessions/`
57    /// 2. `~/.nanobot/workspace/sessions/` (legacy fallback)
58    ///
59    /// If neither exists, defaults to `~/.clawft/workspace/sessions/` and
60    /// creates it. Returns an error if the home directory cannot be determined.
61    pub async fn new(platform: Arc<P>) -> clawft_types::Result<Self> {
62        let home = platform
63            .fs()
64            .home_dir()
65            .ok_or_else(|| ClawftError::ConfigInvalid {
66                reason: "cannot determine home directory".into(),
67            })?;
68
69        let clawft_dir = home.join(".clawft").join("workspace").join("sessions");
70        let nanobot_dir = home.join(".nanobot").join("workspace").join("sessions");
71
72        let sessions_dir = if platform.fs().exists(&clawft_dir).await {
73            debug!(path = %clawft_dir.display(), "using clawft sessions dir");
74            clawft_dir
75        } else if platform.fs().exists(&nanobot_dir).await {
76            debug!(path = %nanobot_dir.display(), "using nanobot sessions dir (fallback)");
77            nanobot_dir
78        } else {
79            debug!(
80                path = %clawft_dir.display(),
81                "sessions dir does not exist, creating"
82            );
83            platform
84                .fs()
85                .create_dir_all(&clawft_dir)
86                .await
87                .map_err(ClawftError::Io)?;
88            clawft_dir
89        };
90
91        Ok(Self {
92            sessions_dir,
93            active_sessions: Arc::new(Mutex::new(HashMap::new())),
94            platform,
95        })
96    }
97
98    /// Create a session manager with an explicit sessions directory.
99    ///
100    /// Useful for testing or when the directory is already known.
101    pub fn with_dir(platform: Arc<P>, sessions_dir: PathBuf) -> Self {
102        Self {
103            sessions_dir,
104            active_sessions: Arc::new(Mutex::new(HashMap::new())),
105            platform,
106        }
107    }
108
109    /// Get an existing session or create a new one.
110    ///
111    /// Checks the in-memory cache first, then attempts to load from disk.
112    /// If neither succeeds, creates a fresh empty session and caches it.
113    ///
114    /// # Errors
115    ///
116    /// Returns an error if `key` fails session-ID validation.
117    pub async fn get_or_create(&self, key: &str) -> clawft_types::Result<Session> {
118        crate::security::validate_session_id(key)?;
119
120        // Check cache first.
121        {
122            let cache = self.active_sessions.lock().await;
123            if let Some(session) = cache.get(key) {
124                return Ok(session.clone());
125            }
126        }
127
128        // Try loading from disk.
129        if let Ok(session) = self.load_session(key).await {
130            let mut cache = self.active_sessions.lock().await;
131            cache.insert(key.to_string(), session.clone());
132            return Ok(session);
133        }
134
135        // Create new session.
136        let session = Session::new(key);
137        let mut cache = self.active_sessions.lock().await;
138        cache.insert(key.to_string(), session.clone());
139        Ok(session)
140    }
141
142    /// Load a session from its JSONL file on disk.
143    ///
144    /// Parses the first line as metadata and remaining lines as messages.
145    /// Returns an error if the file does not exist or contains invalid JSON.
146    ///
147    /// Includes a migration path: if the percent-encoded file does not exist
148    /// but the old underscore-encoded file does, the content is copied to the
149    /// new filename (the old file is preserved for safety).
150    pub async fn load_session(&self, key: &str) -> clawft_types::Result<Session> {
151        crate::security::validate_session_id(key)?;
152        let path = self.session_path(key);
153
154        // Migration: try old-format filename if new-format doesn't exist
155        if !self.platform.fs().exists(&path).await {
156            let old_filename = format!("{}.jsonl", key.replace(':', "_"));
157            let old_path = self.sessions_dir.join(&old_filename);
158            if self.platform.fs().exists(&old_path).await {
159                warn!(
160                    key = key,
161                    old = %old_path.display(),
162                    new = %path.display(),
163                    "migrating session file from old encoding format"
164                );
165                // Read from old, write to new, keep old for safety
166                let content = self.platform.fs().read_to_string(&old_path).await?;
167                self.platform.fs().write_string(&path, &content).await?;
168            }
169        }
170
171        let content = self.platform.fs().read_to_string(&path).await?;
172
173        let mut lines = content.lines();
174
175        // Parse metadata line.
176        let meta_line = lines.next().ok_or_else(|| ClawftError::ConfigInvalid {
177            reason: format!("session file is empty: {}", path.display()),
178        })?;
179
180        let meta: serde_json::Value = serde_json::from_str(meta_line)?;
181
182        let created_at = meta
183            .get("created_at")
184            .and_then(|v| v.as_str())
185            .and_then(|s| s.parse().ok())
186            .unwrap_or_else(Utc::now);
187
188        let updated_at = meta
189            .get("updated_at")
190            .and_then(|v| v.as_str())
191            .and_then(|s| s.parse().ok())
192            .unwrap_or_else(Utc::now);
193
194        let metadata: HashMap<String, serde_json::Value> = meta
195            .get("metadata")
196            .and_then(|v| serde_json::from_value(v.clone()).ok())
197            .unwrap_or_default();
198
199        let last_consolidated = meta
200            .get("last_consolidated")
201            .and_then(|v| v.as_u64())
202            .unwrap_or(0) as usize;
203
204        // Parse message lines.
205        let mut messages = Vec::new();
206        for line in lines {
207            let trimmed = line.trim();
208            if trimmed.is_empty() {
209                continue;
210            }
211            match serde_json::from_str::<serde_json::Value>(trimmed) {
212                Ok(msg) => messages.push(msg),
213                Err(e) => {
214                    warn!(
215                        key = key,
216                        error = %e,
217                        "skipping malformed message line in session"
218                    );
219                }
220            }
221        }
222
223        debug!(
224            key = key,
225            messages = messages.len(),
226            "loaded session from disk"
227        );
228
229        Ok(Session {
230            key: key.to_string(),
231            messages,
232            created_at,
233            updated_at,
234            metadata,
235            last_consolidated,
236        })
237    }
238
239    /// Save a session to its JSONL file on disk.
240    ///
241    /// Writes the full session: metadata line followed by all message lines.
242    /// Also updates the in-memory cache.
243    pub async fn save_session(&self, session: &Session) -> clawft_types::Result<()> {
244        let path = self.session_path(&session.key);
245
246        let meta = serde_json::json!({
247            "_type": "metadata",
248            "created_at": session.created_at.to_rfc3339(),
249            "updated_at": session.updated_at.to_rfc3339(),
250            "metadata": session.metadata,
251            "last_consolidated": session.last_consolidated,
252        });
253
254        let mut content = serde_json::to_string(&meta).map_err(ClawftError::Json)?;
255        content.push('\n');
256
257        for msg in &session.messages {
258            content.push_str(&serde_json::to_string(msg).map_err(ClawftError::Json)?);
259            content.push('\n');
260        }
261
262        self.platform.fs().write_string(&path, &content).await?;
263
264        // Update cache.
265        let mut cache = self.active_sessions.lock().await;
266        cache.insert(session.key.clone(), session.clone());
267
268        debug!(key = %session.key, "saved session to disk");
269
270        Ok(())
271    }
272
273    /// Append a single conversation turn to a session.
274    ///
275    /// Updates both the in-memory cache and appends to the JSONL file on disk.
276    /// If the session does not exist yet, it is created via [`get_or_create`].
277    pub async fn append_turn(
278        &self,
279        key: &str,
280        role: &str,
281        content: &str,
282    ) -> clawft_types::Result<()> {
283        crate::security::validate_session_id(key)?;
284        let mut session = self.get_or_create(key).await?;
285        session.add_message(role, content, None);
286
287        // Append message line to file.
288        let msg = serde_json::json!({
289            "role": role,
290            "content": content,
291            "timestamp": Utc::now().to_rfc3339(),
292        });
293        let mut line = serde_json::to_string(&msg).map_err(ClawftError::Json)?;
294        line.push('\n');
295
296        let path = self.session_path(key);
297
298        // If the file does not exist yet, write the full session (with metadata).
299        if !self.platform.fs().exists(&path).await {
300            self.save_session(&session).await?;
301        } else {
302            self.platform.fs().append_string(&path, &line).await?;
303            // Update cache.
304            let mut cache = self.active_sessions.lock().await;
305            cache.insert(key.to_string(), session);
306        }
307
308        Ok(())
309    }
310
311    /// List all session keys (derived from `.jsonl` filenames on disk).
312    ///
313    /// Decodes percent-encoded filenames back to the original session key.
314    /// Files that cannot be decoded as valid UTF-8 are skipped with a warning.
315    pub async fn list_sessions(&self) -> clawft_types::Result<Vec<String>> {
316        let entries = self
317            .platform
318            .fs()
319            .list_dir(&self.sessions_dir)
320            .await
321            .map_err(ClawftError::Io)?;
322
323        let mut keys = Vec::new();
324        for entry in entries {
325            if let Some(name) = entry.file_name() {
326                let name = name.to_string_lossy();
327                if let Some(stem) = name.strip_suffix(".jsonl") {
328                    match percent_decode_str(stem).decode_utf8() {
329                        Ok(decoded) => keys.push(decoded.into_owned()),
330                        Err(e) => {
331                            warn!(filename = %name, error = %e, "skipping undecodable session filename");
332                        }
333                    }
334                }
335            }
336        }
337
338        keys.sort();
339        Ok(keys)
340    }
341
342    /// Remove a session from the in-memory cache.
343    ///
344    /// The JSONL file on disk is not deleted; only the cached copy is
345    /// evicted. The next [`get_or_create`] call will reload from disk.
346    pub async fn invalidate(&self, key: &str) {
347        let mut cache = self.active_sessions.lock().await;
348        cache.remove(key);
349        debug!(key = key, "invalidated session cache entry");
350    }
351
352    /// Delete a session file from disk and remove from cache.
353    pub async fn delete_session(&self, key: &str) -> clawft_types::Result<()> {
354        crate::security::validate_session_id(key)?;
355        let path = self.session_path(key);
356        if self.platform.fs().exists(&path).await {
357            self.platform
358                .fs()
359                .remove_file(&path)
360                .await
361                .map_err(ClawftError::Io)?;
362        }
363        self.invalidate(key).await;
364        Ok(())
365    }
366
367    /// Get the sessions directory path.
368    pub fn sessions_dir(&self) -> &PathBuf {
369        &self.sessions_dir
370    }
371
372    /// Compute the filesystem path for a session key.
373    ///
374    /// Uses percent-encoding to safely represent any valid session key as
375    /// a filename. This is reversible: `list_sessions()` decodes back to
376    /// the original key.
377    fn session_path(&self, key: &str) -> PathBuf {
378        let encoded = percent_encode(key.as_bytes(), NON_ALPHANUMERIC).to_string();
379        let filename = format!("{encoded}.jsonl");
380        self.sessions_dir.join(filename)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387    use async_trait::async_trait;
388    use clawft_platform::fs::FileSystem;
389    use std::sync::Mutex as StdMutex;
390
391    // -- Mock platform for testing without real I/O --
392
393    /// In-memory filesystem for test isolation.
394    struct MockFs {
395        files: StdMutex<HashMap<PathBuf, String>>,
396        dirs: StdMutex<Vec<PathBuf>>,
397    }
398
399    impl MockFs {
400        fn new() -> Self {
401            Self {
402                files: StdMutex::new(HashMap::new()),
403                dirs: StdMutex::new(Vec::new()),
404            }
405        }
406    }
407
408    #[async_trait]
409    impl clawft_platform::fs::FileSystem for MockFs {
410        async fn read_to_string(&self, path: &std::path::Path) -> std::io::Result<String> {
411            let files = self.files.lock().unwrap();
412            files.get(path).cloned().ok_or_else(|| {
413                std::io::Error::new(
414                    std::io::ErrorKind::NotFound,
415                    format!("file not found: {}", path.display()),
416                )
417            })
418        }
419
420        async fn write_string(&self, path: &std::path::Path, content: &str) -> std::io::Result<()> {
421            // Create parent dirs implicitly.
422            if let Some(parent) = path.parent() {
423                let mut dirs = self.dirs.lock().unwrap();
424                if !dirs.contains(&parent.to_path_buf()) {
425                    dirs.push(parent.to_path_buf());
426                }
427            }
428            let mut files = self.files.lock().unwrap();
429            files.insert(path.to_path_buf(), content.to_string());
430            Ok(())
431        }
432
433        async fn append_string(
434            &self,
435            path: &std::path::Path,
436            content: &str,
437        ) -> std::io::Result<()> {
438            let mut files = self.files.lock().unwrap();
439            let entry = files.entry(path.to_path_buf()).or_default();
440            entry.push_str(content);
441            Ok(())
442        }
443
444        async fn exists(&self, path: &std::path::Path) -> bool {
445            let files = self.files.lock().unwrap();
446            if files.contains_key(path) {
447                return true;
448            }
449            let dirs = self.dirs.lock().unwrap();
450            dirs.contains(&path.to_path_buf())
451        }
452
453        async fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
454            let files = self.files.lock().unwrap();
455            let mut entries = Vec::new();
456            for file_path in files.keys() {
457                if file_path.parent() == Some(path) {
458                    entries.push(file_path.clone());
459                }
460            }
461            Ok(entries)
462        }
463
464        async fn create_dir_all(&self, path: &std::path::Path) -> std::io::Result<()> {
465            let mut dirs = self.dirs.lock().unwrap();
466            if !dirs.contains(&path.to_path_buf()) {
467                dirs.push(path.to_path_buf());
468            }
469            Ok(())
470        }
471
472        async fn remove_file(&self, path: &std::path::Path) -> std::io::Result<()> {
473            let mut files = self.files.lock().unwrap();
474            files.remove(path);
475            Ok(())
476        }
477
478        fn home_dir(&self) -> Option<PathBuf> {
479            Some(PathBuf::from("/mock-home"))
480        }
481    }
482
483    struct MockEnv;
484
485    impl clawft_platform::env::Environment for MockEnv {
486        fn get_var(&self, _name: &str) -> Option<String> {
487            None
488        }
489        fn set_var(&self, _name: &str, _value: &str) {}
490        fn remove_var(&self, _name: &str) {}
491    }
492
493    struct MockHttp;
494
495    #[async_trait]
496    impl clawft_platform::http::HttpClient for MockHttp {
497        async fn request(
498            &self,
499            _method: &str,
500            _url: &str,
501            _headers: &HashMap<String, String>,
502            _body: Option<&[u8]>,
503        ) -> Result<clawft_platform::http::HttpResponse, Box<dyn std::error::Error + Send + Sync>>
504        {
505            Err("MockHttp::request not implemented — use a real HTTP client for integration tests".into())
506        }
507    }
508
509    struct MockPlatform {
510        fs: MockFs,
511        env: MockEnv,
512        http: MockHttp,
513    }
514
515    impl MockPlatform {
516        fn new() -> Self {
517            Self {
518                fs: MockFs::new(),
519                env: MockEnv,
520                http: MockHttp,
521            }
522        }
523    }
524
525    #[async_trait]
526    impl Platform for MockPlatform {
527        fn http(&self) -> &dyn clawft_platform::http::HttpClient {
528            &self.http
529        }
530
531        fn fs(&self) -> &dyn clawft_platform::fs::FileSystem {
532            &self.fs
533        }
534
535        fn env(&self) -> &dyn clawft_platform::env::Environment {
536            &self.env
537        }
538
539        fn process(&self) -> Option<&dyn clawft_platform::process::ProcessSpawner> {
540            None
541        }
542    }
543
544    fn make_platform() -> Arc<MockPlatform> {
545        Arc::new(MockPlatform::new())
546    }
547
548    fn make_manager(platform: Arc<MockPlatform>) -> SessionManager<MockPlatform> {
549        let sessions_dir = PathBuf::from("/mock-home/.clawft/workspace/sessions");
550        SessionManager::with_dir(platform, sessions_dir)
551    }
552
553    #[tokio::test]
554    async fn get_or_create_new_session() {
555        let platform = make_platform();
556        let mgr = make_manager(platform);
557
558        let session = mgr.get_or_create("telegram:123").await.unwrap();
559        assert_eq!(session.key, "telegram:123");
560        assert!(session.messages.is_empty());
561    }
562
563    #[tokio::test]
564    async fn get_or_create_returns_cached() {
565        let platform = make_platform();
566        let mgr = make_manager(platform);
567
568        let session1 = mgr.get_or_create("test:key").await.unwrap();
569        let session2 = mgr.get_or_create("test:key").await.unwrap();
570        // Both should have the same creation time (cached).
571        assert_eq!(session1.created_at, session2.created_at);
572    }
573
574    #[tokio::test]
575    async fn save_and_load_roundtrip() {
576        let platform = make_platform();
577        let mgr = make_manager(platform);
578
579        let mut session = Session::new("roundtrip:test");
580        session.add_message("user", "hello world", None);
581        session.add_message("assistant", "hi there", None);
582
583        mgr.save_session(&session).await.unwrap();
584
585        // Invalidate cache to force load from disk.
586        mgr.invalidate("roundtrip:test").await;
587
588        let loaded = mgr.load_session("roundtrip:test").await.unwrap();
589        assert_eq!(loaded.key, "roundtrip:test");
590        assert_eq!(loaded.messages.len(), 2);
591        assert_eq!(loaded.messages[0]["role"], "user");
592        assert_eq!(loaded.messages[0]["content"], "hello world");
593        assert_eq!(loaded.messages[1]["role"], "assistant");
594        assert_eq!(loaded.messages[1]["content"], "hi there");
595    }
596
597    #[tokio::test]
598    async fn jsonl_format_correctness() {
599        let platform = make_platform();
600        let mgr = make_manager(platform.clone());
601
602        let mut session = Session::new("fmt:check");
603        session.add_message("user", "test", None);
604
605        mgr.save_session(&session).await.unwrap();
606
607        let path = PathBuf::from("/mock-home/.clawft/workspace/sessions/fmt%3Acheck.jsonl");
608        let content = platform.fs.read_to_string(&path).await.unwrap();
609        let lines: Vec<&str> = content.lines().collect();
610
611        // First line is metadata.
612        assert_eq!(lines.len(), 2);
613        let meta: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
614        assert_eq!(meta["_type"], "metadata");
615        assert!(meta.get("created_at").is_some());
616        assert!(meta.get("updated_at").is_some());
617        assert_eq!(meta["last_consolidated"], 0);
618
619        // Second line is the message.
620        let msg: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
621        assert_eq!(msg["role"], "user");
622        assert_eq!(msg["content"], "test");
623    }
624
625    #[tokio::test]
626    async fn append_turn_creates_session_if_needed() {
627        let platform = make_platform();
628        let mgr = make_manager(platform);
629
630        mgr.append_turn("new:session", "user", "first message")
631            .await
632            .unwrap();
633
634        let session = mgr.get_or_create("new:session").await.unwrap();
635        assert_eq!(session.messages.len(), 1);
636        assert_eq!(session.messages[0]["content"], "first message");
637    }
638
639    #[tokio::test]
640    async fn append_turn_adds_to_existing() {
641        let platform = make_platform();
642        let mgr = make_manager(platform);
643
644        // Create and save initial session.
645        let mut session = Session::new("append:test");
646        session.add_message("user", "first", None);
647        mgr.save_session(&session).await.unwrap();
648
649        // Append a second turn.
650        mgr.append_turn("append:test", "assistant", "second")
651            .await
652            .unwrap();
653
654        let loaded = mgr.get_or_create("append:test").await.unwrap();
655        assert_eq!(loaded.messages.len(), 2);
656    }
657
658    #[tokio::test]
659    async fn list_sessions_returns_keys() {
660        let platform = make_platform();
661        let mgr = make_manager(platform);
662
663        let s1 = Session::new("telegram:100");
664        let s2 = Session::new("slack:200");
665        mgr.save_session(&s1).await.unwrap();
666        mgr.save_session(&s2).await.unwrap();
667
668        let keys = mgr.list_sessions().await.unwrap();
669        assert_eq!(keys.len(), 2);
670        assert!(keys.contains(&"slack:200".to_string()));
671        assert!(keys.contains(&"telegram:100".to_string()));
672    }
673
674    #[tokio::test]
675    async fn invalidate_removes_from_cache() {
676        let platform = make_platform();
677        let mgr = make_manager(platform);
678
679        mgr.get_or_create("cache:test").await.unwrap();
680
681        // Verify it is in cache.
682        {
683            let cache = mgr.active_sessions.lock().await;
684            assert!(cache.contains_key("cache:test"));
685        }
686
687        mgr.invalidate("cache:test").await;
688
689        {
690            let cache = mgr.active_sessions.lock().await;
691            assert!(!cache.contains_key("cache:test"));
692        }
693    }
694
695    #[tokio::test]
696    async fn session_path_uses_percent_encoding() {
697        let platform = make_platform();
698        let mgr = make_manager(platform);
699
700        let path = mgr.session_path("telegram:12345");
701        assert_eq!(
702            path,
703            PathBuf::from("/mock-home/.clawft/workspace/sessions/telegram%3A12345.jsonl")
704        );
705    }
706
707    #[tokio::test]
708    async fn roundtrip_key_with_underscores() {
709        let platform = make_platform();
710        let mgr = make_manager(platform);
711
712        // Key containing underscores must survive round-trip without corruption.
713        let key = "telegram:user_123";
714        let session = Session::new(key);
715        mgr.save_session(&session).await.unwrap();
716
717        let keys = mgr.list_sessions().await.unwrap();
718        assert!(
719            keys.contains(&key.to_string()),
720            "list_sessions should contain '{key}', got: {keys:?}"
721        );
722
723        mgr.invalidate(key).await;
724        let loaded = mgr.load_session(key).await.unwrap();
725        assert_eq!(loaded.key, key);
726    }
727
728    #[tokio::test]
729    async fn roundtrip_key_with_multiple_colons() {
730        let platform = make_platform();
731        let mgr = make_manager(platform);
732
733        let key = "slack:channel:thread";
734        let session = Session::new(key);
735        mgr.save_session(&session).await.unwrap();
736
737        let keys = mgr.list_sessions().await.unwrap();
738        assert!(keys.contains(&key.to_string()));
739
740        mgr.invalidate(key).await;
741        let loaded = mgr.load_session(key).await.unwrap();
742        assert_eq!(loaded.key, key);
743    }
744
745    #[tokio::test]
746    async fn roundtrip_key_with_special_chars() {
747        let platform = make_platform();
748        let mgr = make_manager(platform);
749
750        let key = "discord:guild#channel+123";
751        let session = Session::new(key);
752        mgr.save_session(&session).await.unwrap();
753
754        let keys = mgr.list_sessions().await.unwrap();
755        assert!(keys.contains(&key.to_string()));
756
757        mgr.invalidate(key).await;
758        let loaded = mgr.load_session(key).await.unwrap();
759        assert_eq!(loaded.key, key);
760    }
761
762    #[tokio::test]
763    async fn migration_from_old_underscore_format() {
764        let platform = make_platform();
765        let mgr = make_manager(platform.clone());
766
767        // Simulate an old-format file written by the previous implementation.
768        let old_path = PathBuf::from("/mock-home/.clawft/workspace/sessions/telegram_user_123.jsonl");
769        let meta = serde_json::json!({
770            "_type": "metadata",
771            "created_at": "2025-01-01T00:00:00Z",
772            "updated_at": "2025-01-01T00:00:00Z",
773            "metadata": {},
774            "last_consolidated": 0,
775        });
776        let content = format!("{}\n", serde_json::to_string(&meta).unwrap());
777        platform.fs.write_string(&old_path, &content).await.unwrap();
778
779        // load_session should find the old file and migrate it.
780        let loaded = mgr.load_session("telegram:user_123").await.unwrap();
781        assert_eq!(loaded.key, "telegram:user_123");
782    }
783
784    #[tokio::test]
785    async fn load_nonexistent_session_returns_error() {
786        let platform = make_platform();
787        let mgr = make_manager(platform);
788
789        let result = mgr.load_session("nonexistent:key").await;
790        assert!(result.is_err());
791    }
792
793    #[tokio::test]
794    async fn save_preserves_metadata() {
795        let platform = make_platform();
796        let mgr = make_manager(platform);
797
798        let mut session = Session::new("meta:test");
799        session
800            .metadata
801            .insert("agent".into(), serde_json::json!("test-agent"));
802        session.last_consolidated = 5;
803
804        mgr.save_session(&session).await.unwrap();
805        mgr.invalidate("meta:test").await;
806
807        let loaded = mgr.load_session("meta:test").await.unwrap();
808        assert_eq!(loaded.last_consolidated, 5);
809        assert_eq!(loaded.metadata["agent"], "test-agent");
810    }
811
812    #[tokio::test]
813    async fn new_discovers_sessions_dir() {
814        let platform = make_platform();
815        // The mock home is /mock-home, and neither sessions dir exists,
816        // so `new` should create ~/.clawft/workspace/sessions/.
817        let mgr = SessionManager::new(platform).await.unwrap();
818        assert_eq!(
819            mgr.sessions_dir,
820            PathBuf::from("/mock-home/.clawft/workspace/sessions")
821        );
822    }
823
824    #[tokio::test]
825    async fn list_sessions_empty_dir() {
826        let platform = make_platform();
827        let mgr = make_manager(platform);
828
829        let keys = mgr.list_sessions().await.unwrap();
830        assert!(keys.is_empty());
831    }
832}