Skip to main content

rab/agent/
session.rs

1use crate::agent::types::AgentMessage;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8
9// ── Constants ───────────────────────────────────────────────────────
10
11pub const CURRENT_SESSION_VERSION: u32 = 3;
12
13// ── Session header ──────────────────────────────────────────────────
14
15/// The first entry in every session file.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase")]
18pub struct SessionHeader {
19    #[serde(rename = "type")]
20    pub type_: String, // always "session"
21    #[serde(default)]
22    pub version: Option<u32>,
23    pub id: String,
24    pub timestamp: String,
25    pub cwd: String,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub parent_session: Option<String>,
28}
29
30// ── Entry types ─────────────────────────────────────────────────────
31
32/// A session entry — one JSON line in the session file.
33///
34/// Uses serde's internally-tagged enum with `type` field for discrimination.
35#[derive(Debug, Clone, Serialize, Deserialize)]
36#[serde(tag = "type")]
37pub enum SessionEntry {
38    #[serde(rename = "message")]
39    Message(MessageEntry),
40    #[serde(rename = "thinking_level_change")]
41    ThinkingLevelChange(ThinkingLevelChangeEntry),
42    #[serde(rename = "model_change")]
43    ModelChange(ModelChangeEntry),
44    #[serde(rename = "compaction")]
45    Compaction(CompactionEntry),
46    #[serde(rename = "branch_summary")]
47    BranchSummary(BranchSummaryEntry),
48    #[serde(rename = "session_info")]
49    SessionInfo(SessionInfoEntry),
50    #[serde(rename = "label")]
51    Label(LabelEntry),
52    #[serde(rename = "custom")]
53    Custom(CustomEntry),
54    #[serde(rename = "custom_message")]
55    CustomMessage(CustomMessageEntry),
56}
57
58impl SessionEntry {
59    pub fn id(&self) -> &str {
60        match self {
61            SessionEntry::Message(e) => &e.id,
62            SessionEntry::ThinkingLevelChange(e) => &e.id,
63            SessionEntry::ModelChange(e) => &e.id,
64            SessionEntry::Compaction(e) => &e.id,
65            SessionEntry::BranchSummary(e) => &e.id,
66            SessionEntry::SessionInfo(e) => &e.id,
67            SessionEntry::Label(e) => &e.id,
68            SessionEntry::Custom(e) => &e.id,
69            SessionEntry::CustomMessage(e) => &e.id,
70        }
71    }
72
73    pub fn parent_id(&self) -> Option<&str> {
74        match self {
75            SessionEntry::Message(e) => e.parent_id.as_deref(),
76            SessionEntry::ThinkingLevelChange(e) => e.parent_id.as_deref(),
77            SessionEntry::ModelChange(e) => e.parent_id.as_deref(),
78            SessionEntry::Compaction(e) => e.parent_id.as_deref(),
79            SessionEntry::BranchSummary(e) => e.parent_id.as_deref(),
80            SessionEntry::SessionInfo(e) => e.parent_id.as_deref(),
81            SessionEntry::Label(e) => e.parent_id.as_deref(),
82            SessionEntry::Custom(e) => e.parent_id.as_deref(),
83            SessionEntry::CustomMessage(e) => e.parent_id.as_deref(),
84        }
85    }
86
87    pub fn timestamp(&self) -> &str {
88        match self {
89            SessionEntry::Message(e) => &e.timestamp,
90            SessionEntry::ThinkingLevelChange(e) => &e.timestamp,
91            SessionEntry::ModelChange(e) => &e.timestamp,
92            SessionEntry::Compaction(e) => &e.timestamp,
93            SessionEntry::BranchSummary(e) => &e.timestamp,
94            SessionEntry::SessionInfo(e) => &e.timestamp,
95            SessionEntry::Label(e) => &e.timestamp,
96            SessionEntry::Custom(e) => &e.timestamp,
97            SessionEntry::CustomMessage(e) => &e.timestamp,
98        }
99    }
100}
101
102/// Base fields shared by all entries.
103#[derive(Debug, Clone, Serialize, Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct MessageEntry {
106    pub id: String,
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub parent_id: Option<String>,
109    pub timestamp: String,
110    pub message: AgentMessage,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize)]
114#[serde(rename_all = "camelCase")]
115pub struct ThinkingLevelChangeEntry {
116    pub id: String,
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub parent_id: Option<String>,
119    pub timestamp: String,
120    pub thinking_level: String,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
124#[serde(rename_all = "camelCase")]
125pub struct ModelChangeEntry {
126    pub id: String,
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub parent_id: Option<String>,
129    pub timestamp: String,
130    pub provider: String,
131    pub model_id: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct CompactionEntry {
137    pub id: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub parent_id: Option<String>,
140    pub timestamp: String,
141    pub summary: String,
142    pub first_kept_entry_id: String,
143    pub tokens_before: u64,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub details: Option<serde_json::Value>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub from_hook: Option<bool>,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151#[serde(rename_all = "camelCase")]
152pub struct BranchSummaryEntry {
153    pub id: String,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub parent_id: Option<String>,
156    pub timestamp: String,
157    pub from_id: String,
158    pub summary: String,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub details: Option<serde_json::Value>,
161    #[serde(skip_serializing_if = "Option::is_none")]
162    pub from_hook: Option<bool>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct SessionInfoEntry {
168    pub id: String,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub parent_id: Option<String>,
171    pub timestamp: String,
172    pub name: String,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
176#[serde(rename_all = "camelCase")]
177pub struct LabelEntry {
178    pub id: String,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub parent_id: Option<String>,
181    pub timestamp: String,
182    pub target_id: String,
183    #[serde(skip_serializing_if = "Option::is_none")]
184    pub label: Option<String>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
188#[serde(rename_all = "camelCase")]
189pub struct CustomEntry {
190    pub id: String,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub parent_id: Option<String>,
193    pub timestamp: String,
194    pub custom_type: String,
195    pub data: serde_json::Value,
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[serde(rename_all = "camelCase")]
200pub struct CustomMessageEntry {
201    pub id: String,
202    #[serde(skip_serializing_if = "Option::is_none")]
203    pub parent_id: Option<String>,
204    pub timestamp: String,
205    pub custom_type: String,
206    pub content: serde_json::Value,
207    #[serde(default)]
208    pub display: bool,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub details: Option<serde_json::Value>,
211}
212
213// ── SessionInfo (for listing / display) ─────────────────────────────
214
215/// Lightweight metadata about a session, used for listing and selection.
216#[derive(Debug, Clone)]
217pub struct SessionInfo {
218    pub path: PathBuf,
219    pub id: String,
220    pub cwd: String,
221    pub name: Option<String>,
222    pub parent_session_path: Option<String>,
223    pub created: DateTime<Utc>,
224    pub modified: DateTime<Utc>,
225    pub message_count: usize,
226    pub first_message: String,
227    /// All messages concatenated (for text search).
228    pub all_messages_text: String,
229}
230
231// ── SessionContext (resolved messages for LLM) ──────────────────────
232
233/// Resolved conversation context sent to the LLM.
234#[derive(Debug, Clone)]
235pub struct SessionContext {
236    pub messages: Vec<AgentMessage>,
237}
238
239// ── JSONL read/write ────────────────────────────────────────────────
240
241/// Parse a single line as a SessionEntry. Returns None for empty/malformed lines.
242pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
243    let line = line.trim();
244    if line.is_empty() {
245        return None;
246    }
247    serde_json::from_str(line).ok()
248}
249
250/// Parse a single line as a SessionHeader.
251pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
252    let line = line.trim();
253    if line.is_empty() {
254        return None;
255    }
256    let header: SessionHeader = serde_json::from_str(line).ok()?;
257    if header.type_ != "session" {
258        return None;
259    }
260    Some(header)
261}
262
263/// Read the session header from a JSONL file (first line only).
264pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
265    let content = fs::read_to_string(path).ok()?;
266    let first_line = content.lines().next()?;
267    parse_session_header_line(first_line)
268}
269
270/// Load all entries from a session JSONL file.
271/// Returns (header, entries) or empty vec if the file is missing or corrupted.
272pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
273    let content = match fs::read_to_string(path) {
274        Ok(c) => c,
275        Err(_) => return vec![],
276    };
277
278    let entries: Vec<SessionEntry> = content
279        .lines()
280        .filter_map(parse_session_entry_line)
281        .collect();
282
283    // Validate: first entry must be a session header (type = "session")
284    // We check this by ensuring at least one entry exists and is not a non-header type.
285    // Header entries have type="session" which is parsed as a serde error for SessionEntry
286    // since we use tagged enum. The header line will fail to parse as SessionEntry.
287    // That's fine — load_entries_from_file returns only SessionEntry items, not the header.
288    // The caller uses read_session_header() separately for the header.
289
290    entries
291}
292
293/// Write entries to a session file (used for initial write / rewrite).
294/// Does NOT write the session header — caller must include it.
295pub fn write_entries_to_file(
296    path: &Path,
297    header: &SessionHeader,
298    entries: &[SessionEntry],
299) -> std::io::Result<()> {
300    if let Some(parent) = path.parent() {
301        fs::create_dir_all(parent)?;
302    }
303    let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
304    content.push('\n');
305    for entry in entries {
306        let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
307        content.push_str(&line);
308        content.push('\n');
309    }
310    fs::write(path, &content)
311}
312
313/// Append a single entry to the session file (one JSON line).
314pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
315    let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
316    let content = format!("{}\n", line);
317    std::fs::OpenOptions::new()
318        .create(true)
319        .append(true)
320        .open(path)?
321        .write_all(content.as_bytes())
322}
323
324// ── CWD encoding ────────────────────────────────────────────────────
325
326/// Encode a working directory path into a safe directory name.
327/// Mirrors pi's encoding: strip leading /, replace / \ : with -, wrap in --...--
328pub fn encode_cwd_for_dir(cwd: &Path) -> String {
329    let s = cwd.to_string_lossy();
330    let cleaned = s
331        .trim_start_matches('/')
332        .trim_start_matches('\\')
333        .replace(['/', '\\', ':'], "-");
334    format!("--{}--", cleaned)
335}
336
337/// Get the default session directory for a cwd.
338pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
339    let rab_dir = directories::BaseDirs::new()
340        .expect("Could not determine home directory")
341        .home_dir()
342        .join(".rab");
343    rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
344}
345
346/// Generate a unique ID for session entries (8 hex chars, collision-checked).
347pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
348    for _ in 0..100 {
349        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
350        if !by_id.contains_key(&id) {
351            return id;
352        }
353    }
354    // Fallback to full UUID
355    uuid::Uuid::new_v4().to_string()
356}
357
358// ── SessionManager ──────────────────────────────────────────────────
359
360/// Manages conversation sessions as append-only trees in JSONL files.
361///
362/// Each entry has an id and parentId forming a tree structure.
363/// Appending creates a child of the current leaf. Branching moves the
364/// leaf to an earlier entry, allowing new branches without modifying history.
365pub struct SessionManager {
366    session_id: String,
367    session_file: Option<PathBuf>,
368    session_dir: PathBuf,
369    cwd: PathBuf,
370    persist: bool,
371    flushed: bool,
372    file_entries: Vec<SessionEntry>,
373    by_id: HashMap<String, SessionEntry>,
374    labels_by_id: HashMap<String, String>,
375    leaf_id: Option<String>,
376}
377
378impl SessionManager {
379    // ── Construction ─────────────────────────────────────────────
380
381    fn new(
382        cwd: &Path,
383        session_dir: &Path,
384        session_file: Option<PathBuf>,
385        persist: bool,
386        create_new: bool,
387    ) -> Self {
388        let cwd = cwd.to_path_buf();
389        let session_dir = session_dir.to_path_buf();
390
391        let mut sm = Self {
392            session_id: String::new(),
393            session_file: None,
394            session_dir,
395            cwd,
396            persist,
397            flushed: false,
398            file_entries: Vec::new(),
399            by_id: HashMap::new(),
400            labels_by_id: HashMap::new(),
401            leaf_id: None,
402        };
403
404        if let Some(path) = session_file {
405            sm.set_session_file(&path);
406            if create_new {
407                // Override: force new session even if file was loaded
408                sm.new_session(None);
409                sm.session_file = Some(path);
410            }
411        } else if create_new {
412            sm.new_session(None);
413        }
414
415        sm
416    }
417
418    /// Switch to a different session file.
419    fn set_session_file(&mut self, session_file: &Path) {
420        self.session_file = Some(session_file.to_path_buf());
421        if session_file.exists() {
422            self.file_entries = load_entries_from_file(session_file);
423            let header = read_session_header(session_file);
424
425            // If file is empty or has no valid header, treat as corrupted:
426            // truncate and start fresh, preserving the file path.
427            if self.file_entries.is_empty() && header.is_none() {
428                let explicit_path = self.session_file.clone();
429                self.new_session(None);
430                self.session_file = explicit_path;
431                self._rewrite_file();
432                self.flushed = true;
433                return;
434            }
435
436            // Entries exist (or header exists but no entries yet — keep the session)
437            self.session_id = header
438                .map(|h| h.id)
439                .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
440            self.migrate_to_current();
441            self._build_index();
442            self.flushed = true;
443        } else {
444            // File doesn't exist — create new session at this path
445            let explicit_path = self.session_file.clone();
446            self.new_session(None);
447            self.session_file = explicit_path;
448        }
449    }
450
451    /// Create a new session (overwrites current entries).
452    fn new_session(&mut self, id: Option<&str>) {
453        self.session_id = id
454            .map(|s| s.to_string())
455            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
456        let timestamp = chrono::Utc::now().to_rfc3339();
457        let header = SessionHeader {
458            type_: "session".to_string(),
459            version: Some(CURRENT_SESSION_VERSION),
460            id: self.session_id.clone(),
461            timestamp,
462            cwd: self.cwd.to_string_lossy().to_string(),
463            parent_session: None,
464        };
465        // Store header as file_entries[0] implicitly via the first entry.
466        // We handle it separately in file operations.
467        self.file_entries = Vec::new();
468        self.by_id.clear();
469        self.labels_by_id.clear();
470        self.leaf_id = None;
471        self.flushed = false;
472
473        if self.persist {
474            let file_ts = header.timestamp.replace([':', '.'], "-");
475            self.session_file = Some(
476                self.session_dir
477                    .join(format!("{}_{}.jsonl", file_ts, self.session_id)),
478            );
479        }
480
481        // Store header separately for rewrite
482        // We use a sentinel pattern: the header is reconstructed from fields
483    }
484
485    fn _build_index(&mut self) {
486        self.by_id.clear();
487        self.labels_by_id.clear();
488        self.leaf_id = None;
489        for entry in &self.file_entries {
490            self.by_id.insert(entry.id().to_string(), entry.clone());
491            self.leaf_id = Some(entry.id().to_string());
492            if let SessionEntry::Label(e) = entry {
493                if let Some(label) = &e.label {
494                    self.labels_by_id.insert(e.target_id.clone(), label.clone());
495                } else {
496                    self.labels_by_id.remove(&e.target_id);
497                }
498            }
499        }
500    }
501
502    fn _rewrite_file(&self) {
503        if !self.persist {
504            return;
505        }
506        if let Some(ref path) = self.session_file {
507            let header = self._make_header();
508            let _ = write_entries_to_file(path, &header, &self.file_entries);
509        }
510    }
511
512    fn _make_header(&self) -> SessionHeader {
513        SessionHeader {
514            type_: "session".to_string(),
515            version: Some(CURRENT_SESSION_VERSION),
516            id: self.session_id.clone(),
517            timestamp: chrono::Utc::now().to_rfc3339(),
518            cwd: self.cwd.to_string_lossy().to_string(),
519            parent_session: None,
520        }
521    }
522
523    fn _persist(&mut self) {
524        if !self.persist {
525            return;
526        }
527        let has_assistant = self
528            .file_entries
529            .iter()
530            .any(|e| matches!(e, SessionEntry::Message(m) if m.message.role == crate::agent::types::Role::Assistant));
531
532        if !has_assistant {
533            // Don't create file until first assistant message
534            self.flushed = false;
535            return;
536        }
537
538        if !self.flushed {
539            if let Some(ref path) = self.session_file {
540                let header = self._make_header();
541                let _ = write_entries_to_file(path, &header, &self.file_entries);
542                self.flushed = true;
543            }
544        } else if let Some(ref path) = self.session_file
545            && let Some(entry) = self.file_entries.last()
546        {
547            let _ = append_entry_to_file(path, entry);
548        }
549    }
550
551    fn _append_entry(&mut self, entry: SessionEntry) -> String {
552        let id = entry.id().to_string();
553        self.file_entries.push(entry.clone());
554        self.by_id.insert(id.clone(), entry);
555        self.leaf_id = Some(id.clone());
556        self._persist();
557        id
558    }
559
560    /// Run migrations to bring entries to the current version.
561    /// Currently a no-op since we only write v3 entries.
562    fn migrate_to_current(&mut self) {
563        // For now, just ensure entries look valid.
564        // Future: handle v1→v2 (add id/parentId) and v2→v3 (hookMessage→custom).
565    }
566
567    // ── Public: Info ──────────────────────────────────────────────
568
569    pub fn is_persisted(&self) -> bool {
570        self.persist
571    }
572
573    pub fn cwd(&self) -> &Path {
574        &self.cwd
575    }
576
577    pub fn session_dir(&self) -> &Path {
578        &self.session_dir
579    }
580
581    /// Returns true if using the default cwd-encoded session directory.
582    pub fn uses_default_session_dir(&self) -> bool {
583        self.session_dir == get_default_session_dir(&self.cwd)
584    }
585
586    pub fn session_id(&self) -> &str {
587        &self.session_id
588    }
589
590    pub fn session_file(&self) -> Option<&Path> {
591        self.session_file.as_deref()
592    }
593
594    pub fn leaf_id(&self) -> Option<&str> {
595        self.leaf_id.as_deref()
596    }
597
598    /// Get the current session name from the latest session_info entry.
599    pub fn session_name(&self) -> Option<&str> {
600        for entry in self.file_entries.iter().rev() {
601            if let SessionEntry::SessionInfo(e) = entry {
602                let name = e.name.trim();
603                if name.is_empty() {
604                    return None;
605                }
606                return Some(name);
607            }
608        }
609        None
610    }
611
612    // ── Public: Appending ─────────────────────────────────────────
613
614    /// Append a message as child of current leaf, then advance leaf.
615    /// Returns the entry id.
616    pub fn append_message(&mut self, message: &AgentMessage) -> String {
617        let entry = SessionEntry::Message(MessageEntry {
618            id: generate_entry_id(&self.by_id),
619            parent_id: self.leaf_id.clone(),
620            timestamp: chrono::Utc::now().to_rfc3339(),
621            message: message.clone(),
622        });
623        self._append_entry(entry)
624    }
625
626    /// Append a thinking level change.
627    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
628        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
629            id: generate_entry_id(&self.by_id),
630            parent_id: self.leaf_id.clone(),
631            timestamp: chrono::Utc::now().to_rfc3339(),
632            thinking_level: thinking_level.to_string(),
633        });
634        self._append_entry(entry)
635    }
636
637    /// Append a model change.
638    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
639        let entry = SessionEntry::ModelChange(ModelChangeEntry {
640            id: generate_entry_id(&self.by_id),
641            parent_id: self.leaf_id.clone(),
642            timestamp: chrono::Utc::now().to_rfc3339(),
643            provider: provider.to_string(),
644            model_id: model_id.to_string(),
645        });
646        self._append_entry(entry)
647    }
648
649    /// Append a session info entry (display name).
650    pub fn append_session_info(&mut self, name: &str) -> String {
651        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
652            id: generate_entry_id(&self.by_id),
653            parent_id: self.leaf_id.clone(),
654            timestamp: chrono::Utc::now().to_rfc3339(),
655            name: name.trim().to_string(),
656        });
657        self._append_entry(entry)
658    }
659
660    /// Append a compaction summary.
661    pub fn append_compaction(
662        &mut self,
663        summary: &str,
664        first_kept_entry_id: &str,
665        tokens_before: u64,
666    ) -> String {
667        let entry = SessionEntry::Compaction(CompactionEntry {
668            id: generate_entry_id(&self.by_id),
669            parent_id: self.leaf_id.clone(),
670            timestamp: chrono::Utc::now().to_rfc3339(),
671            summary: summary.to_string(),
672            first_kept_entry_id: first_kept_entry_id.to_string(),
673            tokens_before,
674            details: None,
675            from_hook: None,
676        });
677        self._append_entry(entry)
678    }
679
680    /// Append a branch summary.
681    pub fn append_branch_summary(&mut self, from_id: &str, summary: &str) -> String {
682        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
683            id: generate_entry_id(&self.by_id),
684            parent_id: self.leaf_id.clone(),
685            timestamp: chrono::Utc::now().to_rfc3339(),
686            from_id: from_id.to_string(),
687            summary: summary.to_string(),
688            details: None,
689            from_hook: None,
690        });
691        self._append_entry(entry)
692    }
693
694    /// Append a label change (bookmark/unbookmark).
695    pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
696        let entry = SessionEntry::Label(LabelEntry {
697            id: generate_entry_id(&self.by_id),
698            parent_id: self.leaf_id.clone(),
699            timestamp: chrono::Utc::now().to_rfc3339(),
700            target_id: target_id.to_string(),
701            label: label.map(|s| s.to_string()),
702        });
703        let id = self._append_entry(entry);
704
705        // Update label map
706        if let Some(l) = label {
707            self.labels_by_id
708                .insert(target_id.to_string(), l.to_string());
709        } else {
710            self.labels_by_id.remove(target_id);
711        }
712        id
713    }
714
715    /// Append a custom entry (extension data).
716    pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
717        let entry = SessionEntry::Custom(CustomEntry {
718            id: generate_entry_id(&self.by_id),
719            parent_id: self.leaf_id.clone(),
720            timestamp: chrono::Utc::now().to_rfc3339(),
721            custom_type: custom_type.to_string(),
722            data,
723        });
724        self._append_entry(entry)
725    }
726
727    // ── Public: Tree navigation ───────────────────────────────────
728
729    /// Get all entries (excludes header).
730    pub fn entries(&self) -> &[SessionEntry] {
731        &self.file_entries
732    }
733
734    /// Look up an entry by id.
735    pub fn entry(&self, id: &str) -> Option<&SessionEntry> {
736        self.by_id.get(id)
737    }
738
739    /// Get all direct children of an entry.
740    pub fn children(&self, parent_id: &str) -> Vec<&SessionEntry> {
741        self.file_entries
742            .iter()
743            .filter(|e| e.parent_id() == Some(parent_id))
744            .collect()
745    }
746
747    /// Walk from entry to root, returning all entries in path order.
748    pub fn branch(&self, from_id: Option<&str>) -> Vec<&SessionEntry> {
749        let start_id = from_id.or(self.leaf_id.as_deref());
750        let mut path = Vec::new();
751        let mut current = start_id.and_then(|id| self.by_id.get(id));
752        while let Some(entry) = current {
753            path.push(entry);
754            current = entry.parent_id().and_then(|pid| self.by_id.get(pid));
755        }
756        path.reverse();
757        path
758    }
759
760    /// Build the session context (messages for LLM).
761    pub fn build_session_context(&self) -> SessionContext {
762        let path = self.branch(None);
763        let messages: Vec<AgentMessage> = path
764            .iter()
765            .filter_map(|entry| {
766                if let SessionEntry::Message(e) = entry {
767                    Some(e.message.clone())
768                } else {
769                    None
770                }
771            })
772            .collect();
773        SessionContext { messages }
774    }
775
776    /// Get the label for an entry, if any.
777    pub fn label(&self, id: &str) -> Option<&str> {
778        self.labels_by_id.get(id).map(|s| s.as_str())
779    }
780
781    // ── Public: Branching ─────────────────────────────────────────
782
783    /// Move leaf pointer to an earlier entry (starts a new branch).
784    pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
785        if !self.by_id.contains_key(branch_from_id) {
786            return Err(format!("Entry {} not found", branch_from_id));
787        }
788        self.leaf_id = Some(branch_from_id.to_string());
789        Ok(())
790    }
791
792    /// Reset leaf pointer to null (before any entries).
793    pub fn reset_leaf(&mut self) {
794        self.leaf_id = None;
795    }
796
797    // ── Static factories ──────────────────────────────────────────
798
799    /// Create a new session.
800    pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
801        let dir = session_dir
802            .map(|p| p.to_path_buf())
803            .unwrap_or_else(|| get_default_session_dir(cwd));
804        Self::new(cwd, &dir, None, true, true)
805    }
806
807    /// Open a specific session file.
808    pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
809        let cwd = if let Some(cwd_path) = cwd_override {
810            cwd_path.to_path_buf()
811        } else {
812            // Extract cwd from header
813            read_session_header(path)
814                .map(|h| PathBuf::from(h.cwd))
815                .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")))
816        };
817        let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
818            path.parent()
819                .map(|p| p.to_path_buf())
820                .unwrap_or_else(|| get_default_session_dir(&cwd))
821        });
822        Self::new(&cwd, &dir, Some(path.to_path_buf()), true, false)
823    }
824
825    /// Create an in-memory session (no file persistence).
826    pub fn in_memory(cwd: &Path) -> Self {
827        let dir = get_default_session_dir(cwd);
828        Self::new(cwd, &dir, None, false, true)
829    }
830
831    /// Continue the most recent session, or create new if none.
832    pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
833        let dir = session_dir
834            .map(|p| p.to_path_buf())
835            .unwrap_or_else(|| get_default_session_dir(cwd));
836        let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
837        let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
838        if let Some(path) = most_recent {
839            Self::new(cwd, &dir, Some(path), true, false)
840        } else {
841            Self::new(cwd, &dir, None, true, true)
842        }
843    }
844}
845
846/// Find the most recent session file by mtime.
847pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
848    let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
849    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
850
851    let entries = std::fs::read_dir(session_dir).ok()?;
852    for entry in entries.flatten() {
853        let path = entry.path();
854        if path.extension().is_some_and(|ext| ext == "jsonl") {
855            let header = read_session_header(&path);
856            if let Some(ref h) = header {
857                if let Some(ref rcwd) = resolved_cwd
858                    && h.cwd != rcwd.to_string_lossy().as_ref()
859                {
860                    continue;
861                }
862            } else {
863                continue;
864            }
865            if let Ok(meta) = path.metadata()
866                && let Ok(mtime) = meta.modified()
867            {
868                files.push((path, mtime));
869            }
870        }
871    }
872
873    files.sort_by_key(|b| std::cmp::Reverse(b.1));
874    files.into_iter().next().map(|(path, _)| path)
875}
876
877// ── Tests ───────────────────────────────────────────────────────────
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    use crate::agent::types::{AgentMessage, Role, Usage};
883    use tempfile::TempDir;
884
885    fn make_message(role: Role, content: &str) -> AgentMessage {
886        AgentMessage {
887            id: uuid::Uuid::new_v4().to_string(),
888            parent_id: None,
889            role,
890            content: content.to_string(),
891            tool_calls: vec![],
892            tool_call_id: None,
893            usage: None,
894            is_error: false,
895            timestamp: Utc::now().timestamp_millis(),
896        }
897    }
898
899    // ── Entry serialization round-trip ──────────────────────────────
900
901    #[test]
902    fn test_message_entry_roundtrip() {
903        let msg = make_message(Role::User, "hello world");
904        let entry = SessionEntry::Message(MessageEntry {
905            id: "abc12345".to_string(),
906            parent_id: None,
907            timestamp: "2026-06-19T12:00:00Z".to_string(),
908            message: msg.clone(),
909        });
910
911        let json = serde_json::to_string(&entry).unwrap();
912        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
913
914        match parsed {
915            SessionEntry::Message(e) => {
916                assert_eq!(e.id, "abc12345");
917                assert_eq!(e.parent_id, None);
918                assert_eq!(e.message.role, Role::User);
919                assert_eq!(e.message.content, "hello world");
920            }
921            _ => panic!("Expected Message variant"),
922        }
923    }
924
925    #[test]
926    fn test_thinking_level_change_roundtrip() {
927        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
928            id: "abc12345".to_string(),
929            parent_id: Some("parent1".to_string()),
930            timestamp: "2026-06-19T12:00:00Z".to_string(),
931            thinking_level: "high".to_string(),
932        });
933
934        let json = serde_json::to_string(&entry).unwrap();
935        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
936
937        match parsed {
938            SessionEntry::ThinkingLevelChange(e) => {
939                assert_eq!(e.thinking_level, "high");
940                assert_eq!(e.parent_id.as_deref(), Some("parent1"));
941            }
942            _ => panic!("Expected ThinkingLevelChange variant"),
943        }
944    }
945
946    #[test]
947    fn test_model_change_roundtrip() {
948        let entry = SessionEntry::ModelChange(ModelChangeEntry {
949            id: "abc12345".to_string(),
950            parent_id: Some("parent1".to_string()),
951            timestamp: "2026-06-19T12:00:00Z".to_string(),
952            provider: "opencode_go".to_string(),
953            model_id: "deepseek-v4-pro".to_string(),
954        });
955
956        let json = serde_json::to_string(&entry).unwrap();
957        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
958
959        match parsed {
960            SessionEntry::ModelChange(e) => {
961                assert_eq!(e.provider, "opencode_go");
962                assert_eq!(e.model_id, "deepseek-v4-pro");
963            }
964            _ => panic!("Expected ModelChange variant"),
965        }
966    }
967
968    #[test]
969    fn test_compaction_entry_roundtrip() {
970        let entry = SessionEntry::Compaction(CompactionEntry {
971            id: "abc12345".to_string(),
972            parent_id: Some("parent1".to_string()),
973            timestamp: "2026-06-19T12:00:00Z".to_string(),
974            summary: "Earlier conversation summarized...".to_string(),
975            first_kept_entry_id: "entry123".to_string(),
976            tokens_before: 5000,
977            details: None,
978            from_hook: None,
979        });
980
981        let json = serde_json::to_string(&entry).unwrap();
982        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
983
984        match parsed {
985            SessionEntry::Compaction(e) => {
986                assert_eq!(e.summary, "Earlier conversation summarized...");
987                assert_eq!(e.first_kept_entry_id, "entry123");
988                assert_eq!(e.tokens_before, 5000);
989            }
990            _ => panic!("Expected Compaction variant"),
991        }
992    }
993
994    #[test]
995    fn test_branch_summary_roundtrip() {
996        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
997            id: "abc12345".to_string(),
998            parent_id: Some("parent1".to_string()),
999            timestamp: "2026-06-19T12:00:00Z".to_string(),
1000            from_id: "branch_point".to_string(),
1001            summary: "Abandoned work on feature X".to_string(),
1002            details: None,
1003            from_hook: None,
1004        });
1005
1006        let json = serde_json::to_string(&entry).unwrap();
1007        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1008
1009        match parsed {
1010            SessionEntry::BranchSummary(e) => {
1011                assert_eq!(e.summary, "Abandoned work on feature X");
1012                assert_eq!(e.from_id, "branch_point");
1013            }
1014            _ => panic!("Expected BranchSummary variant"),
1015        }
1016    }
1017
1018    #[test]
1019    fn test_session_info_roundtrip() {
1020        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1021            id: "abc12345".to_string(),
1022            parent_id: Some("parent1".to_string()),
1023            timestamp: "2026-06-19T12:00:00Z".to_string(),
1024            name: "Refactor auth module".to_string(),
1025        });
1026
1027        let json = serde_json::to_string(&entry).unwrap();
1028        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1029
1030        match parsed {
1031            SessionEntry::SessionInfo(e) => {
1032                assert_eq!(e.name, "Refactor auth module");
1033            }
1034            _ => panic!("Expected SessionInfo variant"),
1035        }
1036    }
1037
1038    #[test]
1039    fn test_label_entry_roundtrip() {
1040        // Set label
1041        let entry = SessionEntry::Label(LabelEntry {
1042            id: "abc12345".to_string(),
1043            parent_id: Some("parent1".to_string()),
1044            timestamp: "2026-06-19T12:00:00Z".to_string(),
1045            target_id: "target_entry".to_string(),
1046            label: Some("important".to_string()),
1047        });
1048
1049        let json = serde_json::to_string(&entry).unwrap();
1050        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1051
1052        match parsed {
1053            SessionEntry::Label(e) => {
1054                assert_eq!(e.label.as_deref(), Some("important"));
1055                assert_eq!(e.target_id, "target_entry");
1056            }
1057            _ => panic!("Expected Label variant"),
1058        }
1059
1060        // Clear label
1061        let entry = SessionEntry::Label(LabelEntry {
1062            id: "abc12346".to_string(),
1063            parent_id: Some("parent1".to_string()),
1064            timestamp: "2026-06-19T12:01:00Z".to_string(),
1065            target_id: "target_entry".to_string(),
1066            label: None,
1067        });
1068
1069        let json = serde_json::to_string(&entry).unwrap();
1070        // With skip_serializing_if = "Option::is_none", label field is omitted when None
1071        assert!(!json.contains(r#""label":"#));
1072    }
1073
1074    #[test]
1075    fn test_custom_entry_roundtrip() {
1076        let entry = SessionEntry::Custom(CustomEntry {
1077            id: "abc12345".to_string(),
1078            parent_id: Some("parent1".to_string()),
1079            timestamp: "2026-06-19T12:00:00Z".to_string(),
1080            custom_type: "my_extension".to_string(),
1081            data: serde_json::json!({"key": "value"}),
1082        });
1083
1084        let json = serde_json::to_string(&entry).unwrap();
1085        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1086
1087        match parsed {
1088            SessionEntry::Custom(e) => {
1089                assert_eq!(e.custom_type, "my_extension");
1090                assert_eq!(e.data["key"], "value");
1091            }
1092            _ => panic!("Expected Custom variant"),
1093        }
1094    }
1095
1096    #[test]
1097    fn test_custom_message_entry_roundtrip() {
1098        let entry = SessionEntry::CustomMessage(CustomMessageEntry {
1099            id: "abc12345".to_string(),
1100            parent_id: Some("parent1".to_string()),
1101            timestamp: "2026-06-19T12:00:00Z".to_string(),
1102            custom_type: "my_extension".to_string(),
1103            content: serde_json::json!({"text": "Hello from extension"}),
1104            display: true,
1105            details: Some(serde_json::json!({"source": "plugin"})),
1106        });
1107
1108        let json = serde_json::to_string(&entry).unwrap();
1109        let parsed: SessionEntry = serde_json::from_str(&json).unwrap();
1110
1111        match parsed {
1112            SessionEntry::CustomMessage(e) => {
1113                assert_eq!(e.custom_type, "my_extension");
1114                assert!(e.display);
1115            }
1116            _ => panic!("Expected CustomMessage variant"),
1117        }
1118    }
1119
1120    // ── JSONL read/write ────────────────────────────────────────────
1121
1122    #[test]
1123    fn test_parse_session_entry_line() {
1124        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1125            id: "abc12345".to_string(),
1126            parent_id: None,
1127            timestamp: "2026-06-19T12:00:00Z".to_string(),
1128            name: "Test session".to_string(),
1129        });
1130        let json = serde_json::to_string(&entry).unwrap();
1131        let parsed = parse_session_entry_line(&json);
1132        assert!(parsed.is_some());
1133    }
1134
1135    #[test]
1136    fn test_parse_session_entry_line_empty() {
1137        assert!(parse_session_entry_line("").is_none());
1138        assert!(parse_session_entry_line("   ").is_none());
1139    }
1140
1141    #[test]
1142    fn test_parse_session_entry_line_malformed() {
1143        assert!(parse_session_entry_line("not valid json").is_none());
1144    }
1145
1146    #[test]
1147    fn test_parse_session_header_line() {
1148        let header = SessionHeader {
1149            type_: "session".to_string(),
1150            version: Some(3),
1151            id: "session123".to_string(),
1152            timestamp: "2026-06-19T12:00:00Z".to_string(),
1153            cwd: "/home/user/project".to_string(),
1154            parent_session: None,
1155        };
1156        let json = serde_json::to_string(&header).unwrap();
1157        let parsed = parse_session_header_line(&json);
1158        assert!(parsed.is_some());
1159        assert_eq!(parsed.unwrap().id, "session123");
1160    }
1161
1162    #[test]
1163    fn test_parse_session_header_line_wrong_type() {
1164        // parse_session_header_line validates type == "session"
1165        let json =
1166            r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
1167        let result = parse_session_header_line(json);
1168        assert!(result.is_none());
1169    }
1170
1171    #[test]
1172    fn test_write_and_read_entries() {
1173        let tmp = TempDir::new().unwrap();
1174        let file_path = tmp.path().join("test.jsonl");
1175
1176        let header = SessionHeader {
1177            type_: "session".to_string(),
1178            version: Some(3),
1179            id: "session1".to_string(),
1180            timestamp: "2026-06-19T12:00:00Z".to_string(),
1181            cwd: "/home/user/project".to_string(),
1182            parent_session: None,
1183        };
1184
1185        let entries: Vec<SessionEntry> = vec![
1186            SessionEntry::Message(MessageEntry {
1187                id: "msg1".to_string(),
1188                parent_id: None,
1189                timestamp: "2026-06-19T12:00:01Z".to_string(),
1190                message: make_message(Role::User, "hello"),
1191            }),
1192            SessionEntry::Message(MessageEntry {
1193                id: "msg2".to_string(),
1194                parent_id: Some("msg1".to_string()),
1195                timestamp: "2026-06-19T12:00:02Z".to_string(),
1196                message: {
1197                    let mut m = make_message(Role::Assistant, "hi there");
1198                    m.usage = Some(Usage {
1199                        input_tokens: Some(10),
1200                        output_tokens: Some(5),
1201                        cache_tokens: None,
1202                    });
1203                    m
1204                },
1205            }),
1206        ];
1207
1208        write_entries_to_file(&file_path, &header, &entries).unwrap();
1209
1210        // Read back header
1211        let read_header = read_session_header(&file_path).unwrap();
1212        assert_eq!(read_header.id, "session1");
1213
1214        // Read back entries
1215        let read_entries = load_entries_from_file(&file_path);
1216        assert_eq!(read_entries.len(), 2);
1217
1218        match &read_entries[0] {
1219            SessionEntry::Message(e) => {
1220                assert_eq!(e.id, "msg1");
1221                assert_eq!(e.message.role, Role::User);
1222                assert_eq!(e.message.content, "hello");
1223            }
1224            _ => panic!("Expected Message"),
1225        }
1226
1227        match &read_entries[1] {
1228            SessionEntry::Message(e) => {
1229                assert_eq!(e.id, "msg2");
1230                assert_eq!(e.message.role, Role::Assistant);
1231                assert_eq!(e.message.content, "hi there");
1232                assert!(e.message.usage.is_some());
1233            }
1234            _ => panic!("Expected Message"),
1235        }
1236    }
1237
1238    #[test]
1239    fn test_append_entry_to_file() {
1240        let tmp = TempDir::new().unwrap();
1241        let file_path = tmp.path().join("append_test.jsonl");
1242
1243        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1244            id: "abc12345".to_string(),
1245            parent_id: None,
1246            timestamp: "2026-06-19T12:00:00Z".to_string(),
1247            name: "Test".to_string(),
1248        });
1249
1250        append_entry_to_file(&file_path, &entry).unwrap();
1251
1252        let content = fs::read_to_string(&file_path).unwrap();
1253        assert!(content.contains("Test"));
1254        assert!(content.contains("abc12345"));
1255    }
1256
1257    #[test]
1258    fn test_load_entries_missing_file() {
1259        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
1260        assert!(entries.is_empty());
1261    }
1262
1263    #[test]
1264    fn test_read_session_header_missing_file() {
1265        let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
1266        assert!(header.is_none());
1267    }
1268
1269    // ── CWD encoding ────────────────────────────────────────────────
1270
1271    #[test]
1272    fn test_encode_cwd() {
1273        assert_eq!(
1274            encode_cwd_for_dir(Path::new("/home/user/project")),
1275            "--home-user-project--"
1276        );
1277    }
1278
1279    #[test]
1280    fn test_encode_cwd_windows_style() {
1281        assert_eq!(
1282            encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
1283            "--C--Users-user-project--"
1284        );
1285    }
1286
1287    #[test]
1288    fn test_encode_cwd_no_leading_slash() {
1289        assert_eq!(
1290            encode_cwd_for_dir(Path::new("home/user/project")),
1291            "--home-user-project--"
1292        );
1293    }
1294
1295    #[test]
1296    fn test_encode_cwd_special_chars() {
1297        assert_eq!(
1298            encode_cwd_for_dir(Path::new("/home/user/my:project")),
1299            "--home-user-my-project--"
1300        );
1301    }
1302
1303    // ── SessionEntry accessors ───────────────────────────────────────
1304
1305    #[test]
1306    fn test_entry_id_accessor() {
1307        let entry = SessionEntry::Message(MessageEntry {
1308            id: "myid".to_string(),
1309            parent_id: None,
1310            timestamp: "2026-06-19T12:00:00Z".to_string(),
1311            message: make_message(Role::User, "hello"),
1312        });
1313        assert_eq!(entry.id(), "myid");
1314    }
1315
1316    #[test]
1317    fn test_entry_parent_id_accessor() {
1318        let entry = SessionEntry::Message(MessageEntry {
1319            id: "myid".to_string(),
1320            parent_id: Some("parent".to_string()),
1321            timestamp: "2026-06-19T12:00:00Z".to_string(),
1322            message: make_message(Role::User, "hello"),
1323        });
1324        assert_eq!(entry.parent_id(), Some("parent"));
1325    }
1326
1327    #[test]
1328    fn test_entry_timestamp_accessor() {
1329        let entry = SessionEntry::Message(MessageEntry {
1330            id: "myid".to_string(),
1331            parent_id: None,
1332            timestamp: "2026-06-19T12:00:00Z".to_string(),
1333            message: make_message(Role::User, "hello"),
1334        });
1335        assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
1336    }
1337
1338    // ── generate_entry_id ────────────────────────────────────────────
1339
1340    #[test]
1341    fn test_generate_entry_id_length() {
1342        let map = HashMap::new();
1343        let id = generate_entry_id(&map);
1344        assert_eq!(id.len(), 8);
1345    }
1346
1347    #[test]
1348    fn test_generate_entry_id_hex() {
1349        let map = HashMap::new();
1350        let id = generate_entry_id(&map);
1351        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
1352    }
1353
1354    #[test]
1355    fn test_generate_entry_id_collision_fallback() {
1356        // Create a map that has all possible 8-char hex IDs — impossible
1357        // but we test the fallback behavior by only having a collision
1358        // on the first generated ID (unlikely but the code handles it).
1359        // This is more of a smoke test that the function doesn't panic.
1360        let map = HashMap::new();
1361        let id1 = generate_entry_id(&map);
1362        assert!(!id1.is_empty());
1363    }
1364
1365    // ── Deserialize from pi-compatible JSON ──────────────────────────
1366
1367    #[test]
1368    fn test_deserialize_pi_format_message() {
1369        // pi format uses camelCase and "type": "message"
1370        let json = r#"{"type":"message","id":"abc12345","parentId":null,"timestamp":"2026-06-19T12:00:00Z","message":{"id":"msg1","parentId":null,"role":"user","content":"hello","toolCalls":[],"isError":false,"timestamp":1718800000000}}"#;
1371        let entry: SessionEntry = serde_json::from_str(json).unwrap();
1372        match entry {
1373            SessionEntry::Message(e) => {
1374                assert_eq!(e.id, "abc12345");
1375                assert_eq!(e.message.content, "hello");
1376            }
1377            _ => panic!("Expected Message"),
1378        }
1379    }
1380
1381    #[test]
1382    fn test_deserialize_pi_format_thinking_level() {
1383        let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
1384        let entry: SessionEntry = serde_json::from_str(json).unwrap();
1385        match entry {
1386            SessionEntry::ThinkingLevelChange(e) => {
1387                assert_eq!(e.thinking_level, "high");
1388            }
1389            _ => panic!("Expected ThinkingLevelChange"),
1390        }
1391    }
1392
1393    #[test]
1394    fn test_deserialize_pi_format_model_change() {
1395        let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
1396        let entry: SessionEntry = serde_json::from_str(json).unwrap();
1397        match entry {
1398            SessionEntry::ModelChange(e) => {
1399                assert_eq!(e.provider, "opencode_go");
1400                assert_eq!(e.model_id, "deepseek-v4-pro");
1401            }
1402            _ => panic!("Expected ModelChange"),
1403        }
1404    }
1405
1406    #[test]
1407    fn test_deserialize_pi_format_compaction() {
1408        let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
1409        let entry: SessionEntry = serde_json::from_str(json).unwrap();
1410        match entry {
1411            SessionEntry::Compaction(e) => {
1412                assert_eq!(e.summary, "Earlier conversation summarized");
1413                assert_eq!(e.first_kept_entry_id, "entry123");
1414                assert_eq!(e.tokens_before, 5000);
1415            }
1416            _ => panic!("Expected Compaction"),
1417        }
1418    }
1419
1420    #[test]
1421    fn test_deserialize_pi_format_session_info() {
1422        let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
1423        let entry: SessionEntry = serde_json::from_str(json).unwrap();
1424        match entry {
1425            SessionEntry::SessionInfo(e) => {
1426                assert_eq!(e.name, "My session");
1427            }
1428            _ => panic!("Expected SessionInfo"),
1429        }
1430    }
1431
1432    // ── SessionManager ───────────────────────────────────────────────
1433
1434    fn make_agent_message(role: Role, content: &str) -> AgentMessage {
1435        AgentMessage {
1436            id: uuid::Uuid::new_v4().to_string(),
1437            parent_id: None,
1438            role,
1439            content: content.to_string(),
1440            tool_calls: vec![],
1441            tool_call_id: None,
1442            usage: None,
1443            is_error: false,
1444            timestamp: Utc::now().timestamp_millis(),
1445        }
1446    }
1447
1448    #[test]
1449    fn test_session_create_in_memory() {
1450        let cwd = Path::new("/tmp/test-project");
1451        let sm = SessionManager::in_memory(cwd);
1452        assert!(!sm.is_persisted());
1453        assert!(!sm.session_id().is_empty());
1454        assert_eq!(sm.cwd(), cwd);
1455        assert!(sm.leaf_id().is_none());
1456        assert!(sm.entries().is_empty());
1457    }
1458
1459    #[test]
1460    fn test_session_create_persisted() {
1461        let tmp = TempDir::new().unwrap();
1462        let sessions_dir = tmp.path().join("sessions");
1463        let cwd = tmp.path().join("project");
1464        std::fs::create_dir_all(&cwd).unwrap();
1465
1466        let sm = SessionManager::create(&cwd, Some(&sessions_dir));
1467        assert!(sm.is_persisted());
1468        assert!(!sm.session_id().is_empty());
1469        // File not created yet (deferred until first assistant message)
1470        assert!(sm.session_file().is_some());
1471    }
1472
1473    #[test]
1474    fn test_session_append_and_build_context() {
1475        let tmp = TempDir::new().unwrap();
1476        let sessions_dir = tmp.path().join("sessions");
1477        let cwd = tmp.path().join("project");
1478        std::fs::create_dir_all(&cwd).unwrap();
1479
1480        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1481
1482        let user_msg = make_agent_message(Role::User, "hello");
1483        let user_id = sm.append_message(&user_msg);
1484        assert!(sm.leaf_id() == Some(&user_id));
1485
1486        // In-memory entries exist even before flush
1487        assert_eq!(sm.entries().len(), 1);
1488
1489        let assistant_msg = make_agent_message(Role::Assistant, "hi there");
1490        sm.append_message(&assistant_msg);
1491        assert_eq!(sm.entries().len(), 2);
1492
1493        // After assistant message, file should be flushed
1494        let context = sm.build_session_context();
1495        assert_eq!(context.messages.len(), 2);
1496        assert_eq!(context.messages[0].content, "hello");
1497        assert_eq!(context.messages[1].content, "hi there");
1498    }
1499
1500    #[test]
1501    fn test_session_open_existing() {
1502        let tmp = TempDir::new().unwrap();
1503        let sessions_dir = tmp.path().join("sessions");
1504        let cwd = tmp.path().join("project");
1505        std::fs::create_dir_all(&cwd).unwrap();
1506
1507        // Create and populate a session
1508        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1509        sm.append_message(&make_agent_message(Role::User, "first"));
1510        sm.append_message(&make_agent_message(Role::Assistant, "response"));
1511
1512        let file_path = sm.session_file().unwrap().to_path_buf();
1513        let session_id = sm.session_id().to_string();
1514        drop(sm);
1515
1516        // Open it
1517        let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
1518        assert_eq!(sm2.session_id(), &session_id);
1519        let context = sm2.build_session_context();
1520        assert_eq!(context.messages.len(), 2);
1521        assert_eq!(context.messages[0].content, "first");
1522        assert_eq!(context.messages[1].content, "response");
1523    }
1524
1525    #[test]
1526    fn test_session_continue_recent() {
1527        let tmp = TempDir::new().unwrap();
1528        let sessions_dir = tmp.path().join("sessions");
1529        let cwd = tmp.path().join("project");
1530        std::fs::create_dir_all(&cwd).unwrap();
1531
1532        // First session
1533        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
1534        sm1.append_message(&make_agent_message(Role::User, "old session"));
1535        sm1.append_message(&make_agent_message(Role::Assistant, "old response"));
1536        let _old_id = sm1.session_id().to_string();
1537        drop(sm1);
1538
1539        // Small delay to ensure different mtime
1540        std::thread::sleep(std::time::Duration::from_millis(10));
1541
1542        // Second session (more recent)
1543        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
1544        sm2.append_message(&make_agent_message(Role::User, "new session"));
1545        sm2.append_message(&make_agent_message(Role::Assistant, "new response"));
1546        let new_id = sm2.session_id().to_string();
1547        drop(sm2);
1548
1549        // Continue recent — should get the new one
1550        let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
1551        assert_eq!(sm3.session_id(), &new_id);
1552        let context = sm3.build_session_context();
1553        assert_eq!(context.messages[0].content, "new session");
1554    }
1555
1556    #[test]
1557    fn test_session_continue_recent_none_exist() {
1558        let tmp = TempDir::new().unwrap();
1559        let sessions_dir = tmp.path().join("sessions");
1560        let cwd = tmp.path().join("project");
1561        std::fs::create_dir_all(&sessions_dir).unwrap();
1562        std::fs::create_dir_all(&cwd).unwrap();
1563
1564        // No sessions exist — should create new
1565        let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
1566        assert!(!sm.session_id().is_empty());
1567        assert!(sm.entries().is_empty());
1568    }
1569
1570    #[test]
1571    fn test_session_name() {
1572        let tmp = TempDir::new().unwrap();
1573        let sessions_dir = tmp.path().join("sessions");
1574        let cwd = tmp.path().join("project");
1575        std::fs::create_dir_all(&cwd).unwrap();
1576
1577        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1578        assert!(sm.session_name().is_none());
1579
1580        sm.append_session_info("My Task");
1581        sm.append_message(&make_agent_message(Role::User, "hello"));
1582        sm.append_message(&make_agent_message(Role::Assistant, "hi"));
1583        assert_eq!(sm.session_name(), Some("My Task"));
1584
1585        // Setting empty name clears it
1586        sm.append_session_info("");
1587        assert!(sm.session_name().is_none());
1588    }
1589
1590    #[test]
1591    fn test_session_thinking_level_change() {
1592        let tmp = TempDir::new().unwrap();
1593        let sessions_dir = tmp.path().join("sessions");
1594        let cwd = tmp.path().join("project");
1595        std::fs::create_dir_all(&cwd).unwrap();
1596
1597        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1598        sm.append_thinking_level_change("high");
1599
1600        assert_eq!(sm.entries().len(), 1);
1601        match &sm.entries()[0] {
1602            SessionEntry::ThinkingLevelChange(e) => {
1603                assert_eq!(e.thinking_level, "high");
1604            }
1605            _ => panic!("Expected ThinkingLevelChange"),
1606        }
1607    }
1608
1609    #[test]
1610    fn test_session_model_change() {
1611        let tmp = TempDir::new().unwrap();
1612        let sessions_dir = tmp.path().join("sessions");
1613        let cwd = tmp.path().join("project");
1614        std::fs::create_dir_all(&cwd).unwrap();
1615
1616        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1617        sm.append_model_change("opencode_go", "deepseek-v4-pro");
1618
1619        assert_eq!(sm.entries().len(), 1);
1620        match &sm.entries()[0] {
1621            SessionEntry::ModelChange(e) => {
1622                assert_eq!(e.provider, "opencode_go");
1623                assert_eq!(e.model_id, "deepseek-v4-pro");
1624            }
1625            _ => panic!("Expected ModelChange"),
1626        }
1627    }
1628
1629    #[test]
1630    fn test_session_compaction() {
1631        let tmp = TempDir::new().unwrap();
1632        let sessions_dir = tmp.path().join("sessions");
1633        let cwd = tmp.path().join("project");
1634        std::fs::create_dir_all(&cwd).unwrap();
1635
1636        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1637        sm.append_compaction("Earlier work summarized", "entry_kept", 5000);
1638
1639        match &sm.entries()[0] {
1640            SessionEntry::Compaction(e) => {
1641                assert_eq!(e.summary, "Earlier work summarized");
1642                assert_eq!(e.first_kept_entry_id, "entry_kept");
1643                assert_eq!(e.tokens_before, 5000);
1644            }
1645            _ => panic!("Expected Compaction"),
1646        }
1647    }
1648
1649    #[test]
1650    fn test_session_label() {
1651        let tmp = TempDir::new().unwrap();
1652        let sessions_dir = tmp.path().join("sessions");
1653        let cwd = tmp.path().join("project");
1654        std::fs::create_dir_all(&cwd).unwrap();
1655
1656        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1657        let msg_id = sm.append_message(&make_agent_message(Role::User, "important message"));
1658        sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1659
1660        // Set label
1661        sm.append_label_change(&msg_id, Some("important"));
1662        assert_eq!(sm.label(&msg_id), Some("important"));
1663
1664        // Clear label
1665        sm.append_label_change(&msg_id, None);
1666        assert_eq!(sm.label(&msg_id), None);
1667    }
1668
1669    #[test]
1670    fn test_session_branch_navigation() {
1671        let tmp = TempDir::new().unwrap();
1672        let sessions_dir = tmp.path().join("sessions");
1673        let cwd = tmp.path().join("project");
1674        std::fs::create_dir_all(&cwd).unwrap();
1675
1676        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1677        let m1 = sm.append_message(&make_agent_message(Role::User, "one"));
1678        sm.append_message(&make_agent_message(Role::Assistant, "response one"));
1679        let _m2 = sm.append_message(&make_agent_message(Role::User, "two"));
1680        sm.append_message(&make_agent_message(Role::Assistant, "response two"));
1681
1682        // Current leaf is after last message
1683        assert_eq!(sm.entries().len(), 4);
1684
1685        // Branch back to first user message
1686        sm.set_branch(&m1).unwrap();
1687
1688        // Append a new branch
1689        sm.append_message(&make_agent_message(Role::Assistant, "alternate response"));
1690
1691        // We now have 5 entries (original 4 + new branch entry)
1692        assert_eq!(sm.entries().len(), 5);
1693
1694        // Build context from current leaf — should have 3 messages (m1, branch asst, nothing after)
1695        let context = sm.build_session_context();
1696        assert_eq!(context.messages.len(), 2); // user "one" + assistant "alternate response"
1697    }
1698
1699    #[test]
1700    fn test_session_reset_leaf() {
1701        let tmp = TempDir::new().unwrap();
1702        let sessions_dir = tmp.path().join("sessions");
1703        let cwd = tmp.path().join("project");
1704        std::fs::create_dir_all(&cwd).unwrap();
1705
1706        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1707        sm.append_message(&make_agent_message(Role::User, "one"));
1708        sm.append_message(&make_agent_message(Role::Assistant, "response"));
1709
1710        sm.reset_leaf();
1711        assert!(sm.leaf_id().is_none());
1712
1713        // Append from reset state (parentId = null)
1714        sm.append_message(&make_agent_message(Role::User, "fresh start"));
1715        assert_eq!(sm.entries().len(), 3);
1716    }
1717
1718    #[test]
1719    fn test_session_branch_summary() {
1720        let tmp = TempDir::new().unwrap();
1721        let sessions_dir = tmp.path().join("sessions");
1722        let cwd = tmp.path().join("project");
1723        std::fs::create_dir_all(&cwd).unwrap();
1724
1725        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1726        sm.append_message(&make_agent_message(Role::User, "one"));
1727        sm.append_message(&make_agent_message(Role::Assistant, "response"));
1728
1729        sm.append_branch_summary("root", "Abandoned path summary");
1730
1731        match &sm.entries()[2] {
1732            SessionEntry::BranchSummary(e) => {
1733                assert_eq!(e.summary, "Abandoned path summary");
1734                assert_eq!(e.from_id, "root");
1735            }
1736            _ => panic!("Expected BranchSummary"),
1737        }
1738    }
1739
1740    #[test]
1741    fn test_session_children() {
1742        let tmp = TempDir::new().unwrap();
1743        let sessions_dir = tmp.path().join("sessions");
1744        let cwd = tmp.path().join("project");
1745        std::fs::create_dir_all(&cwd).unwrap();
1746
1747        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1748        let m1 = sm.append_message(&make_agent_message(Role::User, "one"));
1749        sm.append_message(&make_agent_message(Role::Assistant, "response"));
1750
1751        // m1 should have the assistant as child
1752        let children = sm.children(&m1);
1753        assert_eq!(children.len(), 1);
1754    }
1755
1756    #[test]
1757    fn test_session_custom_entry() {
1758        let tmp = TempDir::new().unwrap();
1759        let sessions_dir = tmp.path().join("sessions");
1760        let cwd = tmp.path().join("project");
1761        std::fs::create_dir_all(&cwd).unwrap();
1762
1763        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1764        sm.append_message(&make_agent_message(Role::User, "one"));
1765        sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1766        sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
1767
1768        match &sm.entries()[2] {
1769            SessionEntry::Custom(e) => {
1770                assert_eq!(e.custom_type, "my_ext");
1771                assert_eq!(e.data["key"], "value");
1772            }
1773            _ => panic!("Expected Custom"),
1774        }
1775    }
1776
1777    #[test]
1778    fn test_find_most_recent_session() {
1779        let tmp = TempDir::new().unwrap();
1780        let sessions_dir = tmp.path().join("sessions");
1781        let cwd = tmp.path().join("project");
1782        std::fs::create_dir_all(&sessions_dir).unwrap();
1783        std::fs::create_dir_all(&cwd).unwrap();
1784
1785        // Create first session
1786        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
1787        sm1.append_message(&make_agent_message(Role::User, "old"));
1788        sm1.append_message(&make_agent_message(Role::Assistant, "old"));
1789        let _path1 = sm1.session_file().unwrap().to_path_buf();
1790        drop(sm1);
1791
1792        std::thread::sleep(std::time::Duration::from_millis(10));
1793
1794        // Create second session (more recent)
1795        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
1796        sm2.append_message(&make_agent_message(Role::User, "new"));
1797        sm2.append_message(&make_agent_message(Role::Assistant, "new"));
1798        let path2 = sm2.session_file().unwrap().to_path_buf();
1799        drop(sm2);
1800
1801        let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
1802        assert_eq!(most_recent, path2);
1803    }
1804
1805    // ── Corruption handling ───────────────────────────────────────────
1806
1807    #[test]
1808    fn test_corrupt_empty_file_is_recovered() {
1809        let tmp = TempDir::new().unwrap();
1810        let sessions_dir = tmp.path().join("sessions");
1811        let cwd = tmp.path().join("project");
1812        std::fs::create_dir_all(&sessions_dir).unwrap();
1813        std::fs::create_dir_all(&cwd).unwrap();
1814
1815        // Create an empty JSONL file
1816        let file_path = sessions_dir.join("empty.jsonl");
1817        std::fs::write(&file_path, "").unwrap();
1818
1819        // Opening an empty file should not panic — should start fresh
1820        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1821        assert!(!sm.session_id().is_empty());
1822        assert!(sm.entries().is_empty());
1823        assert_eq!(sm.session_file().unwrap(), file_path);
1824    }
1825
1826    #[test]
1827    fn test_corrupt_garbage_file_is_recovered() {
1828        let tmp = TempDir::new().unwrap();
1829        let sessions_dir = tmp.path().join("sessions");
1830        let cwd = tmp.path().join("project");
1831        std::fs::create_dir_all(&sessions_dir).unwrap();
1832        std::fs::create_dir_all(&cwd).unwrap();
1833
1834        // Write complete garbage
1835        let file_path = sessions_dir.join("garbage.jsonl");
1836        std::fs::write(
1837            &file_path,
1838            "this is not json\nneither is this\n{half-json\n",
1839        )
1840        .unwrap();
1841
1842        // Should recover gracefully
1843        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1844        assert!(!sm.session_id().is_empty());
1845        assert!(sm.entries().is_empty());
1846    }
1847
1848    #[test]
1849    fn test_corrupt_header_only_file_is_kept() {
1850        let tmp = TempDir::new().unwrap();
1851        let sessions_dir = tmp.path().join("sessions");
1852        let cwd = tmp.path().join("project");
1853        std::fs::create_dir_all(&sessions_dir).unwrap();
1854        std::fs::create_dir_all(&cwd).unwrap();
1855
1856        // Create a session, get its header, then write just the header line
1857        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1858        sm.append_message(&make_agent_message(Role::User, "test"));
1859        sm.append_message(&make_agent_message(Role::Assistant, "ok"));
1860        let original_id = sm.session_id().to_string();
1861        let file_path = sm.session_file().unwrap().to_path_buf();
1862        drop(sm);
1863
1864        // Read the header line and write only that
1865        let content = std::fs::read_to_string(&file_path).unwrap();
1866        let header_line = content.lines().next().unwrap();
1867        std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
1868
1869        // Open — should keep the session (header exists, just no entries)
1870        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1871        assert_eq!(sm.session_id(), &original_id);
1872        assert!(sm.entries().is_empty());
1873    }
1874
1875    #[test]
1876    fn test_corrupt_malformed_lines_are_skipped() {
1877        let tmp = TempDir::new().unwrap();
1878        let sessions_dir = tmp.path().join("sessions");
1879        let cwd = tmp.path().join("project");
1880        std::fs::create_dir_all(&sessions_dir).unwrap();
1881        std::fs::create_dir_all(&cwd).unwrap();
1882
1883        // Create a valid session
1884        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1885        sm.append_message(&make_agent_message(Role::User, "valid message"));
1886        sm.append_message(&make_agent_message(Role::Assistant, "valid response"));
1887        let file_path = sm.session_file().unwrap().to_path_buf();
1888        drop(sm);
1889
1890        // Append garbage lines to the file
1891        let mut content = std::fs::read_to_string(&file_path).unwrap();
1892        content.push_str("this is garbage\n");
1893        content.push_str("{incomplete json\n");
1894        content.push('\n'); // blank line
1895        std::fs::write(&file_path, &content).unwrap();
1896
1897        // Open — valid entries should be loaded, garbage skipped
1898        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1899        let ctx = sm.build_session_context();
1900        assert_eq!(ctx.messages.len(), 2);
1901        assert_eq!(ctx.messages[0].content, "valid message");
1902        assert_eq!(ctx.messages[1].content, "valid response");
1903    }
1904
1905    #[test]
1906    fn test_corrupt_missing_header_uses_new_id() {
1907        let tmp = TempDir::new().unwrap();
1908        let sessions_dir = tmp.path().join("sessions");
1909        let cwd = tmp.path().join("project");
1910        std::fs::create_dir_all(&sessions_dir).unwrap();
1911        std::fs::create_dir_all(&cwd).unwrap();
1912
1913        // Write only valid entries but no session header
1914        let entry = SessionEntry::Message(MessageEntry {
1915            id: "msg1".to_string(),
1916            parent_id: None,
1917            timestamp: "2026-01-01T00:00:00Z".to_string(),
1918            message: make_agent_message(Role::User, "orphan message"),
1919        });
1920        let json = serde_json::to_string(&entry).unwrap();
1921        let file_path = sessions_dir.join("no_header.jsonl");
1922        std::fs::write(&file_path, format!("{}\n", json)).unwrap();
1923
1924        // Open — should generate new ID, load entries
1925        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1926        assert!(!sm.session_id().is_empty());
1927        assert_eq!(sm.entries().len(), 1);
1928    }
1929
1930    #[test]
1931    fn test_corrupt_file_then_append_works() {
1932        let tmp = TempDir::new().unwrap();
1933        let sessions_dir = tmp.path().join("sessions");
1934        let cwd = tmp.path().join("project");
1935        std::fs::create_dir_all(&sessions_dir).unwrap();
1936        std::fs::create_dir_all(&cwd).unwrap();
1937
1938        // Start with a corrupted file
1939        let file_path = sessions_dir.join("recovered.jsonl");
1940        std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
1941
1942        // Open — recovers
1943        let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
1944        assert!(sm.entries().is_empty());
1945
1946        // Should be able to append normally
1947        sm.append_message(&make_agent_message(Role::User, "fresh start"));
1948        sm.append_message(&make_agent_message(Role::Assistant, "fresh response"));
1949
1950        let ctx = sm.build_session_context();
1951        assert_eq!(ctx.messages.len(), 2);
1952        assert_eq!(ctx.messages[0].content, "fresh start");
1953
1954        // Verify file was rewritten with valid content
1955        let content = std::fs::read_to_string(&file_path).unwrap();
1956        assert!(content.contains("fresh start"));
1957        assert!(!content.contains("garbage"));
1958    }
1959
1960    #[test]
1961    fn test_corrupt_all_lines_malformed_is_empty() {
1962        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
1963        assert!(entries.is_empty());
1964    }
1965
1966    #[test]
1967    fn test_corrupt_malformed_line_returns_none() {
1968        let result = parse_session_entry_line("not valid json");
1969        assert!(result.is_none());
1970    }
1971
1972    #[test]
1973    fn test_corrupt_blank_lines_are_skipped() {
1974        let result = parse_session_entry_line("");
1975        assert!(result.is_none());
1976        let result = parse_session_entry_line("   ");
1977        assert!(result.is_none());
1978    }
1979
1980    #[test]
1981    fn test_corrupt_header_line_malformed_returns_none() {
1982        let result = read_session_header(Path::new("/nonexistent"));
1983        assert!(result.is_none());
1984    }
1985}