Skip to main content

mermaid_cli/session/
conversation.rs

1use crate::domain::CompactionArchive;
2use crate::models::{ChatMessage, MessageRole};
3use anyhow::Result;
4use chrono::{DateTime, Local};
5use serde::{Deserialize, Serialize};
6use std::collections::VecDeque;
7use std::fs;
8use std::path::{Path, PathBuf};
9
10/// Reject a conversation id that doesn't match the generated shape
11/// (`%Y%m%d_%H%M%S_%3f` => `YYYYMMDD_HHMMSS_mmm`). Without this, a
12/// user-typed `/load <id>` (or `delete`) joins arbitrary text into a
13/// filesystem path — `../../secret` would read/delete files outside the
14/// project. Digits-and-underscores can't contain `/`, `\`, `..`, or a drive
15/// prefix, so the format check alone closes the traversal.
16fn validate_conversation_id(id: &str) -> Result<()> {
17    let valid = id.len() == 19
18        && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
19            8 | 15 => *b == b'_',
20            _ => b.is_ascii_digit(),
21        });
22    anyhow::ensure!(valid, "invalid conversation id: {id:?}");
23    Ok(())
24}
25
26/// Marker left in a message's text when its screenshot bytes are dropped on save.
27const SCREENSHOT_ELIDED_MARKER: &str = "\n[screenshot not persisted]";
28
29/// Return a sanitized copy of `messages` with computer-use screenshot bytes
30/// removed before they reach durable storage (#99). Screenshots — which can
31/// capture on-screen secrets — attach to **non-User** messages (the assistant
32/// message the capture is routed onto, or a tool outcome); user-supplied
33/// multimodal images attach to **User** messages and are intentional content,
34/// so they're preserved. The live in-memory conversation is untouched (this
35/// runs on a copy at the save chokepoint), so the chat and model context still
36/// see the screenshot for the session — only the on-disk copy is scrubbed.
37///
38/// Returns `None` when nothing needed stripping, so the hot save path avoids a
39/// clone in the common (no-screenshot) case.
40fn strip_persisted_screenshots(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
41    let needs = messages
42        .iter()
43        .any(|m| m.role != MessageRole::User && m.images.is_some());
44    if !needs {
45        return None;
46    }
47    let mut out = messages.to_vec();
48    for m in out.iter_mut() {
49        if m.role != MessageRole::User && m.images.is_some() {
50            m.images = None;
51            if !m.content.ends_with(SCREENSHOT_ELIDED_MARKER) {
52                m.content.push_str(SCREENSHOT_ELIDED_MARKER);
53            }
54        }
55    }
56    Some(out)
57}
58
59/// A complete conversation history
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct ConversationHistory {
62    pub id: String,
63    pub title: String,
64    pub messages: Vec<ChatMessage>,
65    pub model_name: String,
66    pub project_path: String,
67    pub created_at: DateTime<Local>,
68    pub updated_at: DateTime<Local>,
69    pub total_tokens: Option<usize>,
70    /// Metadata for context compactions performed in this conversation.
71    #[serde(default)]
72    pub compactions: Vec<crate::domain::CompactionRecord>,
73    /// History of user input prompts for navigation (up/down arrows)
74    #[serde(default)]
75    pub input_history: VecDeque<String>,
76}
77
78impl ConversationHistory {
79    /// Create a new conversation history
80    pub fn new(project_path: String, model_name: String) -> Self {
81        let now = Local::now();
82        // Include subsecond precision to avoid ID collisions within the same second
83        let id = format!("{}", now.format("%Y%m%d_%H%M%S_%3f"));
84        Self {
85            id: id.clone(),
86            title: format!("Session {}", now.format("%Y-%m-%d %H:%M")),
87            messages: Vec::new(),
88            model_name,
89            project_path,
90            created_at: now,
91            updated_at: now,
92            total_tokens: None,
93            compactions: Vec::new(),
94            input_history: VecDeque::new(),
95        }
96    }
97
98    /// Add messages to the conversation
99    pub fn add_messages(&mut self, messages: &[ChatMessage]) {
100        self.messages.extend_from_slice(messages);
101        self.updated_at = Local::now();
102        self.update_title();
103    }
104
105    /// Replace the model-visible message log without deriving a new title.
106    /// Used by context compaction: the original title still describes the
107    /// session better than the generated checkpoint.
108    pub fn replace_messages(&mut self, messages: Vec<ChatMessage>) {
109        self.messages = messages;
110        self.updated_at = Local::now();
111    }
112
113    /// Record a completed context compaction.
114    pub fn add_compaction(&mut self, record: crate::domain::CompactionRecord) {
115        self.compactions.push(record);
116        self.updated_at = Local::now();
117    }
118
119    /// Add input to history (with deduplication of consecutive identical inputs)
120    pub fn add_to_input_history(&mut self, input: String) {
121        // Skip empty inputs
122        if input.trim().is_empty() {
123            return;
124        }
125
126        // Don't add if it's identical to the last entry
127        if let Some(last) = self.input_history.back()
128            && last == &input
129        {
130            return;
131        }
132
133        // Cap history at 100 entries to prevent unbounded growth
134        if self.input_history.len() >= 100 {
135            self.input_history.pop_front(); // O(1) instead of O(n)
136        }
137
138        self.input_history.push_back(input);
139    }
140
141    /// Update the title based on the first user message.
142    /// Short-circuits if the title was already derived from a user message.
143    fn update_title(&mut self) {
144        // Only set title once — it comes from the first user message
145        if !self.title.starts_with("Session ") {
146            return;
147        }
148        if let Some(first_user_msg) = self.messages.iter().find(|m| m.role == MessageRole::User) {
149            let preview = if first_user_msg.content.len() > 60 {
150                let end = first_user_msg.content.floor_char_boundary(60);
151                format!("{}...", &first_user_msg.content[..end])
152            } else {
153                first_user_msg.content.clone()
154            };
155            self.title = preview;
156        }
157    }
158
159    /// Get a summary for display
160    pub fn summary(&self) -> String {
161        let message_count = self.messages.len();
162        let duration = self.updated_at.signed_duration_since(self.created_at);
163        let hours = duration.num_hours();
164        let minutes = duration.num_minutes() % 60;
165
166        format!(
167            "{} | {} messages | {}h {}m | {}",
168            self.updated_at.format("%Y-%m-%d %H:%M"),
169            message_count,
170            hours,
171            minutes,
172            self.title
173        )
174    }
175}
176
177/// Manages conversation persistence for a project
178#[derive(Clone)]
179pub struct ConversationManager {
180    conversations_dir: PathBuf,
181    compactions_dir: PathBuf,
182}
183
184impl ConversationManager {
185    /// Create a new conversation manager for a project directory
186    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
187        let mermaid_dir = project_dir.as_ref().join(".mermaid");
188        let conversations_dir = mermaid_dir.join("conversations");
189        let compactions_dir = mermaid_dir.join("compactions");
190
191        // Create conversations directory if it doesn't exist
192        fs::create_dir_all(&conversations_dir)?;
193        fs::create_dir_all(&compactions_dir)?;
194
195        Ok(Self {
196            conversations_dir,
197            compactions_dir,
198        })
199    }
200
201    /// Save a conversation to disk
202    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
203        // The id field is persisted and round-trips through (potentially
204        // tampered) on-disk state; validate it before it drives the write path,
205        // so a loaded conversation can't escape the conversations dir on save.
206        validate_conversation_id(&conversation.id)?;
207        let filename = format!("{}.json", conversation.id);
208        let path = self.conversations_dir.join(filename);
209
210        // Strip computer-use screenshot bytes before they hit disk (#99). Only
211        // clones the conversation when there is actually something to scrub.
212        let json = match strip_persisted_screenshots(&conversation.messages) {
213            Some(sanitized) => {
214                let mut redacted = conversation.clone();
215                redacted.messages = sanitized;
216                serde_json::to_string_pretty(&redacted)?
217            },
218            None => serde_json::to_string_pretty(conversation)?,
219        };
220        // Atomic write: a crash mid-save must not empty/corrupt the session
221        // file (this is the hot path, rewritten after nearly every message).
222        crate::runtime::write_atomic(&path, json.as_bytes())?;
223
224        Ok(())
225    }
226
227    /// Save the raw messages removed by a compaction. Archives live
228    /// outside the hot conversation JSON so `/load` and `/list` don't
229    /// parse old transcripts on every startup.
230    pub fn save_compaction_archive(&self, archive: &CompactionArchive) -> Result<PathBuf> {
231        // Both the conversation id (a directory component) and the archive id
232        // (a file component) come from persisted state and must not traverse.
233        validate_conversation_id(&archive.conversation_id)?;
234        anyhow::ensure!(
235            !archive.id.is_empty()
236                && !archive.id.contains(['/', '\\'])
237                && !archive.id.contains(".."),
238            "invalid compaction archive id: {:?}",
239            archive.id
240        );
241        let dir = self.compactions_dir.join(&archive.conversation_id);
242        fs::create_dir_all(&dir)?;
243        let path = dir.join(format!("{}.json", archive.id));
244        // The archive is the only durable copy of compacted-out messages; scrub
245        // screenshot bytes here too so they don't survive in compaction archives
246        // (#99). Clones only when a screenshot is actually present.
247        let json = match strip_persisted_screenshots(&archive.messages) {
248            Some(sanitized) => {
249                let mut redacted = archive.clone();
250                redacted.messages = sanitized;
251                serde_json::to_string_pretty(&redacted)?
252            },
253            None => serde_json::to_string_pretty(archive)?,
254        };
255        // Atomic write: the archive is the ONLY durable copy of messages
256        // dropped by a compaction — a partial write would lose them.
257        crate::runtime::write_atomic(&path, json.as_bytes())?;
258        Ok(path)
259    }
260
261    /// Load a specific conversation by ID
262    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
263        validate_conversation_id(id)?;
264        let filename = format!("{}.json", id);
265        let path = self.conversations_dir.join(filename);
266
267        let json = fs::read_to_string(path)?;
268        let conversation: ConversationHistory = serde_json::from_str(&json)?;
269        // The file name was validated, but the deserialized `id` (which drives
270        // later saves) is independent on-disk state — validate it too.
271        validate_conversation_id(&conversation.id)?;
272
273        Ok(conversation)
274    }
275
276    /// Load the most recent *valid* conversation.
277    ///
278    /// Iterates files newest-first by mtime and returns the first that reads,
279    /// parses, and has a valid id — skipping (with a warning) any unreadable,
280    /// unparseable, or traversing-id file. Mirrors `list_conversations`'s
281    /// tolerance so one corrupt/partial file (e.g. a crash mid-write) can't make
282    /// `--continue` hard-fail; it falls back to the next-newest valid conversation.
283    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
284        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
285            return Ok(None);
286        };
287
288        let mut candidates: Vec<(std::time::SystemTime, PathBuf)> = entries
289            .flatten()
290            .filter(|e| e.path().extension().is_some_and(|x| x == "json"))
291            .filter_map(|e| {
292                let mtime = e.metadata().ok()?.modified().ok()?;
293                Some((mtime, e.path()))
294            })
295            .collect();
296        candidates.sort_by_key(|(mtime, _)| std::cmp::Reverse(*mtime));
297
298        for (_, path) in candidates {
299            let Ok(json) = fs::read_to_string(&path) else {
300                tracing::warn!(path = %path.display(), "skipping unreadable conversation file");
301                continue;
302            };
303            let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json) else {
304                tracing::warn!(path = %path.display(), "skipping unparseable conversation file");
305                continue;
306            };
307            // A planted session file with a traversing `id` must not become the
308            // resumed conversation (its id would later drive an out-of-dir save).
309            if validate_conversation_id(&conv.id).is_err() {
310                tracing::warn!(path = %path.display(), id = %conv.id, "skipping conversation with invalid id");
311                continue;
312            }
313            return Ok(Some(conv));
314        }
315        Ok(None)
316    }
317
318    /// List all conversations in the project
319    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
320        let mut conversations = Vec::new();
321
322        // Read all JSON files in the conversations directory
323        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
324            for entry in entries.flatten() {
325                if let Some(ext) = entry.path().extension()
326                    && ext == "json"
327                    && let Ok(json) = fs::read_to_string(entry.path())
328                    && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
329                {
330                    conversations.push(conv);
331                }
332            }
333        }
334
335        // Sort by updated_at (newest first)
336        conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
337
338        Ok(conversations)
339    }
340
341    /// Delete a conversation
342    pub fn delete_conversation(&self, id: &str) -> Result<()> {
343        validate_conversation_id(id)?;
344        let filename = format!("{}.json", id);
345        let path = self.conversations_dir.join(filename);
346
347        if path.exists() {
348            fs::remove_file(path)?;
349        }
350
351        Ok(())
352    }
353
354    /// Get the conversations directory path
355    pub fn conversations_dir(&self) -> &Path {
356        &self.conversations_dir
357    }
358
359    pub fn compactions_dir(&self) -> &Path {
360        &self.compactions_dir
361    }
362}
363
364#[cfg(test)]
365mod tests {
366    use super::*;
367
368    #[test]
369    fn validate_conversation_id_rejects_traversal() {
370        assert!(validate_conversation_id("20260101_120000_001").is_ok());
371        assert!(validate_conversation_id("../secret").is_err());
372        assert!(validate_conversation_id("..\\secret").is_err());
373        assert!(validate_conversation_id("/etc/passwd").is_err());
374        assert!(validate_conversation_id("20260101_120000").is_err()); // too short
375        assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); // non-digits
376    }
377
378    #[test]
379    fn strip_persisted_screenshots_drops_assistant_images_keeps_user_images() {
380        let messages = vec![
381            ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
382            ChatMessage::assistant("here is the screen")
383                .with_images(vec!["SCREENSHOT_B64".to_string()]),
384            ChatMessage::assistant("no image here"),
385        ];
386        let sanitized = strip_persisted_screenshots(&messages).expect("had a screenshot to strip");
387        // User-supplied image preserved.
388        assert_eq!(
389            sanitized[0].images.as_deref(),
390            Some(["USER_PASTED_B64".to_string()].as_slice())
391        );
392        // Assistant screenshot dropped + marker added.
393        assert!(sanitized[1].images.is_none());
394        assert!(sanitized[1].content.ends_with(SCREENSHOT_ELIDED_MARKER));
395        // Untouched assistant message is unchanged (no spurious marker).
396        assert!(!sanitized[2].content.ends_with(SCREENSHOT_ELIDED_MARKER));
397    }
398
399    #[test]
400    fn strip_persisted_screenshots_is_none_without_assistant_images() {
401        let messages = vec![
402            ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
403            ChatMessage::assistant("no images"),
404        ];
405        assert!(strip_persisted_screenshots(&messages).is_none());
406    }
407
408    #[test]
409    fn saved_conversation_json_has_no_screenshot_bytes() {
410        let dir = std::env::temp_dir().join("mermaid_strip_test");
411        let _ = fs::create_dir_all(&dir);
412        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into());
413        conv.messages = vec![
414            ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
415            ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
416        ];
417        let store = ConversationManager {
418            conversations_dir: dir.clone(),
419            compactions_dir: dir.clone(),
420        };
421        store.save_conversation(&conv).expect("save");
422        let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
423        assert!(
424            !raw.contains("SHOTBYTES"),
425            "screenshot leaked to disk: {raw}"
426        );
427        assert!(raw.contains("USERIMG"), "user image should persist");
428        // Live conversation untouched — still carries the screenshot in-session.
429        assert_eq!(
430            conv.messages[1].images.as_deref(),
431            Some(["SHOTBYTES".to_string()].as_slice())
432        );
433        let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
434    }
435
436    #[test]
437    fn test_new_conversation_has_session_title() {
438        let conv = ConversationHistory::new("/tmp/project".into(), "test-model".into());
439        assert!(conv.title.starts_with("Session "));
440        assert_eq!(conv.model_name, "test-model");
441        assert_eq!(conv.project_path, "/tmp/project");
442        assert!(conv.messages.is_empty());
443    }
444
445    #[test]
446    fn test_title_updates_from_first_user_message() {
447        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
448        conv.add_messages(&[ChatMessage::user("Fix the login bug")]);
449        assert_eq!(conv.title, "Fix the login bug");
450    }
451
452    #[test]
453    fn test_title_truncated_at_60_chars() {
454        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
455        let long_msg = "a".repeat(100);
456        conv.add_messages(&[ChatMessage::user(long_msg)]);
457        assert!(conv.title.ends_with("..."));
458        assert!(conv.title.len() <= 64); // 60 chars + "..."
459    }
460
461    #[test]
462    fn test_title_set_only_once() {
463        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
464        conv.add_messages(&[ChatMessage::user("First message")]);
465        conv.add_messages(&[ChatMessage::user("Second message")]);
466        assert_eq!(conv.title, "First message");
467    }
468
469    #[test]
470    fn test_input_history_deduplication() {
471        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
472        conv.add_to_input_history("hello".into());
473        conv.add_to_input_history("hello".into()); // duplicate
474        conv.add_to_input_history("world".into());
475        assert_eq!(conv.input_history.len(), 2);
476    }
477
478    #[test]
479    fn test_input_history_skips_empty() {
480        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
481        conv.add_to_input_history("".into());
482        conv.add_to_input_history("   ".into());
483        assert_eq!(conv.input_history.len(), 0);
484    }
485
486    #[test]
487    fn test_input_history_capped_at_100() {
488        let mut conv = ConversationHistory::new("/tmp".into(), "m".into());
489        for i in 0..110 {
490            conv.add_to_input_history(format!("msg{}", i));
491        }
492        assert_eq!(conv.input_history.len(), 100);
493        assert_eq!(conv.input_history.front().unwrap(), "msg10");
494    }
495
496    #[test]
497    fn test_save_load_roundtrip() {
498        let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
499        let _ = fs::remove_dir_all(&dir);
500        let manager = ConversationManager::new(&dir).unwrap();
501
502        let mut conv = ConversationHistory::new("/tmp".into(), "model".into());
503        conv.add_messages(&[ChatMessage::user("test message")]);
504        conv.add_to_input_history("test message".into());
505
506        manager.save_conversation(&conv).unwrap();
507        let loaded = manager.load_conversation(&conv.id).unwrap();
508
509        assert_eq!(loaded.id, conv.id);
510        assert_eq!(loaded.title, conv.title);
511        assert_eq!(loaded.messages.len(), 1);
512        assert_eq!(loaded.input_history.len(), 1);
513
514        let _ = fs::remove_dir_all(&dir);
515    }
516
517    #[test]
518    fn test_list_conversations_ordered_by_updated_at() {
519        let dir = std::env::temp_dir().join("mermaid_test_conv_list");
520        let _ = fs::remove_dir_all(&dir);
521        let manager = ConversationManager::new(&dir).unwrap();
522
523        let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
524        std::thread::sleep(std::time::Duration::from_millis(10));
525        let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
526
527        manager.save_conversation(&conv1).unwrap();
528        manager.save_conversation(&conv2).unwrap();
529
530        let list = manager.list_conversations().unwrap();
531        assert_eq!(list.len(), 2);
532        // Newest first
533        assert_eq!(list[0].id, conv2.id);
534        assert_eq!(list[1].id, conv1.id);
535
536        let _ = fs::remove_dir_all(&dir);
537    }
538
539    #[test]
540    fn test_load_last_conversation() {
541        let dir = std::env::temp_dir().join("mermaid_test_conv_last");
542        let _ = fs::remove_dir_all(&dir);
543        let manager = ConversationManager::new(&dir).unwrap();
544
545        assert!(manager.load_last_conversation().unwrap().is_none());
546
547        let conv = ConversationHistory::new("/tmp".into(), "m".into());
548        manager.save_conversation(&conv).unwrap();
549
550        let last = manager.load_last_conversation().unwrap().unwrap();
551        assert_eq!(last.id, conv.id);
552
553        let _ = fs::remove_dir_all(&dir);
554    }
555
556    #[test]
557    fn test_load_last_conversation_picks_newest_by_mtime() {
558        // Writes three conversations with staggered mtimes (via sleeps
559        // between saves) and asserts the mtime-based picker returns the
560        // last one written — even though filename-alphabetical ordering
561        // would pick a different file.
562        let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
563        let _ = fs::remove_dir_all(&dir);
564        let manager = ConversationManager::new(&dir).unwrap();
565
566        let conv1 = ConversationHistory::new("/tmp".into(), "m".into());
567        manager.save_conversation(&conv1).unwrap();
568        std::thread::sleep(std::time::Duration::from_millis(10));
569
570        let conv2 = ConversationHistory::new("/tmp".into(), "m".into());
571        manager.save_conversation(&conv2).unwrap();
572        std::thread::sleep(std::time::Duration::from_millis(10));
573
574        let conv3 = ConversationHistory::new("/tmp".into(), "m".into());
575        manager.save_conversation(&conv3).unwrap();
576
577        let last = manager.load_last_conversation().unwrap().unwrap();
578        assert_eq!(
579            last.id, conv3.id,
580            "should return the most-recently-written file"
581        );
582
583        let _ = fs::remove_dir_all(&dir);
584    }
585
586    #[test]
587    fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
588        let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
589        let _ = fs::remove_dir_all(&dir);
590        let manager = ConversationManager::new(&dir).unwrap();
591
592        let good = ConversationHistory::new("/tmp".into(), "m".into());
593        manager.save_conversation(&good).unwrap();
594        std::thread::sleep(std::time::Duration::from_millis(10));
595
596        // Plant a NEWER, corrupt file (well-formed name, garbage contents): the
597        // newest-by-mtime entry is unparseable, so #68 must skip it.
598        let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
599        fs::write(&corrupt, b"{ not valid json").unwrap();
600
601        let last = manager.load_last_conversation().unwrap().unwrap();
602        assert_eq!(
603            last.id, good.id,
604            "must fall back to the newest VALID conversation"
605        );
606        let _ = fs::remove_dir_all(&dir);
607    }
608
609    #[test]
610    fn load_last_conversation_none_when_only_corrupt() {
611        let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
612        let _ = fs::remove_dir_all(&dir);
613        let manager = ConversationManager::new(&dir).unwrap();
614        fs::write(
615            manager.conversations_dir().join("20991231_235959_998.json"),
616            b"nope",
617        )
618        .unwrap();
619        assert!(manager.load_last_conversation().unwrap().is_none());
620        let _ = fs::remove_dir_all(&dir);
621    }
622
623    #[test]
624    fn test_delete_conversation() {
625        let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
626        let _ = fs::remove_dir_all(&dir);
627        let manager = ConversationManager::new(&dir).unwrap();
628
629        let conv = ConversationHistory::new("/tmp".into(), "m".into());
630        manager.save_conversation(&conv).unwrap();
631        assert_eq!(manager.list_conversations().unwrap().len(), 1);
632
633        manager.delete_conversation(&conv.id).unwrap();
634        assert_eq!(manager.list_conversations().unwrap().len(), 0);
635
636        let _ = fs::remove_dir_all(&dir);
637    }
638}