Skip to main content

mermaid_cli/session/
conversation.rs

1use anyhow::{Context, Result};
2use chrono::{DateTime, Local};
3use mermaid_domain::ConversationHistory;
4use mermaid_model::models::{ChatMessage, MessageRole};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::SystemTime;
11
12/// Reject a conversation id that doesn't match the generated shape
13/// (`%Y%m%d_%H%M%S_%3f` => `YYYYMMDD_HHMMSS_mmm`). Without this, a
14/// user-typed `/load <id>` (or `delete`) joins arbitrary text into a
15/// filesystem path — `../../secret` would read/delete files outside the
16/// project. Digits-and-underscores can't contain `/`, `\`, `..`, or a drive
17/// prefix, so the format check alone closes the traversal.
18fn validate_conversation_id(id: &str) -> Result<()> {
19    let valid = id.len() == 19
20        && id.as_bytes().iter().enumerate().all(|(i, b)| match i {
21            8 | 15 => *b == b'_',
22            _ => b.is_ascii_digit(),
23        });
24    anyhow::ensure!(valid, "invalid conversation id: {id:?}");
25    Ok(())
26}
27
28/// Upper bound on a conversation file we'll read into memory (#129). A giant or
29/// hostile `.mermaid/conversations/*.json` (or one with an enormous `content`)
30/// would otherwise OOM the process — `--continue` walks every file. 64 MiB is
31/// far above any real transcript yet bounds the worst case.
32const MAX_CONVERSATION_BYTES: u64 = 64 * 1024 * 1024;
33
34/// Read a conversation file with the [`MAX_CONVERSATION_BYTES`] cap enforced
35/// *before* the bytes are pulled into RAM.
36fn read_conversation_capped(path: &Path) -> std::io::Result<String> {
37    let len = fs::metadata(path)?.len();
38    if len > MAX_CONVERSATION_BYTES {
39        return Err(std::io::Error::new(
40            std::io::ErrorKind::InvalidData,
41            format!(
42                "conversation file {} is {len} bytes, over the {} MiB cap",
43                path.display(),
44                MAX_CONVERSATION_BYTES / (1024 * 1024)
45            ),
46        ));
47    }
48    fs::read_to_string(path)
49}
50
51/// Marker left in a message's text when its tool-image bytes are dropped on save.
52const TOOL_IMAGE_ELIDED_MARKER: &str = "\n[tool image not persisted]";
53
54/// Top-level key in a checkpoint file naming the last log `seq` folded into
55/// it. Absent on legacy snapshots and on any file written before the log
56/// existed, which is exactly the signal to fold from zero instead of
57/// trusting the checkpoint. See `docs/design/fold-first-resume.md`.
58const CHECKPOINT_SEQ_KEY: &str = "checkpoint_seq";
59
60/// Return a sanitized copy of `messages` with tool-returned image bytes
61/// removed before they reach durable storage (#99). Tool images (an MCP
62/// browser's page capture, say) can carry on-screen secrets and attach to
63/// **non-User** messages (the assistant message the capture is routed onto,
64/// or a tool outcome); user-supplied multimodal images attach to **User**
65/// messages and are intentional content, so they're preserved. The live
66/// in-memory conversation is untouched (this runs on a copy at the save
67/// chokepoint), so the chat and model context still see the image for the
68/// session — only the on-disk copy is scrubbed.
69///
70/// Returns `None` when nothing needed stripping, so the hot save path avoids a
71/// clone in the common (no-image) case.
72fn strip_persisted_tool_images(messages: &[ChatMessage]) -> Option<Vec<ChatMessage>> {
73    let needs = messages
74        .iter()
75        .any(|m| m.role != MessageRole::User && m.images.is_some());
76    if !needs {
77        return None;
78    }
79    let mut out = messages.to_vec();
80    for m in out.iter_mut() {
81        if m.role != MessageRole::User && m.images.is_some() {
82            m.images = None;
83            if !m.content.ends_with(TOOL_IMAGE_ELIDED_MARKER) {
84                m.content.push_str(TOOL_IMAGE_ELIDED_MARKER);
85            }
86        }
87    }
88    Some(out)
89}
90
91/// Best-effort current git branch of `dir`, for labelling `--resume` rows.
92/// `None` when `dir` isn't a git work tree, git is absent, or HEAD is
93/// detached. Kept out of the pure reducer — callers invoke it in the impure
94/// startup path and stamp the result onto the conversation.
95#[must_use]
96pub fn detect_git_branch(dir: &Path) -> Option<String> {
97    let output = std::process::Command::new("git")
98        .args(["rev-parse", "--abbrev-ref", "HEAD"])
99        .current_dir(dir)
100        .output()
101        .ok()?;
102    if !output.status.success() {
103        return None;
104    }
105    let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
106    // A detached HEAD reports the literal "HEAD"; treat that as no branch.
107    (!branch.is_empty() && branch != "HEAD").then_some(branch)
108}
109
110/// Best-effort short git SHA of `dir`'s HEAD, for session provenance. `None`
111/// outside a git work tree or when git is absent. Impure — stamped at startup
112/// alongside `detect_git_branch`, never in the reducer.
113#[must_use]
114pub fn detect_git_sha(dir: &Path) -> Option<String> {
115    let output = std::process::Command::new("git")
116        .args(["rev-parse", "--short", "HEAD"])
117        .current_dir(dir)
118        .output()
119        .ok()?;
120    if !output.status.success() {
121        return None;
122    }
123    let sha = String::from_utf8_lossy(&output.stdout).trim().to_string();
124    (!sha.is_empty()).then_some(sha)
125}
126
127/// Lightweight session metadata, persisted as an `<id>.meta` sidecar so listing
128/// sessions doesn't have to read a transcript at all.
129///
130/// A cache of a cache: the log is the truth, the checkpoint materializes it,
131/// and this indexes the checkpoint. Written on every append, because with
132/// checkpoints on a coarse cadence a short session has no checkpoint yet and
133/// would otherwise be invisible to the picker. A session missing one is
134/// listed by reading its checkpoint, or failing that by folding its log.
135#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct ConversationMeta {
137    pub id: String,
138    pub title: String,
139    pub updated_at: DateTime<Local>,
140    #[serde(default)]
141    pub git_branch: Option<String>,
142    #[serde(default)]
143    pub message_count: usize,
144    #[serde(default)]
145    pub forked_from: Option<String>,
146}
147
148impl ConversationMeta {
149    fn from_history(h: &ConversationHistory) -> Self {
150        Self {
151            id: h.id.clone(),
152            title: h.title.clone(),
153            updated_at: h.updated_at,
154            git_branch: h.git_branch.clone(),
155            message_count: h.messages().len(),
156            forked_from: h.forked_from.clone(),
157        }
158    }
159}
160
161/// Read a checkpoint file: the conversation plus the log watermark it was
162/// materialized at, when it carries one.
163///
164/// The watermark is read off the raw JSON rather than the deserialized
165/// value, because it is deliberately not a field of `ConversationHistory`
166/// (see [`CHECKPOINT_SEQ_KEY`]). A file without it is a legacy snapshot or
167/// one written by a build that did not stamp, and the caller treats a
168/// missing watermark as "cannot trust this as a checkpoint".
169fn read_checkpoint(path: &Path) -> Result<(ConversationHistory, Option<u64>)> {
170    let json = read_conversation_capped(path)?;
171    let value: serde_json::Value = serde_json::from_str(&json)?;
172    let seq = value
173        .get(CHECKPOINT_SEQ_KEY)
174        .and_then(serde_json::Value::as_u64);
175    let conversation: ConversationHistory = serde_json::from_value(value)?;
176    Ok((conversation, seq))
177}
178
179/// Manages conversation persistence for a project
180#[derive(Clone)]
181pub struct ConversationManager {
182    /// The project directory the manager was built for — keys the
183    /// scratchpad cascade in [`ConversationManager::delete_conversation`].
184    project_dir: PathBuf,
185    conversations_dir: PathBuf,
186    /// The per-session `.jsonl` appender/reader (see `event_log`). Shared
187    /// across clones like `seen`, so one process keeps one seq cursor.
188    events: Arc<crate::session::event_log::EventLog>,
189}
190
191impl ConversationManager {
192    /// Create a new conversation manager for a project directory
193    ///
194    /// # Errors
195    ///
196    /// Creating `.mermaid/conversations` under `project_dir` — a read-only
197    /// or unwritable project. It is created here, so the later load and list
198    /// paths can treat an unreadable directory as "no conversations" rather
199    /// than a failure.
200    pub fn new(project_dir: impl AsRef<Path>) -> Result<Self> {
201        let conversations_dir = project_dir.as_ref().join(".mermaid").join("conversations");
202        fs::create_dir_all(&conversations_dir)?;
203
204        Ok(Self {
205            project_dir: project_dir.as_ref().to_path_buf(),
206            events: Arc::new(crate::session::event_log::EventLog::new(
207                conversations_dir.clone(),
208            )),
209            conversations_dir,
210        })
211    }
212
213    /// Append session events for `snapshot` to its `.jsonl` log, creating
214    /// the log (with a backfill from the snapshot) on first touch. Called by
215    /// the persistence chain BEFORE the snapshot rewrite, so the history
216    /// lands before the file it explains is overwritten.
217    ///
218    /// # Errors
219    ///
220    /// An `id` that would escape the conversations dir, and the append I/O
221    /// itself. The caller treats a failure as a warning: the snapshot save
222    /// must still run, and the log self-heals on the next save.
223    pub fn append_session_events(
224        &self,
225        snapshot: &ConversationHistory,
226        events: &[mermaid_domain::SessionEvent],
227    ) -> Result<()> {
228        validate_conversation_id(&snapshot.id)?;
229        let appended = self.events.append(snapshot, events);
230        // The picker's index rides the append, not the checkpoint. With
231        // checkpoints on a ~200-event cadence a short session has none at
232        // all until it exits, so a sidecar written only alongside one would
233        // leave real sessions invisible to `--resume`. Best-effort as ever:
234        // the sidecar is a cache of a cache.
235        self.write_meta(snapshot);
236        appended
237    }
238
239    /// Write the tiny `<id>.meta` sidecar the session picker lists from.
240    /// Best-effort by construction — every field is recoverable from the
241    /// log, so a failed write costs a slower listing, never data.
242    fn write_meta(&self, conversation: &ConversationHistory) {
243        if conversation.messages().is_empty() {
244            return;
245        }
246        let meta = ConversationMeta::from_history(conversation);
247        if let Ok(json) = serde_json::to_string(&meta) {
248            let path = self
249                .conversations_dir
250                .join(format!("{}.meta", conversation.id));
251            let _ = mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600);
252        }
253    }
254
255    /// Where a session's event log lives. The compaction bookkeeping row
256    /// records this instead of a per-compaction archive file: the dropped
257    /// messages are the log's earlier `message` events.
258    #[must_use]
259    pub fn event_log_path(&self, id: &str) -> PathBuf {
260        self.events.path_for(id)
261    }
262
263    /// Read a session's log as EVENTS rather than folding it into a
264    /// conversation — for readers that want the history in the order it
265    /// happened instead of the state it produced.
266    ///
267    /// The daemon's `subscribe_task` catch-up is that reader: a mid-run
268    /// attach replays these onto the `RunEvent` wire so a subscriber joining
269    /// at minute nine learns what the first nine produced. `Ok(None)` when
270    /// there is no readable log; a torn tail yields the prefix, exactly as a
271    /// fold does.
272    ///
273    /// # Errors
274    ///
275    /// An `id` that would escape the conversations dir, and read I/O on the
276    /// log file. A log that is absent, over the cap, or newer-format is
277    /// `Ok(None)`, not an error.
278    pub fn read_session_events(
279        &self,
280        id: &str,
281    ) -> Result<Option<Vec<mermaid_domain::SessionEvent>>> {
282        validate_conversation_id(id)?;
283        Ok(self
284            .events
285            .read_events(id, None)?
286            .map(|(events, _highest)| events))
287    }
288
289    /// Rebuild a conversation from its event log — the recovery source when
290    /// the snapshot is missing or will not parse. `Ok(None)` when there is
291    /// no (foldable) log.
292    ///
293    /// # Errors
294    ///
295    /// An `id` that would escape the conversations dir, and read I/O on the
296    /// log file. A log that is absent, capped, newer-format, or headerless
297    /// is `Ok(None)`, not an error.
298    pub fn fold_conversation_from_log(&self, id: &str) -> Result<Option<ConversationHistory>> {
299        validate_conversation_id(id)?;
300        let Some(folded) = self.events.fold(id)? else {
301            return Ok(None);
302        };
303        // The folded id drives later saves exactly like a parsed one; hold
304        // it to the same rule.
305        validate_conversation_id(&folded.id)?;
306        Ok(Some(folded))
307    }
308
309    /// Save a conversation to disk
310    ///
311    /// # Errors
312    ///
313    /// An `id` that would escape the conversations dir, serializing the
314    /// conversation, and the atomic 0600 write. Two cases that look like
315    /// failures are `Ok`: a message-less conversation is deliberately not
316    /// persisted, and a concurrent writer detected through the `(mtime, len)`
317    /// baseline diverts this copy to a `.conflict` sibling and warns instead
318    /// of overwriting. The `.meta` sidecar is best-effort and never fails the
319    /// save.
320    pub fn save_conversation(&self, conversation: &ConversationHistory) -> Result<()> {
321        // The id field is persisted and round-trips through (potentially
322        // tampered) on-disk state; validate it before it drives the write path,
323        // so a loaded conversation can't escape the conversations dir on save.
324        validate_conversation_id(&conversation.id)?;
325
326        // An untouched session — the user ran `mermaid` and closed it without
327        // sending anything — has no transcript. Never persist it, so it can't
328        // clutter the `--resume` picker or be reached by `--continue`; the first
329        // real message triggers the next save, which creates the file then.
330        if conversation.messages().is_empty() {
331            return Ok(());
332        }
333
334        let filename = format!("{}.json", conversation.id);
335        let path = self.conversations_dir.join(filename);
336
337        // Sanitize before persisting: strip tool-returned image bytes (#99)
338        // AND scrub credential-shaped strings, so a persisted `read_file` of
339        // `.env` or an API error echoing a key can't sit in cleartext (mirrors
340        // the --record redaction in recorder.rs). Only clones when scrubbing.
341        let mut value = match strip_persisted_tool_images(conversation.messages()) {
342            Some(sanitized) => {
343                let mut stripped = conversation.clone();
344                *stripped.messages_mut() = sanitized;
345                serde_json::to_value(&stripped)?
346            },
347            None => serde_json::to_value(conversation)?,
348        };
349        mermaid_model::utils::redact_json(&mut value);
350        // Stamp WHICH events this checkpoint already contains. Storage's
351        // business, not the domain's: a `ConversationHistory` describes a
352        // conversation, while the log offset a cache was materialized at
353        // describes the cache. Injected into the serialized object rather
354        // than added as a field, and unknown keys are ignored on the way
355        // back in, so the value round-trips through the reducer untouched
356        // and an older mermaid reads the file as a plain snapshot.
357        if let Some(seq) = self.events.checkpoint_seq(&conversation.id)
358            && let Some(object) = value.as_object_mut()
359        {
360            object.insert(CHECKPOINT_SEQ_KEY.to_string(), seq.into());
361        }
362        let json = serde_json::to_string_pretty(&value)?;
363
364        // F73's concurrent-writer guard is NOT here any more; it moved to the
365        // append (see `event_log::diverted_on_conflict`). Two reasons, both
366        // consequences of the log becoming the truth: this file is now a
367        // derived cache, so a clobbered checkpoint costs a longer replay
368        // rather than lost history — and by the time a save reaches here the
369        // append has already decided whether this process is still writing
370        // the shared session at all. Guarding the cache after the truth was
371        // written would only produce `.conflict` copies of a rebuildable file.
372
373        // Atomic write: a crash mid-save must not leave a half-written
374        // checkpoint that resume would then have to distrust.
375        // Owner-only (0o600): the transcript can carry secrets in cleartext.
376        mermaid_runtime::write_atomic_with_mode(&path, json.as_bytes(), 0o600)?;
377        // Refresh our baseline to the file we just wrote so the NEXT save by this
378        // process compares against our own write, not the pre-save state.
379
380        // Keep the picker's sidecar current for a checkpoint written without
381        // a preceding append (the QA paths do this). The append writes it
382        // too, which is what covers sessions with no checkpoint yet.
383        self.write_meta(conversation);
384
385        Ok(())
386    }
387
388    /// Load a specific conversation by ID
389    ///
390    /// # Errors
391    ///
392    /// An `id` that would escape the conversations dir, a file that is
393    /// missing, unreadable, or past the size cap, JSON that does not parse,
394    /// and a parsed `id` that would itself traverse — checked separately,
395    /// because that field is on-disk state that drives later saves.
396    pub fn load_conversation(&self, id: &str) -> Result<ConversationHistory> {
397        validate_conversation_id(id)?;
398        let path = self.conversations_dir.join(format!("{id}.json"));
399        let checkpoint = read_checkpoint(&path);
400
401        // A session with no log is one that predates it: the snapshot is
402        // all there is, so it is still the truth for that session. Every
403        // other case goes through the log.
404        if !self.events.exists(id) {
405            let (conversation, _) = checkpoint?;
406            validate_conversation_id(&conversation.id)?;
407            return Ok(conversation);
408        }
409
410        // Checkpoint plus the events after its watermark. Anything that
411        // makes the checkpoint untrustworthy — unreadable, unparseable, no
412        // watermark, or a watermark its log cannot account for — falls
413        // through to folding the log from zero. That fallback is the
414        // property this design is for: the checkpoint is never
415        // load-bearing, only faster.
416        if let Ok((checkpoint, Some(seq))) = checkpoint
417            && validate_conversation_id(&checkpoint.id).is_ok()
418            && let Some(resumed) = self.events.replay_onto(id, checkpoint, seq)?
419        {
420            return Ok(resumed);
421        }
422
423        let folded = self
424            .fold_conversation_from_log(id)?
425            .with_context(|| format!("session {id} has a log that could not be folded"))?;
426        Ok(folded)
427    }
428
429    /// Load the most recent *valid* conversation.
430    ///
431    /// Iterates files newest-first by mtime and returns the first that reads,
432    /// parses, and has a valid id — skipping (with a warning) any unreadable,
433    /// unparseable, or traversing-id file. Mirrors `list_conversations`'s
434    /// tolerance so one corrupt/partial file (e.g. a crash mid-write) can't make
435    /// `--continue` hard-fail; it falls back to the next-newest valid conversation.
436    ///
437    /// # Errors
438    ///
439    /// In practice none: an unreadable conversations dir and every unreadable,
440    /// oversized, unparseable, or traversing-id file are skipped with a
441    /// warning, and running out of candidates is `Ok(None)`. The `Result` is
442    /// kept for callers that already handle one and so this can grow a real
443    /// failure later.
444    pub fn load_last_conversation(&self) -> Result<Option<ConversationHistory>> {
445        // Newest-first over LOGS, then checkpoints for sessions that predate
446        // the log. Ranking by log mtime matters: a session's checkpoint can
447        // be many events stale (it is written every ~200), so ordering by
448        // checkpoint mtime would answer with the wrong session.
449        for id in self.session_ids_newest_first() {
450            // One resume algorithm, whatever the entry point: `load_conversation`
451            // already picks checkpoint-plus-replay or a full fold.
452            let conv = match self.load_conversation(&id) {
453                Ok(conv) => conv,
454                Err(error) => {
455                    tracing::warn!(id, %error, "skipping session that would not load");
456                    continue;
457                },
458            };
459            // Skip untouched (message-less) sessions — `--continue` resumes the
460            // last chat with real history, not a blank one opened and closed.
461            if conv.messages().is_empty() {
462                continue;
463            }
464            return Ok(Some(conv));
465        }
466        Ok(None)
467    }
468
469    /// Every session id in this project, newest activity first.
470    ///
471    /// Ranked by the LOG's mtime where there is one, since that is what a
472    /// save touches every time; a checkpoint is written on a much coarser
473    /// cadence and would rank a busy session as stale. Sessions with only a
474    /// checkpoint (written before logs existed) rank by that instead.
475    fn session_ids_newest_first(&self) -> Vec<String> {
476        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
477            return Vec::new();
478        };
479        // Per id, keep the log's mtime if there is a log, else the
480        // checkpoint's. `is_log` wins over recency, not the other way
481        // round: a checkpoint written after the last append is still the
482        // coarser clock.
483        let mut best: HashMap<String, (bool, SystemTime)> = HashMap::new();
484        for entry in entries.flatten() {
485            let path = entry.path();
486            let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
487                continue;
488            };
489            let is_log = match ext {
490                "jsonl" => true,
491                "json" => false,
492                _ => continue,
493            };
494            let Some(id) = path.file_stem().and_then(|stem| stem.to_str()) else {
495                continue;
496            };
497            if validate_conversation_id(id).is_err() {
498                continue;
499            }
500            let Ok(mtime) = entry.metadata().and_then(|meta| meta.modified()) else {
501                continue;
502            };
503            match best.get(id) {
504                Some((had_log, _)) if *had_log && !is_log => {},
505                _ => {
506                    best.insert(id.to_string(), (is_log, mtime));
507                },
508            }
509        }
510        let mut ranked: Vec<(SystemTime, String)> = best
511            .into_iter()
512            .map(|(id, (_, mtime))| (mtime, id))
513            .collect();
514        ranked.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| left.1.cmp(&right.1)));
515        ranked.into_iter().map(|(_, id)| id).collect()
516    }
517
518    /// List all conversations in the project
519    ///
520    /// # Errors
521    ///
522    /// In practice none, and deliberately: an unreadable directory yields an
523    /// empty list, and any file that will not read or parse is skipped, so one
524    /// corrupt transcript cannot empty the picker.
525    pub fn list_conversations(&self) -> Result<Vec<ConversationHistory>> {
526        let mut conversations = Vec::new();
527
528        // Read all JSON files in the conversations directory
529        if let Ok(entries) = fs::read_dir(&self.conversations_dir) {
530            for entry in entries.flatten() {
531                if let Some(ext) = entry.path().extension()
532                    && ext == "json"
533                    && let Ok(json) = read_conversation_capped(&entry.path())
534                    && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
535                    // Skip untouched (message-less) sessions — they carry no
536                    // history worth resuming, so they never appear in the picker.
537                    && !conv.messages().is_empty()
538                {
539                    conversations.push(conv);
540                }
541            }
542        }
543
544        // Sort by updated_at (newest first)
545        conversations.sort_by_key(|c| std::cmp::Reverse(c.updated_at));
546
547        Ok(conversations)
548    }
549
550    /// Fast session list: read each `<id>.meta` sidecar; for a session that
551    /// lacks a (valid) one — older, or written by a pre-sidecar build — fall
552    /// back to fully parsing its `<id>.json`. Message-less sessions are skipped.
553    /// Newest-first. Cheaper than [`Self::list_conversations`] for display-only paths.
554    ///
555    /// # Errors
556    ///
557    /// In practice none, matching [`Self::list_conversations`]: an unreadable
558    /// directory yields an empty list, and a sidecar that will not read or
559    /// parse falls through to parsing its `<id>.json`, which is itself skipped
560    /// if that fails too.
561    pub fn list_conversation_metas(&self) -> Result<Vec<ConversationMeta>> {
562        let mut metas = Vec::new();
563        let mut seen = std::collections::HashSet::new();
564        let Ok(entries) = fs::read_dir(&self.conversations_dir) else {
565            return Ok(metas);
566        };
567        let paths: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect();
568        // Fast path: `<id>.meta` sidecars.
569        for path in &paths {
570            if path.extension().is_some_and(|e| e == "meta")
571                && let Ok(raw) = fs::read_to_string(path)
572                && let Ok(meta) = serde_json::from_str::<ConversationMeta>(&raw)
573                && meta.message_count > 0
574            {
575                seen.insert(meta.id.clone());
576                metas.push(meta);
577            }
578        }
579        // Fallback: any `<id>.json` without a valid sidecar.
580        for path in &paths {
581            if path.extension().is_some_and(|e| e == "json")
582                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
583                && !seen.contains(stem)
584                && let Ok(json) = read_conversation_capped(path)
585                && let Ok(conv) = serde_json::from_str::<ConversationHistory>(&json)
586                && !conv.messages().is_empty()
587            {
588                seen.insert(stem.to_string());
589                metas.push(ConversationMeta::from_history(&conv));
590            }
591        }
592        // Last resort: a session with a log but neither sidecar nor
593        // checkpoint. Rare (both are written on every save), but the log is
594        // the truth, so a session that has one must be listable — otherwise
595        // the picker would hide a resumable session because a cache is
596        // missing. Folding here is the expensive path, which is exactly why
597        // it runs only for what the two cheap passes missed.
598        for path in &paths {
599            if path.extension().is_some_and(|e| e == "jsonl")
600                && let Some(stem) = path.file_stem().and_then(|s| s.to_str())
601                && !seen.contains(stem)
602                && let Ok(Some(conv)) = self.fold_conversation_from_log(stem)
603                && !conv.messages().is_empty()
604            {
605                metas.push(ConversationMeta::from_history(&conv));
606            }
607        }
608        metas.sort_by_key(|m| std::cmp::Reverse(m.updated_at));
609        Ok(metas)
610    }
611
612    /// Delete a conversation (and its metadata sidecar).
613    ///
614    /// # Errors
615    ///
616    /// An `id` that would escape the conversations dir, and removing the
617    /// `<id>.json` itself. An id with no file is `Ok`, and the `.meta` sidecar
618    /// and the session scratch dir are cleaned up best-effort — neither can
619    /// fail the delete.
620    pub fn delete_conversation(&self, id: &str) -> Result<()> {
621        validate_conversation_id(id)?;
622        // The index row and what hangs off it in the runtime store. Best-effort
623        // like the sidecars: the files are the truth, and the daemon's next
624        // start prunes any row this misses.
625        let _ = mermaid_runtime::with_shared_store(|store| store.sessions().delete(id));
626        let path = self.conversations_dir.join(format!("{id}.json"));
627        if path.exists() {
628            fs::remove_file(path)?;
629        }
630        // Best-effort sidecar + event-log cleanup — their absence is harmless.
631        let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.meta")));
632        let _ = fs::remove_file(self.conversations_dir.join(format!("{id}.jsonl")));
633        // Cascade to the session's scratch directory (skipped if another
634        // live mermaid still holds its pid lock). Best-effort: the sweep
635        // eventually reaps whatever this misses.
636        let _ = crate::session::scratchpad::remove(&self.project_dir, id);
637
638        Ok(())
639    }
640
641    /// Get the conversations directory path
642    #[must_use]
643    pub fn conversations_dir(&self) -> &Path {
644        &self.conversations_dir
645    }
646}
647
648/// Probe the session's provenance. Impure — spawns `git` twice — so it is a
649/// value the shell resolves once at startup and delivers as
650/// `Msg::SessionProvenanceResolved`.
651#[must_use]
652pub fn probe_session_provenance(cwd: &Path) -> mermaid_domain::SessionProvenance {
653    mermaid_domain::SessionProvenance {
654        git_branch: detect_git_branch(cwd),
655        git_sha: detect_git_sha(cwd),
656        cli_version: Some(env!("CARGO_PKG_VERSION").to_string()),
657    }
658}
659
660#[cfg(test)]
661mod tests {
662    use super::*;
663
664    /// A conversation carrying one message, so it actually persists — empty
665    /// (message-less) sessions are intentionally not saved.
666    fn touched(project: &str) -> ConversationHistory {
667        let mut c = ConversationHistory::new(project.into(), "m".into(), Local::now());
668        c.add_messages(&[ChatMessage::user("hi")], Local::now());
669        c
670    }
671
672    #[test]
673    fn legacy_conversation_json_without_git_branch_deserializes() {
674        // Every session saved before the `--resume` picker existed lacks a
675        // `git_branch` key; `#[serde(default)]` must load it as `None` rather
676        // than failing the picker's `list_conversations`.
677        let json = r#"{
678            "id": "20260101_120000_001",
679            "title": "Legacy session",
680            "messages": [],
681            "model_name": "ollama/test",
682            "project_path": "/tmp/proj",
683            "created_at": "2026-01-01T12:00:00-05:00",
684            "updated_at": "2026-01-01T12:00:00-05:00",
685            "total_tokens": null
686        }"#;
687        let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
688        assert!(conv.git_branch.is_none());
689        assert_eq!(conv.title, "Legacy session");
690        // And a round-trip of a branch-bearing conversation preserves it.
691        let mut fresh =
692            ConversationHistory::new("/tmp/proj".to_string(), "m".to_string(), Local::now());
693        fresh.git_branch = Some("feature/x".to_string());
694        let round: ConversationHistory =
695            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
696        assert_eq!(round.git_branch.as_deref(), Some("feature/x"));
697    }
698
699    #[test]
700    fn legacy_json_defaults_session_state_fields() {
701        // A file written before the session-state fields existed lacks them;
702        // `#[serde(default)]` must load safety/meters as None/0 (safety then
703        // falls back to the config default on resume — see `seed_conversation`).
704        let json = r#"{
705            "id": "20260101_120000_002",
706            "title": "Old",
707            "messages": [],
708            "model_name": "m",
709            "project_path": "/tmp/proj",
710            "created_at": "2026-01-01T12:00:00-05:00",
711            "updated_at": "2026-01-01T12:00:00-05:00",
712            "total_tokens": null
713        }"#;
714        let conv: ConversationHistory = serde_json::from_str(json).expect("legacy json loads");
715        assert_eq!(conv.safety_mode, None);
716        assert_eq!(
717            conv.cumulative_token_usage,
718            mermaid_domain::TokenUsageTotals::default()
719        );
720        assert!(conv.last_token_usage.is_none());
721        assert!(conv.context_usage.is_none());
722        assert!(conv.tasks.tasks.is_empty());
723        assert_eq!(conv.tasks.next_id, 0);
724        assert!(
725            conv.advertised_context.is_none(),
726            "pre-field saves load a None baseline (silent seed)"
727        );
728    }
729
730    #[test]
731    fn advertised_context_round_trips_through_conversation_json() {
732        let mut fresh = touched("/tmp/proj");
733        fresh.advertised_context = Some(mermaid_domain::AdvertisedContext {
734            plan_path: Some(std::path::PathBuf::from("/tmp/proj/.mermaid/plans/x.md")),
735            safety_mode: mermaid_runtime::SafetyMode::Ask,
736            model_id: "ollama/test".to_string(),
737        });
738        let round: ConversationHistory =
739            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
740        let ctx = round.advertised_context.expect("field survives");
741        assert_eq!(
742            ctx.plan_path.as_deref(),
743            Some(std::path::Path::new("/tmp/proj/.mermaid/plans/x.md"))
744        );
745        assert_eq!(ctx.model_id, "ollama/test");
746    }
747
748    #[test]
749    fn tasks_round_trip_through_conversation_json() {
750        let mut fresh = touched("/tmp/proj");
751        fresh.tasks.create(
752            vec![mermaid_domain::ChecklistSpec {
753                subject: "wire broker".into(),
754                active_form: "wiring broker".into(),
755                description: Some("through ExecContext".into()),
756                in_progress: true,
757            }],
758            mermaid_domain::ChecklistOrigin::Model,
759            mermaid_domain::Stamp {
760                now_epoch: 42,
761                run_tokens: 7,
762            },
763        );
764        let round: ConversationHistory =
765            serde_json::from_str(&serde_json::to_string(&fresh).unwrap()).unwrap();
766        assert_eq!(round.tasks, fresh.tasks);
767        assert_eq!(round.tasks.tasks[0].started_at, Some(42));
768    }
769
770    #[test]
771    fn session_state_round_trips_through_json() {
772        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
773        conv.safety_mode = Some(mermaid_runtime::SafetyMode::FullAccess);
774        conv.cumulative_token_usage = mermaid_domain::TokenUsageTotals {
775            prompt_tokens: 777,
776            ..Default::default()
777        };
778        let round: ConversationHistory =
779            serde_json::from_str(&serde_json::to_string(&conv).unwrap()).unwrap();
780        assert_eq!(
781            round.safety_mode,
782            Some(mermaid_runtime::SafetyMode::FullAccess)
783        );
784        assert_eq!(round.cumulative_token_usage.total_tokens(), 777);
785    }
786
787    #[test]
788    fn validate_conversation_id_rejects_traversal() {
789        assert!(validate_conversation_id("20260101_120000_001").is_ok());
790        assert!(validate_conversation_id("../secret").is_err());
791        assert!(validate_conversation_id("..\\secret").is_err());
792        assert!(validate_conversation_id("/etc/passwd").is_err());
793        assert!(validate_conversation_id("20260101_120000").is_err()); // too short
794        assert!(validate_conversation_id("abcdefgh_120000_001").is_err()); // non-digits
795    }
796
797    #[test]
798    fn strip_persisted_tool_images_drops_assistant_images_keeps_user_images() {
799        let messages = vec![
800            ChatMessage::user("look at this").with_images(vec!["USER_PASTED_B64".to_string()]),
801            ChatMessage::assistant("here is the screen")
802                .with_images(vec!["SCREENSHOT_B64".to_string()]),
803            ChatMessage::assistant("no image here"),
804        ];
805        let sanitized = strip_persisted_tool_images(&messages).expect("had a tool image to strip");
806        // User-supplied image preserved.
807        assert_eq!(
808            sanitized[0].images.as_deref(),
809            Some(["USER_PASTED_B64".to_string()].as_slice())
810        );
811        // Assistant image dropped + marker added.
812        assert!(sanitized[1].images.is_none());
813        assert!(sanitized[1].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
814        // Untouched assistant message is unchanged (no spurious marker).
815        assert!(!sanitized[2].content.ends_with(TOOL_IMAGE_ELIDED_MARKER));
816    }
817
818    #[test]
819    fn strip_persisted_tool_images_is_none_without_assistant_images() {
820        let messages = vec![
821            ChatMessage::user("hi").with_images(vec!["USER_B64".to_string()]),
822            ChatMessage::assistant("no images"),
823        ];
824        assert!(strip_persisted_tool_images(&messages).is_none());
825    }
826
827    #[test]
828    fn saved_conversation_json_has_no_tool_image_bytes() {
829        let dir = std::env::temp_dir().join("mermaid_strip_test");
830        let _ = fs::create_dir_all(&dir);
831        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
832        *conv.messages_mut() = vec![
833            ChatMessage::user("u").with_images(vec!["USERIMG".to_string()]),
834            ChatMessage::assistant("a").with_images(vec!["SHOTBYTES".to_string()]),
835        ];
836        let store = ConversationManager {
837            project_dir: dir.clone(),
838            events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
839            conversations_dir: dir.clone(),
840        };
841        store.save_conversation(&conv).expect("save");
842        let raw = fs::read_to_string(dir.join(format!("{}.json", conv.id))).expect("read");
843        assert!(
844            !raw.contains("SHOTBYTES"),
845            "tool image leaked to disk: {raw}"
846        );
847        assert!(raw.contains("USERIMG"), "user image should persist");
848        // Live conversation untouched — still carries the image in-session.
849        assert_eq!(
850            conv.messages()[1].images.as_deref(),
851            Some(["SHOTBYTES".to_string()].as_slice())
852        );
853        let _ = fs::remove_file(dir.join(format!("{}.json", conv.id)));
854    }
855
856    #[test]
857    fn saved_conversation_redacts_secrets_and_is_owner_only() {
858        let dir = std::env::temp_dir().join(format!("mermaid_conv_redact_{}", std::process::id()));
859        let _ = fs::remove_dir_all(&dir);
860        let _ = fs::create_dir_all(&dir);
861        let mut conv = ConversationHistory::new("/tmp/p".into(), "m".into(), Local::now());
862        // A read_file of .env lands in a tool-result message in cleartext today.
863        *conv.messages_mut() = vec![
864            ChatMessage::user("read .env"),
865            ChatMessage::assistant("OPENAI_API_KEY=sk-abcdefghijklmnop1234"),
866        ];
867        let store = ConversationManager {
868            project_dir: dir.clone(),
869            events: Arc::new(crate::session::event_log::EventLog::new(dir.clone())),
870            conversations_dir: dir.clone(),
871        };
872        store.save_conversation(&conv).expect("save");
873        let path = dir.join(format!("{}.json", conv.id));
874        let raw = fs::read_to_string(&path).expect("read");
875        assert!(
876            !raw.contains("sk-abcdefghijklmnop1234"),
877            "secret leaked to the conversation store: {raw}"
878        );
879        assert!(
880            raw.contains("[REDACTED]"),
881            "expected redaction marker: {raw}"
882        );
883        #[cfg(unix)]
884        {
885            use std::os::unix::fs::PermissionsExt;
886            let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
887            assert_eq!(
888                mode, 0o600,
889                "conversation file must be owner-only, got {mode:o}"
890            );
891        }
892        // Live conversation untouched — the model still sees the real content in-session.
893        assert!(
894            conv.messages()[1]
895                .content
896                .contains("sk-abcdefghijklmnop1234")
897        );
898        let _ = fs::remove_dir_all(&dir);
899    }
900
901    #[test]
902    fn test_new_conversation_has_session_title() {
903        let conv =
904            ConversationHistory::new("/tmp/project".into(), "test-model".into(), Local::now());
905        assert!(conv.title.starts_with("Session "));
906        assert_eq!(conv.model_name, "test-model");
907        assert_eq!(conv.project_path, "/tmp/project");
908        assert!(conv.messages().is_empty());
909    }
910
911    #[test]
912    fn test_title_updates_from_first_user_message() {
913        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
914        conv.add_messages(&[ChatMessage::user("Fix the login bug")], Local::now());
915        assert_eq!(conv.title, "Fix the login bug");
916    }
917
918    #[test]
919    fn test_title_truncated_at_60_chars() {
920        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
921        let long_msg = "a".repeat(100);
922        conv.add_messages(&[ChatMessage::user(long_msg)], Local::now());
923        assert!(conv.title.ends_with("..."));
924        assert!(conv.title.len() <= 64); // 60 chars + "..."
925    }
926
927    #[test]
928    fn test_title_set_only_once() {
929        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
930        conv.add_messages(&[ChatMessage::user("First message")], Local::now());
931        conv.add_messages(&[ChatMessage::user("Second message")], Local::now());
932        assert_eq!(conv.title, "First message");
933    }
934
935    #[test]
936    fn test_input_history_deduplication() {
937        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
938        conv.add_to_input_history("hello".into());
939        conv.add_to_input_history("hello".into()); // duplicate
940        conv.add_to_input_history("world".into());
941        assert_eq!(conv.input_history.len(), 2);
942    }
943
944    #[test]
945    fn test_input_history_skips_empty() {
946        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
947        conv.add_to_input_history("".into());
948        conv.add_to_input_history("   ".into());
949        assert_eq!(conv.input_history.len(), 0);
950    }
951
952    #[test]
953    fn test_input_history_capped_at_100() {
954        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
955        for i in 0..110 {
956            conv.add_to_input_history(format!("msg{i}"));
957        }
958        assert_eq!(conv.input_history.len(), 100);
959        assert_eq!(conv.input_history.front().unwrap(), "msg10");
960    }
961
962    #[test]
963    fn sidecar_powers_metadata_listing() {
964        let dir = std::env::temp_dir().join("mermaid_test_meta_sidecar");
965        let _ = fs::remove_dir_all(&dir);
966        let manager = ConversationManager::new(&dir).unwrap();
967        let mut conv = ConversationHistory::new("/tmp/proj".into(), "model".into(), Local::now());
968        conv.title = "My session".into();
969        conv.add_messages(
970            &[ChatMessage::user("hi"), ChatMessage::user("there")],
971            Local::now(),
972        );
973        manager.save_conversation(&conv).unwrap();
974
975        assert!(
976            manager
977                .conversations_dir()
978                .join(format!("{}.meta", conv.id))
979                .exists()
980        );
981        let metas = manager.list_conversation_metas().unwrap();
982        assert_eq!(metas.len(), 1);
983        assert_eq!(metas[0].id, conv.id);
984        assert_eq!(metas[0].title, "My session");
985        assert_eq!(metas[0].message_count, 2);
986
987        // Deleting the session removes its sidecar too.
988        manager.delete_conversation(&conv.id).unwrap();
989        assert!(
990            !manager
991                .conversations_dir()
992                .join(format!("{}.meta", conv.id))
993                .exists()
994        );
995        assert!(manager.list_conversation_metas().unwrap().is_empty());
996        let _ = fs::remove_dir_all(&dir);
997    }
998
999    #[test]
1000    fn metadata_listing_falls_back_to_full_parse_without_sidecar() {
1001        let dir = std::env::temp_dir().join("mermaid_test_meta_fallback");
1002        let _ = fs::remove_dir_all(&dir);
1003        let manager = ConversationManager::new(&dir).unwrap();
1004        let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1005        conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1006        manager.save_conversation(&conv).unwrap();
1007        // Simulate a pre-sidecar session by removing the sidecar.
1008        fs::remove_file(
1009            manager
1010                .conversations_dir()
1011                .join(format!("{}.meta", conv.id)),
1012        )
1013        .unwrap();
1014        let metas = manager.list_conversation_metas().unwrap();
1015        assert_eq!(metas.len(), 1, "falls back to parsing the .json");
1016        assert_eq!(metas[0].message_count, 1);
1017        let _ = fs::remove_dir_all(&dir);
1018    }
1019
1020    #[test]
1021    fn lineage_fields_default_on_old_sessions() {
1022        // A transcript persisted before the lineage fields existed still loads.
1023        let json = r#"{"id":"x","title":"t","messages":[],"model_name":"m","project_path":"/p","created_at":"2026-01-01T00:00:00+00:00","updated_at":"2026-01-01T00:00:00+00:00","total_tokens":null}"#;
1024        let conv: ConversationHistory = serde_json::from_str(json).unwrap();
1025        assert!(conv.git_sha.is_none());
1026        assert!(conv.cli_version.is_none());
1027        assert!(conv.forked_from.is_none());
1028        assert!(conv.parent_session.is_none());
1029    }
1030
1031    #[test]
1032    fn test_save_load_roundtrip() {
1033        let dir = std::env::temp_dir().join("mermaid_test_conv_roundtrip");
1034        let _ = fs::remove_dir_all(&dir);
1035        let manager = ConversationManager::new(&dir).unwrap();
1036
1037        let mut conv = ConversationHistory::new("/tmp".into(), "model".into(), Local::now());
1038        conv.add_messages(&[ChatMessage::user("test message")], Local::now());
1039        conv.add_to_input_history("test message".into());
1040
1041        manager.save_conversation(&conv).unwrap();
1042        let loaded = manager.load_conversation(&conv.id).unwrap();
1043
1044        assert_eq!(loaded.id, conv.id);
1045        assert_eq!(loaded.title, conv.title);
1046        assert_eq!(loaded.messages().len(), 1);
1047        assert_eq!(loaded.input_history.len(), 1);
1048
1049        let _ = fs::remove_dir_all(&dir);
1050    }
1051
1052    #[test]
1053    fn test_list_conversations_ordered_by_updated_at() {
1054        let dir = std::env::temp_dir().join("mermaid_test_conv_list");
1055        let _ = fs::remove_dir_all(&dir);
1056        let manager = ConversationManager::new(&dir).unwrap();
1057
1058        let conv1 = touched("/tmp");
1059        std::thread::sleep(std::time::Duration::from_millis(10));
1060        let conv2 = touched("/tmp");
1061
1062        manager.save_conversation(&conv1).unwrap();
1063        manager.save_conversation(&conv2).unwrap();
1064
1065        let list = manager.list_conversations().unwrap();
1066        assert_eq!(list.len(), 2);
1067        // Newest first
1068        assert_eq!(list[0].id, conv2.id);
1069        assert_eq!(list[1].id, conv1.id);
1070
1071        let _ = fs::remove_dir_all(&dir);
1072    }
1073
1074    #[test]
1075    fn test_load_last_conversation() {
1076        let dir = std::env::temp_dir().join("mermaid_test_conv_last");
1077        let _ = fs::remove_dir_all(&dir);
1078        let manager = ConversationManager::new(&dir).unwrap();
1079
1080        assert!(manager.load_last_conversation().unwrap().is_none());
1081
1082        let conv = touched("/tmp");
1083        manager.save_conversation(&conv).unwrap();
1084
1085        let last = manager.load_last_conversation().unwrap().unwrap();
1086        assert_eq!(last.id, conv.id);
1087
1088        let _ = fs::remove_dir_all(&dir);
1089    }
1090
1091    #[test]
1092    fn test_load_last_conversation_picks_newest_by_mtime() {
1093        // Writes three conversations with staggered mtimes (via sleeps
1094        // between saves) and asserts the mtime-based picker returns the
1095        // last one written — even though filename-alphabetical ordering
1096        // would pick a different file.
1097        let dir = std::env::temp_dir().join("mermaid_test_conv_mtime");
1098        let _ = fs::remove_dir_all(&dir);
1099        let manager = ConversationManager::new(&dir).unwrap();
1100
1101        let conv1 = touched("/tmp");
1102        manager.save_conversation(&conv1).unwrap();
1103        std::thread::sleep(std::time::Duration::from_millis(10));
1104
1105        let conv2 = touched("/tmp");
1106        manager.save_conversation(&conv2).unwrap();
1107        std::thread::sleep(std::time::Duration::from_millis(10));
1108
1109        let conv3 = touched("/tmp");
1110        manager.save_conversation(&conv3).unwrap();
1111
1112        let last = manager.load_last_conversation().unwrap().unwrap();
1113        assert_eq!(
1114            last.id, conv3.id,
1115            "should return the most-recently-written file"
1116        );
1117
1118        let _ = fs::remove_dir_all(&dir);
1119    }
1120
1121    #[test]
1122    fn load_last_conversation_skips_corrupt_newest_falls_back_to_valid() {
1123        let dir = std::env::temp_dir().join("mermaid_test_conv_corrupt");
1124        let _ = fs::remove_dir_all(&dir);
1125        let manager = ConversationManager::new(&dir).unwrap();
1126
1127        let good = touched("/tmp");
1128        manager.save_conversation(&good).unwrap();
1129        std::thread::sleep(std::time::Duration::from_millis(10));
1130
1131        // Plant a NEWER, corrupt file (well-formed name, garbage contents): the
1132        // newest-by-mtime entry is unparseable, so #68 must skip it.
1133        let corrupt = manager.conversations_dir().join("20991231_235959_999.json");
1134        fs::write(&corrupt, b"{ not valid json").unwrap();
1135
1136        let last = manager.load_last_conversation().unwrap().unwrap();
1137        assert_eq!(
1138            last.id, good.id,
1139            "must fall back to the newest VALID conversation"
1140        );
1141        let _ = fs::remove_dir_all(&dir);
1142    }
1143
1144    #[test]
1145    fn load_last_conversation_none_when_only_corrupt() {
1146        let dir = std::env::temp_dir().join("mermaid_test_conv_only_corrupt");
1147        let _ = fs::remove_dir_all(&dir);
1148        let manager = ConversationManager::new(&dir).unwrap();
1149        fs::write(
1150            manager.conversations_dir().join("20991231_235959_998.json"),
1151            b"nope",
1152        )
1153        .unwrap();
1154        assert!(manager.load_last_conversation().unwrap().is_none());
1155        let _ = fs::remove_dir_all(&dir);
1156    }
1157
1158    #[test]
1159    fn load_conversation_tolerates_unknown_message_role() {
1160        // F74: a conversation written by a NEWER build may carry a MessageRole
1161        // this build doesn't model. It must still load — the unknown role maps to
1162        // a neutral System message — so `--continue` doesn't silently skip the
1163        // newest session (the prior behavior, when the whole parse hard-failed).
1164        let dir =
1165            std::env::temp_dir().join(format!("mermaid_conv_role_skew_{}", std::process::id()));
1166        let _ = fs::remove_dir_all(&dir);
1167        let manager = ConversationManager::new(&dir).unwrap();
1168
1169        let id = "20260101_120000_001";
1170        let json = format!(
1171            r#"{{
1172                "id": "{id}",
1173                "title": "skew",
1174                "messages": [
1175                    {{
1176                        "role": "Developer",
1177                        "content": "from a newer build",
1178                        "timestamp": "2026-01-01T12:00:00-04:00"
1179                    }}
1180                ],
1181                "model_name": "m",
1182                "project_path": "/tmp",
1183                "created_at": "2026-01-01T12:00:00-04:00",
1184                "updated_at": "2026-01-01T12:00:00-04:00",
1185                "total_tokens": null
1186            }}"#
1187        );
1188        fs::write(manager.conversations_dir().join(format!("{id}.json")), json).unwrap();
1189
1190        let loaded = manager
1191            .load_conversation(id)
1192            .expect("must load despite an unknown role");
1193        assert_eq!(loaded.messages().len(), 1);
1194        assert_eq!(
1195            loaded.messages()[0].role,
1196            MessageRole::System,
1197            "an unknown role becomes a neutral System message"
1198        );
1199
1200        // And `--continue`'s newest-valid picker returns it instead of skipping.
1201        let last = manager
1202            .load_last_conversation()
1203            .unwrap()
1204            .expect("the newest session must load");
1205        assert_eq!(last.id, id);
1206
1207        let _ = fs::remove_dir_all(&dir);
1208    }
1209
1210    #[test]
1211    fn test_delete_conversation() {
1212        let dir = std::env::temp_dir().join("mermaid_test_conv_delete");
1213        let _ = fs::remove_dir_all(&dir);
1214        let manager = ConversationManager::new(&dir).unwrap();
1215
1216        let conv = touched("/tmp");
1217        manager.save_conversation(&conv).unwrap();
1218        assert_eq!(manager.list_conversations().unwrap().len(), 1);
1219
1220        manager.delete_conversation(&conv.id).unwrap();
1221        assert_eq!(manager.list_conversations().unwrap().len(), 0);
1222
1223        let _ = fs::remove_dir_all(&dir);
1224    }
1225
1226    #[test]
1227    fn empty_session_is_not_saved() {
1228        let dir = std::env::temp_dir().join("mermaid_test_conv_empty_save");
1229        let _ = fs::remove_dir_all(&dir);
1230        let manager = ConversationManager::new(&dir).unwrap();
1231
1232        // An untouched (message-less) conversation must not create a file.
1233        let mut conv = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1234        manager.save_conversation(&conv).unwrap();
1235        assert!(
1236            manager.list_conversations().unwrap().is_empty(),
1237            "empty session must not be listed"
1238        );
1239        assert!(
1240            manager.load_last_conversation().unwrap().is_none(),
1241            "empty session must not be --continue-able"
1242        );
1243
1244        // The first real message makes it persist.
1245        conv.add_messages(&[ChatMessage::user("hi")], Local::now());
1246        manager.save_conversation(&conv).unwrap();
1247        assert_eq!(manager.list_conversations().unwrap().len(), 1);
1248
1249        let _ = fs::remove_dir_all(&dir);
1250    }
1251
1252    #[test]
1253    fn resume_paths_skip_pre_existing_empty_files() {
1254        // An empty session file planted directly on disk (e.g. saved before this
1255        // guard existed) must be invisible to the picker and to `--continue`.
1256        let dir = std::env::temp_dir().join("mermaid_test_conv_empty_resume");
1257        let _ = fs::remove_dir_all(&dir);
1258        let manager = ConversationManager::new(&dir).unwrap();
1259
1260        let real = touched("/tmp");
1261        manager.save_conversation(&real).unwrap();
1262        // A NEWER empty file, written straight to disk to bypass the save guard.
1263        std::thread::sleep(std::time::Duration::from_millis(10));
1264        let empty = ConversationHistory::new("/tmp".into(), "m".into(), Local::now());
1265        let path = manager
1266            .conversations_dir()
1267            .join(format!("{}.json", empty.id));
1268        fs::write(&path, serde_json::to_string(&empty).unwrap()).unwrap();
1269
1270        let list = manager.list_conversations().unwrap();
1271        assert_eq!(list.len(), 1, "the empty file must not be listed");
1272        assert_eq!(list[0].id, real.id);
1273        assert_eq!(
1274            manager.load_last_conversation().unwrap().unwrap().id,
1275            real.id,
1276            "--continue must skip the newer empty file"
1277        );
1278
1279        let _ = fs::remove_dir_all(&dir);
1280    }
1281
1282    #[test]
1283    fn read_conversation_capped_refuses_oversized_file() {
1284        // #129: a file over the cap is refused before it's read into RAM. Use a
1285        // sparse file so the test stays fast and doesn't actually write 64 MiB.
1286        let dir = std::env::temp_dir().join(format!("mermaid_conv_cap_{}", std::process::id()));
1287        let _ = fs::remove_dir_all(&dir);
1288        fs::create_dir_all(&dir).unwrap();
1289
1290        let small = dir.join("small.json");
1291        fs::write(&small, b"{}").unwrap();
1292        assert!(read_conversation_capped(&small).is_ok());
1293
1294        let big = dir.join("big.json");
1295        let f = fs::File::create(&big).unwrap();
1296        f.set_len(MAX_CONVERSATION_BYTES + 1).unwrap();
1297        assert!(
1298            read_conversation_capped(&big).is_err(),
1299            "a file over the cap must be refused, not slurped into memory"
1300        );
1301
1302        let _ = fs::remove_dir_all(&dir);
1303    }
1304
1305    // The two tests that lived here pinned the snapshot-side F73 guard.
1306    // That guard moved to the append, so its coverage moved with it:
1307    // event_log::tests::a_second_writer_diverts_this_process_to_a_conflict_sibling.
1308    // Keeping them here would assert that a derived cache defends itself
1309    // against a writer that no longer races for it.
1310}