Skip to main content

rab/agent/
session.rs

1use crate::agent::session_storage::{InMemorySessionStorage, JsonlSessionStorage, SessionStorage};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::fs;
6use std::io::Write;
7use std::path::{Path, PathBuf};
8use yoagent::types::AgentMessage;
9
10// ── Constants ───────────────────────────────────────────────────────
11
12pub const CURRENT_SESSION_VERSION: u32 = 3;
13
14// ── Session header ──────────────────────────────────────────────────
15
16/// The first entry in every session file.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18#[serde(rename_all = "camelCase")]
19pub struct SessionHeader {
20    #[serde(rename = "type")]
21    pub type_: String, // always "session"
22    #[serde(default)]
23    pub version: Option<u32>,
24    pub id: String,
25    pub timestamp: String,
26    pub cwd: String,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub parent_session: Option<String>,
29}
30
31// ── Entry types ─────────────────────────────────────────────────────
32
33/// A session entry - one JSON line in the session file.
34///
35/// Uses serde's internally-tagged enum with `type` field for discrimination.
36#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(tag = "type")]
38pub enum SessionEntry {
39    #[serde(rename = "message")]
40    Message(MessageEntry),
41    #[serde(rename = "thinking_level_change")]
42    ThinkingLevelChange(ThinkingLevelChangeEntry),
43    #[serde(rename = "model_change")]
44    ModelChange(ModelChangeEntry),
45    #[serde(rename = "active_tools_change")]
46    ActiveToolsChange(ActiveToolsChangeEntry),
47    #[serde(rename = "compaction")]
48    Compaction(CompactionEntry),
49    #[serde(rename = "branch_summary")]
50    BranchSummary(BranchSummaryEntry),
51    #[serde(rename = "session_info")]
52    SessionInfo(SessionInfoEntry),
53    #[serde(rename = "label")]
54    Label(LabelEntry),
55    #[serde(rename = "custom")]
56    Custom(CustomEntry),
57    #[serde(rename = "custom_message")]
58    CustomMessage(CustomMessageEntry),
59    #[serde(rename = "leaf")]
60    Leaf(LeafEntry),
61}
62
63impl SessionEntry {
64    pub fn id(&self) -> &str {
65        match self {
66            SessionEntry::Message(e) => &e.id,
67            SessionEntry::ThinkingLevelChange(e) => &e.id,
68            SessionEntry::ModelChange(e) => &e.id,
69            SessionEntry::ActiveToolsChange(e) => &e.id,
70            SessionEntry::Compaction(e) => &e.id,
71            SessionEntry::BranchSummary(e) => &e.id,
72            SessionEntry::SessionInfo(e) => &e.id,
73            SessionEntry::Label(e) => &e.id,
74            SessionEntry::Custom(e) => &e.id,
75            SessionEntry::CustomMessage(e) => &e.id,
76            SessionEntry::Leaf(e) => &e.id,
77        }
78    }
79
80    pub fn parent_id(&self) -> Option<&str> {
81        match self {
82            SessionEntry::Message(e) => e.parent_id.as_deref(),
83            SessionEntry::ThinkingLevelChange(e) => e.parent_id.as_deref(),
84            SessionEntry::ModelChange(e) => e.parent_id.as_deref(),
85            SessionEntry::ActiveToolsChange(e) => e.parent_id.as_deref(),
86            SessionEntry::Compaction(e) => e.parent_id.as_deref(),
87            SessionEntry::BranchSummary(e) => e.parent_id.as_deref(),
88            SessionEntry::SessionInfo(e) => e.parent_id.as_deref(),
89            SessionEntry::Label(e) => e.parent_id.as_deref(),
90            SessionEntry::Custom(e) => e.parent_id.as_deref(),
91            SessionEntry::CustomMessage(e) => e.parent_id.as_deref(),
92            SessionEntry::Leaf(e) => e.parent_id.as_deref(),
93        }
94    }
95
96    pub fn timestamp(&self) -> &str {
97        match self {
98            SessionEntry::Message(e) => &e.timestamp,
99            SessionEntry::ThinkingLevelChange(e) => &e.timestamp,
100            SessionEntry::ModelChange(e) => &e.timestamp,
101            SessionEntry::ActiveToolsChange(e) => &e.timestamp,
102            SessionEntry::Compaction(e) => &e.timestamp,
103            SessionEntry::BranchSummary(e) => &e.timestamp,
104            SessionEntry::SessionInfo(e) => &e.timestamp,
105            SessionEntry::Label(e) => &e.timestamp,
106            SessionEntry::Custom(e) => &e.timestamp,
107            SessionEntry::CustomMessage(e) => &e.timestamp,
108            SessionEntry::Leaf(e) => &e.timestamp,
109        }
110    }
111}
112
113/// Base fields shared by all entries.
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct MessageEntry {
117    pub id: String,
118    #[serde(skip_serializing_if = "Option::is_none")]
119    pub parent_id: Option<String>,
120    pub timestamp: String,
121    pub message: AgentMessage,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125#[serde(rename_all = "camelCase")]
126pub struct ThinkingLevelChangeEntry {
127    pub id: String,
128    #[serde(skip_serializing_if = "Option::is_none")]
129    pub parent_id: Option<String>,
130    pub timestamp: String,
131    pub thinking_level: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct ModelChangeEntry {
137    pub id: String,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub parent_id: Option<String>,
140    pub timestamp: String,
141    pub provider: String,
142    pub model_id: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146#[serde(rename_all = "camelCase")]
147pub struct ActiveToolsChangeEntry {
148    pub id: String,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub parent_id: Option<String>,
151    pub timestamp: String,
152    pub active_tool_names: Vec<String>,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(rename_all = "camelCase")]
157pub struct CompactionEntry {
158    pub id: String,
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub parent_id: Option<String>,
161    pub timestamp: String,
162    pub summary: String,
163    pub first_kept_entry_id: String,
164    pub tokens_before: u64,
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub details: Option<serde_json::Value>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub from_hook: Option<bool>,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
172#[serde(rename_all = "camelCase")]
173pub struct BranchSummaryEntry {
174    pub id: String,
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub parent_id: Option<String>,
177    pub timestamp: String,
178    pub from_id: String,
179    pub summary: String,
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub details: Option<serde_json::Value>,
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub from_hook: Option<bool>,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187#[serde(rename_all = "camelCase")]
188pub struct SessionInfoEntry {
189    pub id: String,
190    #[serde(skip_serializing_if = "Option::is_none")]
191    pub parent_id: Option<String>,
192    pub timestamp: String,
193    pub name: String,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
197#[serde(rename_all = "camelCase")]
198pub struct LabelEntry {
199    pub id: String,
200    #[serde(skip_serializing_if = "Option::is_none")]
201    pub parent_id: Option<String>,
202    pub timestamp: String,
203    pub target_id: String,
204    #[serde(skip_serializing_if = "Option::is_none")]
205    pub label: Option<String>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
209#[serde(rename_all = "camelCase")]
210pub struct CustomEntry {
211    pub id: String,
212    #[serde(skip_serializing_if = "Option::is_none")]
213    pub parent_id: Option<String>,
214    pub timestamp: String,
215    pub custom_type: String,
216    pub data: serde_json::Value,
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
220#[serde(rename_all = "camelCase")]
221pub struct CustomMessageEntry {
222    pub id: String,
223    #[serde(skip_serializing_if = "Option::is_none")]
224    pub parent_id: Option<String>,
225    pub timestamp: String,
226    pub custom_type: String,
227    pub content: serde_json::Value,
228    #[serde(default)]
229    pub display: bool,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub details: Option<serde_json::Value>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
235#[serde(rename_all = "camelCase")]
236pub struct LeafEntry {
237    pub id: String,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub parent_id: Option<String>,
240    pub timestamp: String,
241    #[serde(skip_serializing_if = "Option::is_none")]
242    pub target_id: Option<String>,
243}
244
245// ── SessionInfo (for listing / display) ─────────────────────────────
246
247/// Lightweight metadata about a session, used for listing and selection.
248#[derive(Debug, Clone)]
249pub struct SessionInfo {
250    pub path: PathBuf,
251    pub id: String,
252    pub cwd: String,
253    pub name: Option<String>,
254    pub parent_session_path: Option<String>,
255    pub created: DateTime<Utc>,
256    pub modified: DateTime<Utc>,
257    pub message_count: usize,
258    pub first_message: String,
259    /// All messages concatenated (for text search).
260    pub all_messages_text: String,
261}
262
263// ── SessionTreeNode ─────────────────────────────────────────────────
264
265/// A node in the session tree, with resolved children and labels.
266#[derive(Debug, Clone)]
267pub struct SessionTreeNode {
268    pub entry: SessionEntry,
269    pub children: Vec<SessionTreeNode>,
270    pub label: Option<String>,
271    pub label_timestamp: Option<String>,
272}
273
274// ── NewSessionOptions ───────────────────────────────────────────────
275
276/// Options for creating a new session.
277#[derive(Debug, Clone, Default)]
278pub struct NewSessionOptions {
279    pub id: Option<String>,
280    pub parent_session: Option<String>,
281}
282
283// ── SessionContext (resolved messages for LLM) ──────────────────────
284
285/// Resolved conversation context sent to the LLM.
286/// Pi-compatible: includes resolved thinking level, model, and active tool names.
287#[derive(Debug, Clone)]
288pub struct SessionContext {
289    pub messages: Vec<AgentMessage>,
290    pub thinking_level: String,
291    pub model: Option<(String, String)>,
292    pub active_tool_names: Option<Vec<String>>,
293}
294
295// ── JSONL read/write ────────────────────────────────────────────────
296
297/// Parse a single line as a SessionEntry. Returns None for empty/malformed lines.
298pub fn parse_session_entry_line(line: &str) -> Option<SessionEntry> {
299    let line = line.trim();
300    if line.is_empty() {
301        return None;
302    }
303    serde_json::from_str(line).ok()
304}
305
306/// Parse a single line as a SessionHeader.
307pub fn parse_session_header_line(line: &str) -> Option<SessionHeader> {
308    let line = line.trim();
309    if line.is_empty() {
310        return None;
311    }
312    let header: SessionHeader = serde_json::from_str(line).ok()?;
313    if header.type_ != "session" {
314        return None;
315    }
316    Some(header)
317}
318
319/// Read the session header from a JSONL file (first line only).
320pub fn read_session_header(path: &Path) -> Option<SessionHeader> {
321    let content = fs::read_to_string(path).ok()?;
322    let first_line = content.lines().next()?;
323    parse_session_header_line(first_line)
324}
325
326const SESSION_READ_BUFFER_SIZE: usize = 1024 * 1024; // 1MB
327
328/// Load header + entries from a session JSONL file using buffered reading.
329/// Pi-compatible: uses a 1MB buffer for efficient reading of large files.
330/// Returns (header, entries). Returns (None, empty) if file is missing/corrupted.
331pub fn load_session_from_file(path: &Path) -> (Option<SessionHeader>, Vec<SessionEntry>) {
332    let file = match std::fs::File::open(path) {
333        Ok(f) => f,
334        Err(_) => return (None, vec![]),
335    };
336
337    use std::io::Read;
338    let mut reader = std::io::BufReader::with_capacity(SESSION_READ_BUFFER_SIZE, file);
339    let mut content = String::new();
340    if reader.read_to_string(&mut content).is_err() {
341        return (None, vec![]);
342    }
343
344    let mut header: Option<SessionHeader> = None;
345    let mut entries: Vec<SessionEntry> = Vec::new();
346
347    for (i, line_str) in content.lines().enumerate() {
348        let line = line_str.trim();
349        if line.is_empty() {
350            continue;
351        }
352
353        if i == 0 {
354            // First line must be session header, or the file is invalid
355            header = parse_session_header_line(line);
356            if header.is_none() {
357                // Invalid session file - return empty
358                return (None, vec![]);
359            }
360            continue;
361        }
362
363        if let Some(entry) = parse_session_entry_line(line) {
364            entries.push(entry);
365        }
366        // Malformed lines are skipped (pi-compatible)
367    }
368
369    (header, entries)
370}
371
372/// Load all entries from a session JSONL file (backward-compatible wrapper).
373pub fn load_entries_from_file(path: &Path) -> Vec<SessionEntry> {
374    load_session_from_file(path).1
375}
376
377/// Write entries to a session file (used for initial write / rewrite).
378/// Does NOT write the session header - caller must include it.
379pub fn write_entries_to_file(
380    path: &Path,
381    header: &SessionHeader,
382    entries: &[SessionEntry],
383) -> std::io::Result<()> {
384    if let Some(parent) = path.parent() {
385        fs::create_dir_all(parent)?;
386    }
387    let mut content = serde_json::to_string(header).map_err(std::io::Error::from)?;
388    content.push('\n');
389    for entry in entries {
390        let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
391        content.push_str(&line);
392        content.push('\n');
393    }
394    fs::write(path, &content)
395}
396
397/// Append a single entry to the session file (one JSON line).
398pub fn append_entry_to_file(path: &Path, entry: &SessionEntry) -> std::io::Result<()> {
399    let line = serde_json::to_string(entry).map_err(std::io::Error::from)?;
400    let content = format!("{}\n", line);
401    std::fs::OpenOptions::new()
402        .create(true)
403        .append(true)
404        .open(path)?
405        .write_all(content.as_bytes())
406}
407
408// ── CWD encoding ────────────────────────────────────────────────────
409
410/// Encode a working directory path into a safe directory name.
411/// Mirrors pi's encoding: strip leading /, replace / \ : with -, wrap in --...--
412pub fn encode_cwd_for_dir(cwd: &Path) -> String {
413    let s = cwd.to_string_lossy();
414    let cleaned = s
415        .trim_start_matches('/')
416        .trim_start_matches('\\')
417        .replace(['/', '\\', ':'], "-");
418    format!("--{}--", cleaned)
419}
420
421/// Get the default session directory for a cwd.
422pub fn get_default_session_dir(cwd: &Path) -> PathBuf {
423    let rab_dir = directories::BaseDirs::new()
424        .expect("Could not determine home directory")
425        .home_dir()
426        .join(".rab");
427    rab_dir.join("sessions").join(encode_cwd_for_dir(cwd))
428}
429
430/// Generate a unique ID for session entries (8 hex chars, collision-checked).
431pub fn generate_entry_id(by_id: &HashMap<String, SessionEntry>) -> String {
432    for _ in 0..100 {
433        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
434        if !by_id.contains_key(&id) {
435            return id;
436        }
437    }
438    // Fallback to full UUID
439    uuid::Uuid::new_v4().to_string()
440}
441
442// ── Session (Pi-compatible high-level wrapper) ──────────────────────
443
444use crate::agent::session_storage::SessionMetadata;
445
446/// High-level session wrapper, matching pi's `Session` class.
447///
448/// Owns a `SessionStorage` and provides entry construction, context building,
449/// branch navigation, and metadata access. All `append_*` methods generate
450/// typed entries with auto-generated IDs, parent chains, and timestamps.
451pub struct Session {
452    storage: Box<dyn SessionStorage>,
453}
454
455impl Session {
456    /// Wrap an existing storage backend.
457    pub fn new(storage: Box<dyn SessionStorage>) -> Self {
458        Self { storage }
459    }
460
461    /// Access the underlying storage.
462    pub fn get_storage(&self) -> &dyn SessionStorage {
463        self.storage.as_ref()
464    }
465
466    /// Mutably access the underlying storage.
467    pub fn get_storage_mut(&mut self) -> &mut dyn SessionStorage {
468        self.storage.as_mut()
469    }
470
471    /// Consume and return the underlying storage.
472    pub fn into_storage(self) -> Box<dyn SessionStorage> {
473        self.storage
474    }
475
476    // ── Delegation to storage ──────────────────────────────────
477
478    pub fn metadata(&self) -> SessionMetadata {
479        self.storage.metadata()
480    }
481
482    pub fn get_leaf_id(&self) -> Option<String> {
483        self.storage.get_leaf_id()
484    }
485
486    pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
487        self.storage.get_entry(id)
488    }
489
490    pub fn get_entries(&self) -> Vec<SessionEntry> {
491        self.storage.get_entries()
492    }
493
494    pub fn find_entries(&self, type_name: &str) -> Vec<SessionEntry> {
495        self.storage.find_entries(type_name)
496    }
497
498    pub fn get_label(&self, id: &str) -> Option<String> {
499        self.storage.get_label(id)
500    }
501
502    /// Get the path from root to the given leaf (or current leaf if None).
503    /// Pi-compatible: delegates to storage's `get_path_to_root`.
504    pub fn get_branch(&self, from_id: Option<&str>) -> Result<Vec<SessionEntry>, String> {
505        self.storage.get_path_to_root(from_id)
506    }
507
508    /// Build the session context (messages + metadata) for the LLM.
509    /// Pi-compatible: uses `build_session_context()` from this module.
510    pub fn build_context(&self) -> SessionContext {
511        let path = self.get_branch(None).unwrap_or_default();
512        build_session_context(&path)
513    }
514
515    /// Alias for `build_context` — pi-compatible naming.
516    pub fn build_session_context(&self) -> SessionContext {
517        self.build_context()
518    }
519
520    /// Convenience: session ID from metadata.
521    pub fn session_id(&self) -> String {
522        self.metadata().id
523    }
524
525    /// Convenience: session file path from metadata.
526    pub fn session_file(&self) -> Option<PathBuf> {
527        self.metadata().path
528    }
529
530    /// Convenience: session display name.
531    pub fn session_name(&self) -> Option<String> {
532        self.get_session_name()
533    }
534
535    /// Get the latest session name from session_info entries.
536    pub fn get_session_name(&self) -> Option<String> {
537        let entries = self.find_entries("session_info");
538        let last = entries.last()?;
539        if let SessionEntry::SessionInfo(e) = last {
540            let name = e.name.trim();
541            if name.is_empty() {
542                None
543            } else {
544                Some(name.to_string())
545            }
546        } else {
547            None
548        }
549    }
550
551    // ── Entry construction (typed append methods) ───────────────
552
553    /// Append a conversation message. Returns the entry id.
554    pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
555        let entry = SessionEntry::Message(MessageEntry {
556            id: self.storage.create_entry_id(),
557            parent_id: self.storage.get_leaf_id(),
558            timestamp: chrono::Utc::now().to_rfc3339(),
559            message: message.clone(),
560        });
561        let id = entry.id().to_string();
562        self.storage.append_entry(entry).unwrap_or_else(|e| {
563            eprintln!("Warning: failed to append message: {}", e);
564        });
565        id
566    }
567
568    /// Append a thinking level change. Returns the entry id.
569    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
570        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
571            id: self.storage.create_entry_id(),
572            parent_id: self.storage.get_leaf_id(),
573            timestamp: chrono::Utc::now().to_rfc3339(),
574            thinking_level: thinking_level.to_string(),
575        });
576        let id = entry.id().to_string();
577        self.storage.append_entry(entry).unwrap_or_else(|e| {
578            eprintln!("Warning: failed to append thinking level change: {}", e);
579        });
580        id
581    }
582
583    /// Append a model change. Returns the entry id.
584    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
585        let entry = SessionEntry::ModelChange(ModelChangeEntry {
586            id: self.storage.create_entry_id(),
587            parent_id: self.storage.get_leaf_id(),
588            timestamp: chrono::Utc::now().to_rfc3339(),
589            provider: provider.to_string(),
590            model_id: model_id.to_string(),
591        });
592        let id = entry.id().to_string();
593        self.storage.append_entry(entry).unwrap_or_else(|e| {
594            eprintln!("Warning: failed to append model change: {}", e);
595        });
596        id
597    }
598
599    /// Append an active tools change. Returns the entry id.
600    pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
601        let entry = SessionEntry::ActiveToolsChange(ActiveToolsChangeEntry {
602            id: self.storage.create_entry_id(),
603            parent_id: self.storage.get_leaf_id(),
604            timestamp: chrono::Utc::now().to_rfc3339(),
605            active_tool_names: active_tool_names.to_vec(),
606        });
607        let id = entry.id().to_string();
608        self.storage.append_entry(entry).unwrap_or_else(|e| {
609            eprintln!("Warning: failed to append active tools change: {}", e);
610        });
611        id
612    }
613
614    /// Append a compaction summary. Returns the entry id.
615    pub fn append_compaction(
616        &mut self,
617        summary: &str,
618        first_kept_entry_id: &str,
619        tokens_before: u64,
620        details: Option<serde_json::Value>,
621        from_hook: Option<bool>,
622    ) -> String {
623        let entry = SessionEntry::Compaction(CompactionEntry {
624            id: self.storage.create_entry_id(),
625            parent_id: self.storage.get_leaf_id(),
626            timestamp: chrono::Utc::now().to_rfc3339(),
627            summary: summary.to_string(),
628            first_kept_entry_id: first_kept_entry_id.to_string(),
629            tokens_before,
630            details,
631            from_hook,
632        });
633        let id = entry.id().to_string();
634        self.storage.append_entry(entry).unwrap_or_else(|e| {
635            eprintln!("Warning: failed to append compaction: {}", e);
636        });
637        id
638    }
639
640    /// Append a session info entry (display name). Returns the entry id.
641    pub fn append_session_info(&mut self, name: &str) -> String {
642        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
643            id: self.storage.create_entry_id(),
644            parent_id: self.storage.get_leaf_id(),
645            timestamp: chrono::Utc::now().to_rfc3339(),
646            name: name.trim().to_string(),
647        });
648        let id = entry.id().to_string();
649        self.storage.append_entry(entry).unwrap_or_else(|e| {
650            eprintln!("Warning: failed to append session info: {}", e);
651        });
652        id
653    }
654
655    /// Append a branch summary. Returns the entry id.
656    pub fn append_branch_summary(
657        &mut self,
658        from_id: &str,
659        summary: &str,
660        details: Option<serde_json::Value>,
661        from_hook: Option<bool>,
662    ) -> String {
663        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
664            id: self.storage.create_entry_id(),
665            parent_id: self.storage.get_leaf_id(),
666            timestamp: chrono::Utc::now().to_rfc3339(),
667            from_id: from_id.to_string(),
668            summary: summary.to_string(),
669            details,
670            from_hook,
671        });
672        let id = entry.id().to_string();
673        self.storage.append_entry(entry).unwrap_or_else(|e| {
674            eprintln!("Warning: failed to append branch summary: {}", e);
675        });
676        id
677    }
678
679    /// Append a label change (bookmark/unbookmark). Returns the entry id.
680    pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
681        let entry = SessionEntry::Label(LabelEntry {
682            id: self.storage.create_entry_id(),
683            parent_id: self.storage.get_leaf_id(),
684            timestamp: chrono::Utc::now().to_rfc3339(),
685            target_id: target_id.to_string(),
686            label: label.map(|s| s.to_string()),
687        });
688        let id = entry.id().to_string();
689        self.storage.append_entry(entry).unwrap_or_else(|e| {
690            eprintln!("Warning: failed to append label change: {}", e);
691        });
692        id
693    }
694
695    /// Append a custom entry (extension data). Returns the entry id.
696    pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
697        let entry = SessionEntry::Custom(CustomEntry {
698            id: self.storage.create_entry_id(),
699            parent_id: self.storage.get_leaf_id(),
700            timestamp: chrono::Utc::now().to_rfc3339(),
701            custom_type: custom_type.to_string(),
702            data,
703        });
704        let id = entry.id().to_string();
705        self.storage.append_entry(entry).unwrap_or_else(|e| {
706            eprintln!("Warning: failed to append custom entry: {}", e);
707        });
708        id
709    }
710
711    /// Append a custom message entry (pi-compatible extension message). Returns the entry id.
712    pub fn append_custom_message_entry(
713        &mut self,
714        custom_type: &str,
715        content: serde_json::Value,
716        display: bool,
717        details: Option<serde_json::Value>,
718    ) -> String {
719        let entry = SessionEntry::CustomMessage(CustomMessageEntry {
720            id: self.storage.create_entry_id(),
721            parent_id: self.storage.get_leaf_id(),
722            timestamp: chrono::Utc::now().to_rfc3339(),
723            custom_type: custom_type.to_string(),
724            content,
725            display,
726            details,
727        });
728        let id = entry.id().to_string();
729        self.storage.append_entry(entry).unwrap_or_else(|e| {
730            eprintln!("Warning: failed to append custom message: {}", e);
731        });
732        id
733    }
734
735    // ── Tree navigation ───────────────────────────────────────────
736
737    /// Move the leaf pointer to an earlier entry, optionally with a summary.
738    /// Pi-compatible: atomically moves leaf and appends a BranchSummaryEntry.
739    /// Returns the entry id of the BranchSummaryEntry if a summary was provided.
740    pub fn move_to(
741        &mut self,
742        entry_id: Option<&str>,
743        summary: Option<(String, Option<serde_json::Value>, Option<bool>)>,
744    ) -> Result<Option<String>, String> {
745        // Validate target exists
746        if let Some(ref id) = entry_id
747            && self.get_entry(id).is_none()
748        {
749            return Err(format!("Entry {} not found", id));
750        }
751        // Persist leaf via storage
752        self.storage.set_leaf_id(entry_id)?;
753
754        // Optionally append BranchSummaryEntry
755        if let Some((summary_text, details, from_hook)) = summary {
756            let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
757                id: self.storage.create_entry_id(),
758                parent_id: entry_id.map(|s| s.to_string()),
759                timestamp: chrono::Utc::now().to_rfc3339(),
760                from_id: entry_id.unwrap_or("root").to_string(),
761                summary: summary_text,
762                details,
763                from_hook,
764            });
765            let id = entry.id().to_string();
766            self.storage.append_entry(entry).unwrap_or_else(|e| {
767                eprintln!("Warning: failed to append branch summary: {}", e);
768            });
769            Ok(Some(id))
770        } else {
771            Ok(None)
772        }
773    }
774
775    /// Reset the leaf to the given entry (in-memory + leaf entry persisted).
776    /// Pi-compatible: delegates to `set_leaf_id` on storage.
777    pub fn set_leaf_id(&mut self, leaf_id: Option<&str>) -> Result<(), String> {
778        self.storage.set_leaf_id(leaf_id)
779    }
780
781    /// Reset leaf to null (before any entries).
782    pub fn reset_leaf(&mut self) -> Result<(), String> {
783        self.storage.set_leaf_id(None)
784    }
785}
786
787/// Build the session context from a resolved branch path.
788///
789/// Pi-compatible: walks path to find latest thinking level, model, active tools,
790/// and handles compaction by replacing compacted entries with a summary message.
791pub fn build_session_context(path: &[SessionEntry]) -> SessionContext {
792    let mut thinking_level = "off".to_string();
793    let mut model: Option<(String, String)> = None;
794    let mut active_tool_names: Option<Vec<String>> = None;
795    let mut compaction_entry: Option<&CompactionEntry> = None;
796
797    for entry in path {
798        match entry {
799            SessionEntry::ThinkingLevelChange(e) => {
800                thinking_level = e.thinking_level.clone();
801            }
802            SessionEntry::ModelChange(e) => {
803                model = Some((e.provider.clone(), e.model_id.clone()));
804            }
805            SessionEntry::ActiveToolsChange(e) => {
806                active_tool_names = Some(e.active_tool_names.clone());
807            }
808            SessionEntry::Compaction(e) => {
809                compaction_entry = Some(e);
810            }
811            _ => {}
812        }
813    }
814
815    // Pi-compatible: fallback — extract model from assistant messages if no explicit model_change
816    if model.is_none() {
817        for entry in path {
818            if let SessionEntry::Message(e) = entry
819                && let yoagent::types::AgentMessage::Llm(yoagent::types::Message::Assistant {
820                    model: ref m,
821                    provider: ref p,
822                    ..
823                }) = e.message
824                && !m.is_empty()
825                && !p.is_empty()
826            {
827                model = Some((p.clone(), m.clone()));
828                break;
829            }
830        }
831    }
832
833    let messages = if let Some(compaction) = compaction_entry {
834        let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
835
836        // 1. Compaction summary message (pi-compatible: user role with XML wrapping)
837        let comp_text = format!(
838            "The conversation history before this point was compacted into the following summary:\n\n<summary>\n{}\n</summary>",
839            compaction.summary
840        );
841        msgs.push(yoagent::types::AgentMessage::Llm(
842            yoagent::types::Message::User {
843                content: vec![yoagent::types::Content::Text { text: comp_text }],
844                timestamp: chrono::Utc::now().timestamp_millis() as u64,
845            },
846        ));
847
848        // 2. Find compaction entry index
849        let compaction_idx = path
850            .iter()
851            .position(|e| matches!(e, SessionEntry::Compaction(ce) if ce.id == compaction.id));
852
853        if let Some(cidx) = compaction_idx {
854            // Entries BEFORE the compaction: only those at/after firstKeptEntryId
855            let mut found_first_kept = false;
856            for entry in path.iter().take(cidx) {
857                if entry.id() == compaction.first_kept_entry_id {
858                    found_first_kept = true;
859                }
860                if found_first_kept {
861                    append_entry_to_message_list(entry, &mut msgs);
862                }
863            }
864
865            // Entries AFTER the compaction: include all
866            for entry in path.iter().skip(cidx + 1) {
867                append_entry_to_message_list(entry, &mut msgs);
868            }
869        } else {
870            // Fallback: include all entries
871            for entry in path {
872                append_entry_to_message_list(entry, &mut msgs);
873            }
874        }
875
876        msgs
877    } else {
878        // No compaction: include all convertible entries
879        let mut msgs: Vec<yoagent::types::AgentMessage> = Vec::new();
880        for entry in path {
881            append_entry_to_message_list(entry, &mut msgs);
882        }
883        msgs
884    };
885
886    SessionContext {
887        messages,
888        thinking_level,
889        model,
890        active_tool_names,
891    }
892}
893
894/// Convert a session tree entry to an `AgentMessage` and append to the list.
895/// Pi-compatible: handles `message`, `custom_message`, and `branch_summary` entries.
896/// Skips provider/diagnostic error messages — their empty (or error-text-only)
897/// content would cause the provider to reject subsequent requests.
898fn append_entry_to_message_list(
899    entry: &SessionEntry,
900    msgs: &mut Vec<yoagent::types::AgentMessage>,
901) {
902    match entry {
903        SessionEntry::Message(e) => {
904            // Skip provider/diagnostic error messages
905            if crate::agent::types::message_error(&e.message).is_some() {
906                return;
907            }
908            msgs.push(e.message.clone());
909        }
910        SessionEntry::CustomMessage(e) => {
911            msgs.push(yoagent::types::AgentMessage::Extension(
912                yoagent::types::ExtensionMessage::new(
913                    &e.custom_type,
914                    serde_json::json!({ "text": e.content.get("text").and_then(|v| v.as_str()).unwrap_or(""), "display": e.display }),
915                ),
916            ));
917        }
918        SessionEntry::BranchSummary(e) if !e.summary.is_empty() => {
919            // Pi-compatible: user role with XML summary wrapping
920            let bs_text = format!(
921                "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n{}\n</summary>",
922                e.summary
923            );
924            msgs.push(yoagent::types::AgentMessage::Llm(
925                yoagent::types::Message::User {
926                    content: vec![yoagent::types::Content::Text { text: bs_text }],
927                    timestamp: chrono::Utc::now().timestamp_millis() as u64,
928                },
929            ));
930        }
931        _ => {}
932    }
933}
934
935// ── SessionManager ──────────────────────────────────────────────────
936
937/// Manages conversation sessions as append-only trees in JSONL files.
938///
939/// Each entry has an id and parentId forming a tree structure.
940/// Appending creates a child of the current leaf. Branching moves the
941/// leaf to an earlier entry, allowing new branches without modifying history.
942pub struct SessionManager {
943    /// The high-level session wrapper.
944    session: Session,
945    /// Session storage directory on disk.
946    session_dir: PathBuf,
947    /// Working directory for this session.
948    cwd: PathBuf,
949    /// Whether session persistence is enabled.
950    persist: bool,
951    /// Whether the session file has been written at least once.
952    flushed: bool,
953}
954
955impl SessionManager {
956    // ── Construction ─────────────────────────────────────────────
957
958    /// Create a SessionManager wrapping an existing Session.
959    pub fn with_session(
960        session: Session,
961        session_dir: PathBuf,
962        cwd: PathBuf,
963        persist: bool,
964    ) -> Self {
965        Self {
966            session,
967            session_dir,
968            cwd,
969            persist,
970            flushed: false,
971        }
972    }
973
974    /// Create a new persisted session.
975    /// Pi-compatible: defers file creation until first assistant message (lazy write).
976    fn create_persisted(
977        cwd: &Path,
978        session_dir: &Path,
979        options: Option<&NewSessionOptions>,
980    ) -> Self {
981        let id = options
982            .and_then(|o| o.id.as_deref())
983            .map(|s| s.to_string())
984            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
985        let created_at = chrono::Utc::now().to_rfc3339();
986
987        // Use in-memory storage initially — no file created yet (lazy write).
988        let meta = crate::agent::session_storage::SessionMetadata {
989            id: id.clone(),
990            created_at: created_at.clone(),
991            cwd: cwd.to_string_lossy().to_string(),
992            path: None, // Path will be set when flushed
993            parent_session_path: options.and_then(|o| o.parent_session.clone()),
994        };
995        let storage = InMemorySessionStorage::new(meta);
996        let session = Session::new(Box::new(storage));
997        Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), true)
998    }
999
1000    /// Open an existing session file.
1001    fn open_session(path: &Path, session_dir: &Path, cwd_override: Option<&Path>) -> Self {
1002        let cwd = cwd_override
1003            .map(|p| p.to_path_buf())
1004            .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")));
1005
1006        let storage: Box<dyn SessionStorage> = match JsonlSessionStorage::open(path.to_path_buf()) {
1007            Ok(s) => Box::new(s),
1008            Err(e) => {
1009                eprintln!("Warning: failed to open session: {}, creating new", e);
1010                // Fall back: create a fresh file-backed session at the same path (overwrite)
1011                let id = uuid::Uuid::new_v4().to_string();
1012                match JsonlSessionStorage::create(
1013                    path.to_path_buf(),
1014                    &cwd.to_string_lossy(),
1015                    &id,
1016                    None,
1017                ) {
1018                    Ok(s) => Box::new(s),
1019                    Err(e2) => {
1020                        eprintln!("Warning: failed to create session file: {}", e2);
1021                        Box::new(InMemorySessionStorage::new(
1022                            crate::agent::session_storage::SessionMetadata {
1023                                id,
1024                                created_at: chrono::Utc::now().to_rfc3339(),
1025                                cwd: cwd.to_string_lossy().to_string(),
1026                                path: Some(path.to_path_buf()),
1027                                parent_session_path: None,
1028                            },
1029                        ))
1030                    }
1031                }
1032            }
1033        };
1034        let cwd = cwd_override
1035            .map(|p| p.to_path_buf())
1036            .unwrap_or_else(|| PathBuf::from(storage.metadata().cwd));
1037        let session = Session::new(storage);
1038        Self::with_session(session, session_dir.to_path_buf(), cwd, true)
1039    }
1040
1041    /// Create an in-memory (non-persisted) session.
1042    fn create_in_memory(cwd: &Path, session_dir: &Path) -> Self {
1043        let meta = crate::agent::session_storage::SessionMetadata {
1044            id: uuid::Uuid::new_v4().to_string(),
1045            created_at: chrono::Utc::now().to_rfc3339(),
1046            cwd: cwd.to_string_lossy().to_string(),
1047            path: None,
1048            parent_session_path: None,
1049        };
1050        let storage = InMemorySessionStorage::new(meta);
1051        let session = Session::new(Box::new(storage));
1052        Self::with_session(session, session_dir.to_path_buf(), cwd.to_path_buf(), false)
1053    }
1054
1055    /// Create a new session (overwrites current entries).
1056    /// Pi-compatible: defers writing to disk until first assistant message.
1057    pub fn new_session(&mut self, options: Option<&NewSessionOptions>) {
1058        let id = options
1059            .and_then(|o| o.id.as_deref())
1060            .map(|s| s.to_string())
1061            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1062        let created_at = chrono::Utc::now().to_rfc3339();
1063
1064        // Always create in-memory initially (lazy write).
1065        // ensure_flushed() will create the file on first assistant message.
1066        let meta = crate::agent::session_storage::SessionMetadata {
1067            id,
1068            created_at,
1069            cwd: self.cwd.to_string_lossy().to_string(),
1070            path: None,
1071            parent_session_path: options.and_then(|o| o.parent_session.clone()),
1072        };
1073        let storage = InMemorySessionStorage::new(meta);
1074        self.session = Session::new(Box::new(storage));
1075        self.flushed = false;
1076    }
1077
1078    /// Ensure the session file has been written (lazy write).
1079    /// Migrates from in-memory to file-backed storage, writing header + all entries.
1080    /// Called before first assistant message.
1081    pub fn ensure_flushed(&mut self) {
1082        if self.flushed || !self.persist {
1083            return;
1084        }
1085
1086        let id = self.session.metadata().id;
1087        let created_at = self.session.metadata().created_at.clone();
1088        let cwd_str = self.cwd.to_string_lossy().to_string();
1089        let parent_session = self.session.metadata().parent_session_path.clone();
1090        let file_ts = created_at.replace([':', '.'], "-");
1091        let file_path = self.session_dir.join(format!("{}_{}.jsonl", file_ts, id));
1092
1093        // Get existing entries before replacing storage
1094        let existing_entries = self.session.get_entries();
1095
1096        // Create file-backed storage and copy entries
1097        match JsonlSessionStorage::create(file_path.clone(), &cwd_str, &id, parent_session) {
1098            Ok(mut file_storage) => {
1099                // Write all existing entries to file
1100                for entry in &existing_entries {
1101                    if let Err(e) = file_storage.append_entry(entry.clone()) {
1102                        eprintln!("Warning: failed to write entry to session file: {}", e);
1103                    }
1104                }
1105                self.session = Session::new(Box::new(file_storage));
1106                self.flushed = true;
1107            }
1108            Err(e) => {
1109                eprintln!("Warning: failed to create session file: {}", e);
1110                // Stay in-memory but mark as "flushed" to avoid repeated attempts
1111                self.flushed = true;
1112            }
1113        }
1114    }
1115
1116    // ── Public: Info ──────────────────────────────────────────────
1117
1118    pub fn is_persisted(&self) -> bool {
1119        self.persist
1120    }
1121
1122    pub fn cwd(&self) -> &Path {
1123        &self.cwd
1124    }
1125
1126    pub fn session_dir(&self) -> &Path {
1127        &self.session_dir
1128    }
1129
1130    /// Returns true if using the default cwd-encoded session directory.
1131    pub fn uses_default_session_dir(&self) -> bool {
1132        self.session_dir == get_default_session_dir(&self.cwd)
1133    }
1134
1135    pub fn session_id(&self) -> String {
1136        self.session.metadata().id
1137    }
1138
1139    pub fn session_file(&self) -> Option<PathBuf> {
1140        self.session.metadata().path
1141    }
1142
1143    pub fn leaf_id(&self) -> Option<String> {
1144        self.session.get_leaf_id()
1145    }
1146
1147    /// Get the current session name.
1148    pub fn session_name(&self) -> Option<String> {
1149        self.session.get_session_name()
1150    }
1151
1152    /// Get the underlying Session reference.
1153    pub fn session(&self) -> &Session {
1154        &self.session
1155    }
1156
1157    /// Get the underlying Session mutable reference.
1158    pub fn session_mut(&mut self) -> &mut Session {
1159        &mut self.session
1160    }
1161
1162    /// Consume and return the inner Session.
1163    pub fn into_session(self) -> Session {
1164        self.session
1165    }
1166
1167    // ── Public: Info (pi-compatible methods) ──────────────────────
1168
1169    /// Get the current leaf entry (pi-compatible).
1170    pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1171        self.leaf_id().and_then(|id| self.entry(&id))
1172    }
1173
1174    /// Get the session as a tree structure with resolved children and labels (pi-compatible).
1175    pub fn get_tree(&self) -> Vec<SessionTreeNode> {
1176        let entries = self.session.get_entries();
1177        let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
1178
1179        for entry in &entries {
1180            let label = self.session.get_label(entry.id());
1181            node_map.insert(
1182                entry.id().to_string(),
1183                SessionTreeNode {
1184                    entry: entry.clone(),
1185                    children: Vec::new(),
1186                    label,
1187                    label_timestamp: None,
1188                },
1189            );
1190        }
1191
1192        let child_edges: Vec<(Option<String>, String)> = entries
1193            .iter()
1194            .map(|e| (e.parent_id().map(|s| s.to_string()), e.id().to_string()))
1195            .collect();
1196
1197        let mut child_additions: Vec<(String, SessionTreeNode)> = Vec::new();
1198        let mut roots: Vec<String> = Vec::new();
1199        for (parent_id, child_id) in &child_edges {
1200            if let Some(pid) = parent_id {
1201                if !node_map.contains_key(pid) {
1202                    roots.push(child_id.clone());
1203                } else if let Some(child) = node_map.get(child_id) {
1204                    child_additions.push((pid.clone(), child.clone()));
1205                }
1206            } else {
1207                roots.push(child_id.clone());
1208            }
1209        }
1210        for (pid, child) in child_additions {
1211            if let Some(parent) = node_map.get_mut(&pid) {
1212                parent.children.push(child);
1213            }
1214        }
1215
1216        fn sort_tree(node: &mut SessionTreeNode) {
1217            node.children
1218                .sort_by_key(|c| c.entry.timestamp().to_string());
1219            for child in &mut node.children {
1220                sort_tree(child);
1221            }
1222        }
1223
1224        let mut result: Vec<SessionTreeNode> =
1225            roots.iter().filter_map(|id| node_map.remove(id)).collect();
1226        for root in &mut result {
1227            sort_tree(root);
1228        }
1229
1230        result
1231    }
1232
1233    /// Get all session entries (excludes header). Pi-compatible.
1234    pub fn get_entries(&self) -> Vec<SessionEntry> {
1235        self.session.get_entries()
1236    }
1237
1238    // ── Public: Appending (delegated to Session) ──────────────────
1239
1240    pub fn append_message(&mut self, message: &yoagent::types::AgentMessage) -> String {
1241        // Flush before first assistant message (lazy write)
1242        if !self.flushed && self.persist && crate::agent::types::message_is_assistant(message) {
1243            self.ensure_flushed();
1244        }
1245        self.session.append_message(message)
1246    }
1247
1248    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1249        self.session.append_thinking_level_change(thinking_level)
1250    }
1251
1252    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1253        self.session.append_model_change(provider, model_id)
1254    }
1255
1256    pub fn append_session_info(&mut self, name: &str) -> String {
1257        self.session.append_session_info(name)
1258    }
1259
1260    pub fn append_compaction(
1261        &mut self,
1262        summary: &str,
1263        first_kept_entry_id: &str,
1264        tokens_before: u64,
1265        details: Option<serde_json::Value>,
1266        from_hook: Option<bool>,
1267    ) -> String {
1268        self.session.append_compaction(
1269            summary,
1270            first_kept_entry_id,
1271            tokens_before,
1272            details,
1273            from_hook,
1274        )
1275    }
1276
1277    pub fn append_branch_summary(
1278        &mut self,
1279        from_id: &str,
1280        summary: &str,
1281        details: Option<serde_json::Value>,
1282        from_hook: Option<bool>,
1283    ) -> String {
1284        self.session
1285            .append_branch_summary(from_id, summary, details, from_hook)
1286    }
1287
1288    pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
1289        self.session.append_label_change(target_id, label)
1290    }
1291
1292    pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
1293        self.session.append_custom_entry(custom_type, data)
1294    }
1295
1296    pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
1297        self.session.append_active_tools_change(active_tool_names)
1298    }
1299
1300    pub fn append_custom_message_entry(
1301        &mut self,
1302        custom_type: &str,
1303        content: serde_json::Value,
1304        display: bool,
1305        details: Option<serde_json::Value>,
1306    ) -> String {
1307        self.session
1308            .append_custom_message_entry(custom_type, content, display, details)
1309    }
1310
1311    // ── Public: Querying (delegated to Session) ──────────────────
1312
1313    /// Find all entries of a given type (pi-compatible).
1314    pub fn find_entries_by_type(&self, type_name: &str) -> Vec<SessionEntry> {
1315        self.session.find_entries(type_name)
1316    }
1317
1318    /// Get all entries (excludes header).
1319    pub fn entries(&self) -> Vec<SessionEntry> {
1320        self.session.get_entries()
1321    }
1322
1323    /// Look up an entry by id.
1324    pub fn entry(&self, id: &str) -> Option<SessionEntry> {
1325        self.session.get_entry(id)
1326    }
1327
1328    /// Get all direct children of an entry.
1329    pub fn children(&self, parent_id: &str) -> Vec<SessionEntry> {
1330        self.session
1331            .get_entries()
1332            .into_iter()
1333            .filter(|e| e.parent_id() == Some(parent_id))
1334            .collect()
1335    }
1336
1337    /// Walk from entry to root, returning all entries in path order.
1338    pub fn branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1339        self.session.get_branch(from_id).unwrap_or_default()
1340    }
1341
1342    /// Build the session context (messages for LLM with compaction handling).
1343    /// Pi-compatible: delegates to Session::build_context.
1344    pub fn build_session_context(&self) -> SessionContext {
1345        self.session.build_context()
1346    }
1347
1348    /// Get the label for an entry, if any.
1349    pub fn label(&self, id: &str) -> Option<String> {
1350        self.session.get_label(id)
1351    }
1352
1353    // ── Public: Branching ─────────────────────────────────────────
1354
1355    /// Move leaf pointer to an earlier entry (starts a new branch).
1356    /// Pi-compatible: delegates to Session::set_leaf_id.
1357    pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1358        self.session.set_leaf_id(Some(branch_from_id))
1359    }
1360
1361    /// Reset leaf pointer to null (before any entries).
1362    pub fn reset_leaf(&mut self) {
1363        let _ = self.session.reset_leaf();
1364    }
1365
1366    /// Move leaf pointer with a branch summary entry.
1367    /// Pi-compatible: delegates to Session::move_to.
1368    pub fn branch_with_summary(
1369        &mut self,
1370        branch_from_id: Option<&str>,
1371        summary: &str,
1372        details: Option<serde_json::Value>,
1373        from_hook: Option<bool>,
1374    ) -> Result<String, String> {
1375        let summary_tuple = Some((summary.to_string(), details, from_hook));
1376        self.session
1377            .move_to(branch_from_id, summary_tuple)
1378            .map(|opt| opt.unwrap_or_default())
1379    }
1380
1381    // ── Static factories ──────────────────────────────────────────
1382
1383    /// Create a new session.
1384    pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1385        let dir = session_dir
1386            .map(|p| p.to_path_buf())
1387            .unwrap_or_else(|| get_default_session_dir(cwd));
1388        Self::create_persisted(cwd, &dir, None)
1389    }
1390
1391    /// Create a new session with options (pi-compatible).
1392    pub fn create_with_options(
1393        cwd: &Path,
1394        session_dir: Option<&Path>,
1395        options: Option<&NewSessionOptions>,
1396    ) -> Self {
1397        let dir = session_dir
1398            .map(|p| p.to_path_buf())
1399            .unwrap_or_else(|| get_default_session_dir(cwd));
1400        Self::create_persisted(cwd, &dir, options)
1401    }
1402
1403    /// Open a specific session file.
1404    pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1405        let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1406            path.parent()
1407                .map(|p| p.to_path_buf())
1408                .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1409        });
1410        Self::open_session(path, &dir, cwd_override)
1411    }
1412
1413    /// Create an in-memory session (no file persistence).
1414    pub fn in_memory(cwd: &Path) -> Self {
1415        let dir = get_default_session_dir(cwd);
1416        Self::create_in_memory(cwd, &dir)
1417    }
1418
1419    /// Continue the most recent session, or create new if none.
1420    pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1421        let dir = session_dir
1422            .map(|p| p.to_path_buf())
1423            .unwrap_or_else(|| get_default_session_dir(cwd));
1424        let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1425        let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1426        if let Some(path) = most_recent {
1427            Self::open_session(&path, &dir, Some(cwd))
1428        } else {
1429            Self::create_persisted(cwd, &dir, None)
1430        }
1431    }
1432
1433    /// Fork a session from another project directory into the current one.
1434    /// Pi-compatible: creates a new session with the full history from the source session.
1435    pub fn fork_from(
1436        source_path: &Path,
1437        target_cwd: &Path,
1438        session_dir: Option<&Path>,
1439        options: Option<&NewSessionOptions>,
1440    ) -> std::io::Result<Self> {
1441        let resolved_source = source_path;
1442        let resolved_target = target_cwd.to_path_buf();
1443        let dir = session_dir
1444            .map(|p| p.to_path_buf())
1445            .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1446
1447        let source_entries = load_entries_from_file(resolved_source);
1448        if source_entries.is_empty() {
1449            return Err(std::io::Error::new(
1450                std::io::ErrorKind::InvalidData,
1451                "Cannot fork: source session is empty or invalid",
1452            ));
1453        }
1454
1455        let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1456            std::io::Error::new(
1457                std::io::ErrorKind::InvalidData,
1458                "Cannot fork: source session has no header",
1459            )
1460        })?;
1461
1462        // Create new session
1463        let id = options
1464            .and_then(|o| o.id.clone())
1465            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1466        let timestamp = chrono::Utc::now().to_rfc3339();
1467        let file_ts = timestamp.replace([':', '.'], "-");
1468        let file_name = format!("{}_{}.jsonl", file_ts, id);
1469        let target_path = dir.join(&file_name);
1470
1471        // Create storage and write immediately
1472        let mut storage = JsonlSessionStorage::create(
1473            target_path.clone(),
1474            &resolved_target.to_string_lossy(),
1475            &id,
1476            Some(resolved_source.to_string_lossy().to_string()),
1477        )
1478        .map_err(std::io::Error::other)?;
1479
1480        // Push all source entries (re-chaining through append_entry)
1481        for entry in &source_entries {
1482            storage
1483                .append_entry(entry.clone())
1484                .map_err(std::io::Error::other)?;
1485        }
1486
1487        let session = Session::new(Box::new(storage));
1488        Ok(Self::with_session(session, dir, resolved_target, true))
1489    }
1490
1491    /// Create a branched session from a specific leaf path.
1492    /// Extracts the linear path from root to leaf into a new session file.
1493    /// Pi-compatible: creates a new session file, preserving labels.
1494    pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1495        let path = self.branch(Some(leaf_id));
1496        if path.is_empty() {
1497            return None;
1498        }
1499
1500        // Filter out label entries and leaf entries, re-chain parentIds
1501        let mut path_clean: Vec<SessionEntry> = Vec::new();
1502        let mut path_parent_id: Option<String> = None;
1503        for entry in &path {
1504            if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1505                continue;
1506            }
1507            let mut e = entry.clone();
1508            match &mut e {
1509                SessionEntry::Message(m) => m.parent_id = path_parent_id.clone(),
1510                SessionEntry::ThinkingLevelChange(m) => m.parent_id = path_parent_id.clone(),
1511                SessionEntry::ModelChange(m) => m.parent_id = path_parent_id.clone(),
1512                SessionEntry::ActiveToolsChange(m) => m.parent_id = path_parent_id.clone(),
1513                SessionEntry::Compaction(m) => m.parent_id = path_parent_id.clone(),
1514                SessionEntry::BranchSummary(m) => m.parent_id = path_parent_id.clone(),
1515                SessionEntry::SessionInfo(m) => m.parent_id = path_parent_id.clone(),
1516                SessionEntry::Custom(m) => m.parent_id = path_parent_id.clone(),
1517                SessionEntry::CustomMessage(m) => m.parent_id = path_parent_id.clone(),
1518                _ => {}
1519            }
1520            path_parent_id = Some(e.id().to_string());
1521            path_clean.push(e);
1522        }
1523
1524        // Collect labels for entries in the path
1525        let path_entry_ids: std::collections::HashSet<String> =
1526            path_clean.iter().map(|e| e.id().to_string()).collect();
1527        let mut labels_to_write: Vec<(String, String)> = Vec::new();
1528        for id in &path_entry_ids {
1529            if let Some(label) = self.session.get_label(id) {
1530                labels_to_write.push((id.clone(), label));
1531            }
1532        }
1533
1534        let new_session_id = uuid::Uuid::new_v4().to_string();
1535        let timestamp = chrono::Utc::now().to_rfc3339();
1536        let file_ts = timestamp.replace([':', '.'], "-");
1537        let new_session_file = self
1538            .session_dir
1539            .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1540
1541        let cwd_str = self.cwd.to_string_lossy().to_string();
1542
1543        // Write header + cleaned path + label entries to file
1544        if self.persist {
1545            let header = SessionHeader {
1546                type_: "session".to_string(),
1547                version: Some(CURRENT_SESSION_VERSION),
1548                id: new_session_id,
1549                timestamp,
1550                cwd: cwd_str,
1551                parent_session: self
1552                    .session
1553                    .metadata()
1554                    .path
1555                    .map(|p| p.to_string_lossy().to_string()),
1556            };
1557
1558            if let Some(parent) = new_session_file.parent() {
1559                let _ = std::fs::create_dir_all(parent);
1560            }
1561            let mut content = serde_json::to_string(&header).unwrap_or_default();
1562            content.push('\n');
1563            for entry in &path_clean {
1564                let line = serde_json::to_string(entry).unwrap_or_default();
1565                content.push_str(&line);
1566                content.push('\n');
1567            }
1568            for (target_id, label) in &labels_to_write {
1569                let label_entry = SessionEntry::Label(LabelEntry {
1570                    id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1571                    parent_id: path_parent_id.clone(),
1572                    timestamp: chrono::Utc::now().to_rfc3339(),
1573                    target_id: target_id.clone(),
1574                    label: Some(label.clone()),
1575                });
1576                let line = serde_json::to_string(&label_entry).unwrap_or_default();
1577                content.push_str(&line);
1578                content.push('\n');
1579            }
1580            let _ = std::fs::write(&new_session_file, &content);
1581        }
1582
1583        Some(new_session_file)
1584    }
1585
1586    /// List all sessions across all project directories (pi-compatible).
1587    pub fn list_all(session_dir: Option<&Path>) -> Vec<SessionInfo> {
1588        let dir = if let Some(d) = session_dir {
1589            d.to_path_buf()
1590        } else {
1591            directories::BaseDirs::new()
1592                .expect("Could not determine home directory")
1593                .home_dir()
1594                .join(".rab")
1595                .join("sessions")
1596        };
1597
1598        let mut all_sessions: Vec<SessionInfo> = Vec::new();
1599
1600        if let Ok(read_dir) = std::fs::read_dir(&dir) {
1601            for entry in read_dir.flatten() {
1602                let path = entry.path();
1603                if path.is_dir() {
1604                    let sessions = list_sessions(&path);
1605                    all_sessions.extend(sessions);
1606                }
1607            }
1608        }
1609
1610        // Also check the root dir itself for sessions
1611        let root_sessions = list_sessions(&dir);
1612        all_sessions.extend(root_sessions);
1613
1614        all_sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1615        all_sessions
1616    }
1617}
1618
1619/// Find the most recent session file by mtime.
1620pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1621    let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1622    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1623
1624    let entries = std::fs::read_dir(session_dir).ok()?;
1625    for entry in entries.flatten() {
1626        let path = entry.path();
1627        if path.extension().is_some_and(|ext| ext == "jsonl") {
1628            let header = read_session_header(&path);
1629            if let Some(ref h) = header {
1630                if let Some(ref rcwd) = resolved_cwd
1631                    && h.cwd != rcwd.to_string_lossy().as_ref()
1632                {
1633                    continue;
1634                }
1635            } else {
1636                continue;
1637            }
1638            if let Ok(meta) = path.metadata()
1639                && let Ok(mtime) = meta.modified()
1640            {
1641                files.push((path, mtime));
1642            }
1643        }
1644    }
1645
1646    files.sort_by_key(|b| std::cmp::Reverse(b.1));
1647    files.into_iter().next().map(|(path, _)| path)
1648}
1649
1650// ── Session repository (list / delete / fork) ───────────────────────
1651
1652/// List all session metadata in a session directory, newest first.
1653/// Pi-compatible: returns metadata for all valid `.jsonl` sessions.
1654pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1655    let mut sessions: Vec<SessionInfo> = Vec::new();
1656    let dir = match std::fs::read_dir(session_dir) {
1657        Ok(d) => d,
1658        Err(_) => return sessions,
1659    };
1660    for entry in dir.flatten() {
1661        let path = entry.path();
1662        if path.extension().is_some_and(|ext| ext == "jsonl")
1663            && let Some(info) = load_session_info(&path)
1664        {
1665            sessions.push(info);
1666        }
1667    }
1668    sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1669    sessions
1670}
1671
1672/// Load session info from a session file.
1673pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1674    let header = read_session_header(path)?;
1675    let created = DateTime::parse_from_rfc3339(&header.timestamp)
1676        .ok()?
1677        .with_timezone(&Utc);
1678    let modified = path.metadata().ok()?.modified().ok()?;
1679    let modified_dt: DateTime<Utc> = modified.into();
1680    let entries = load_entries_from_file(path);
1681    let name = entries.iter().rev().find_map(|e| {
1682        if let SessionEntry::SessionInfo(si) = e {
1683            let n = si.name.trim();
1684            if n.is_empty() {
1685                None
1686            } else {
1687                Some(n.to_string())
1688            }
1689        } else {
1690            None
1691        }
1692    });
1693    let message_count = entries
1694        .iter()
1695        .filter(|e| matches!(e, SessionEntry::Message(_)))
1696        .count();
1697    let first_message = entries
1698        .iter()
1699        .find_map(|e| {
1700            if let SessionEntry::Message(m) = e {
1701                Some(crate::agent::types::message_text(&m.message))
1702            } else {
1703                None
1704            }
1705        })
1706        .unwrap_or_default();
1707    let all_messages_text = entries
1708        .iter()
1709        .filter_map(|e| {
1710            if let SessionEntry::Message(m) = e {
1711                Some(crate::agent::types::message_text(&m.message))
1712            } else {
1713                None
1714            }
1715        })
1716        .collect::<Vec<_>>()
1717        .join("\n");
1718
1719    Some(SessionInfo {
1720        path: path.to_path_buf(),
1721        id: header.id,
1722        cwd: header.cwd,
1723        name,
1724        parent_session_path: header.parent_session,
1725        created,
1726        modified: modified_dt,
1727        message_count,
1728        first_message,
1729        all_messages_text,
1730    })
1731}
1732
1733/// Delete a session file.
1734pub fn delete_session(path: &Path) -> std::io::Result<()> {
1735    if path.exists() {
1736        std::fs::remove_file(path)?;
1737    }
1738    Ok(())
1739}
1740
1741/// Fork a session: create a new session file containing a copy of entries from the source session
1742/// up to (and including) the entry with the given `entry_id`, or all entries if `entry_id` is None.
1743/// If `entry_id` is provided and `position` is "at", the copy goes up to and including that entry.
1744/// If `position` is "before" (default), the copy goes up to but not including the entry
1745/// (which must be a user message). Pi-compatible.
1746pub fn fork_session(
1747    source_path: &Path,
1748    target_dir: &Path,
1749    entry_id: Option<&str>,
1750    position: Option<&str>,
1751) -> std::io::Result<String> {
1752    let header = read_session_header(source_path).ok_or_else(|| {
1753        std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1754    })?;
1755    let entries = load_entries_from_file(source_path);
1756
1757    // Build by_id map for parent traversal
1758    let by_id: HashMap<String, &SessionEntry> =
1759        entries.iter().map(|e| (e.id().to_string(), e)).collect();
1760
1761    let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1762        // Find the target entry
1763        let target = by_id.get(target_id).ok_or_else(|| {
1764            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1765        })?;
1766
1767        // Determine the effective leaf ID for the fork
1768        let effective_leaf_id = match position.unwrap_or("before") {
1769            "at" => Some(target.id().to_string()),
1770            _ => {
1771                if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1772                {
1773                    return Err(std::io::Error::new(
1774                        std::io::ErrorKind::InvalidInput,
1775                        "Entry is not a user message",
1776                    ));
1777                }
1778                target.parent_id().map(|s| s.to_string())
1779            }
1780        };
1781
1782        // Collect path from effective leaf to root
1783        let mut path: Vec<&SessionEntry> = Vec::new();
1784        let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1785        while let Some(entry) = current {
1786            path.push(entry);
1787            current = entry.parent_id().and_then(|pid| by_id.get(pid));
1788        }
1789        path.reverse();
1790        path.into_iter().cloned().collect()
1791    } else {
1792        entries.clone()
1793    };
1794
1795    // Create the new session
1796    let session_id = uuid::Uuid::new_v4().to_string();
1797    let timestamp = chrono::Utc::now().to_rfc3339();
1798    let file_ts = timestamp.replace([':', '.'], "-");
1799    let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1800    let target_path = target_dir.join(&file_name);
1801
1802    std::fs::create_dir_all(target_dir)?;
1803
1804    let new_header = SessionHeader {
1805        type_: "session".to_string(),
1806        version: Some(CURRENT_SESSION_VERSION),
1807        id: session_id.clone(),
1808        timestamp,
1809        cwd: header.cwd.clone(),
1810        parent_session: Some(source_path.to_string_lossy().to_string()),
1811    };
1812    write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1813
1814    Ok(session_id)
1815}
1816
1817// ── Tests ───────────────────────────────────────────────────────────
1818
1819#[cfg(test)]
1820mod tests {
1821    use super::*;
1822    use crate::agent::types::user_message;
1823    use tempfile::TempDir;
1824
1825    fn make_user_msg(content: &str) -> AgentMessage {
1826        user_message(content)
1827    }
1828
1829    fn make_asst_msg(content: &str) -> AgentMessage {
1830        crate::agent::types::assistant_message(content)
1831    }
1832
1833    // ── Entry serialization round-trip ──────────────────────────────
1834
1835    #[test]
1836    fn test_build_context_tracks_metadata() {
1837        let tmp = TempDir::new().unwrap();
1838        let sessions_dir = tmp.path().join("sessions");
1839        let cwd = tmp.path().join("project");
1840        std::fs::create_dir_all(&cwd).unwrap();
1841
1842        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1843        sm.append_thinking_level_change("high");
1844        sm.append_model_change("opencode_go", "deepseek-v4-pro");
1845        sm.append_active_tools_change(&["read".to_string(), "write".to_string()]);
1846        sm.append_message(&make_user_msg("hello"));
1847        sm.append_message(&make_asst_msg("hi"));
1848
1849        let context = sm.build_session_context();
1850        assert_eq!(context.thinking_level, "high");
1851        assert_eq!(
1852            context.model,
1853            Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1854        );
1855        assert_eq!(
1856            context.active_tool_names,
1857            Some(vec!["read".to_string(), "write".to_string()])
1858        );
1859        assert_eq!(context.messages.len(), 2);
1860    }
1861
1862    #[test]
1863    fn test_build_context_defaults_when_no_metadata() {
1864        let cwd = Path::new("/tmp/test");
1865        let sm = SessionManager::in_memory(cwd);
1866        let context = sm.build_session_context();
1867        assert_eq!(context.thinking_level, "off");
1868        assert!(context.model.is_none());
1869        assert!(context.active_tool_names.is_none());
1870        assert!(context.messages.is_empty());
1871    }
1872
1873    // ── Find entries test ────────────────────────────────────────────
1874
1875    #[test]
1876    fn test_find_entries_by_type() {
1877        let cwd = Path::new("/tmp/test");
1878        let mut sm = SessionManager::in_memory(cwd);
1879        sm.append_message(&make_user_msg("hello"));
1880        sm.append_thinking_level_change("high");
1881        sm.append_model_change("p", "m");
1882        sm.append_session_info("test session");
1883
1884        let messages = sm.find_entries_by_type("message");
1885        assert_eq!(messages.len(), 1);
1886
1887        let thinking = sm.find_entries_by_type("thinking_level_change");
1888        assert_eq!(thinking.len(), 1);
1889
1890        let models = sm.find_entries_by_type("model_change");
1891        assert_eq!(models.len(), 1);
1892
1893        let infos = sm.find_entries_by_type("session_info");
1894        assert_eq!(infos.len(), 1);
1895    }
1896
1897    // ── Session listing / forking tests ──────────────────────────────
1898
1899    #[test]
1900    fn test_list_sessions_empty_dir() {
1901        let tmp = TempDir::new().unwrap();
1902        let sessions = list_sessions(tmp.path());
1903        assert!(sessions.is_empty());
1904    }
1905
1906    #[test]
1907    fn test_list_sessions() {
1908        let tmp = TempDir::new().unwrap();
1909        let sessions_dir = tmp.path().join("sessions");
1910        let cwd = tmp.path().join("project");
1911        std::fs::create_dir_all(&cwd).unwrap();
1912
1913        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1914        sm.append_message(&make_user_msg("first"));
1915        sm.append_message(&make_asst_msg("response"));
1916        let path = sm.session_file().unwrap().to_path_buf();
1917        drop(sm);
1918
1919        let sessions = list_sessions(&sessions_dir);
1920        assert_eq!(sessions.len(), 1);
1921        assert_eq!(sessions[0].path, path);
1922        assert_eq!(sessions[0].message_count, 2);
1923    }
1924
1925    #[test]
1926    fn test_fork_session_all_entries() {
1927        let tmp = TempDir::new().unwrap();
1928        let sessions_dir = tmp.path().join("sessions");
1929        let cwd = tmp.path().join("project");
1930        std::fs::create_dir_all(&cwd).unwrap();
1931
1932        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1933        sm.append_message(&make_user_msg("hello"));
1934        sm.append_message(&make_asst_msg("world"));
1935        let source_path = sm.session_file().unwrap().to_path_buf();
1936        drop(sm);
1937
1938        let target_dir = tmp.path().join("forked");
1939        let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
1940        assert!(!new_id.is_empty());
1941
1942        let sessions = list_sessions(&target_dir);
1943        assert_eq!(sessions.len(), 1);
1944        assert_eq!(sessions[0].message_count, 2);
1945    }
1946
1947    #[test]
1948    fn test_delete_session() {
1949        let tmp = TempDir::new().unwrap();
1950        let path = tmp.path().join("test.jsonl");
1951        std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
1952        assert!(path.exists());
1953        delete_session(&path).unwrap();
1954        assert!(!path.exists());
1955        // deleting non-existent file should be ok
1956        delete_session(&path).unwrap();
1957    }
1958
1959    #[test]
1960    fn test_parse_session_entry_line() {
1961        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1962            id: "abc12345".to_string(),
1963            parent_id: None,
1964            timestamp: "2026-06-19T12:00:00Z".to_string(),
1965            name: "Test session".to_string(),
1966        });
1967        let json = serde_json::to_string(&entry).unwrap();
1968        let parsed = parse_session_entry_line(&json);
1969        assert!(parsed.is_some());
1970    }
1971
1972    #[test]
1973    fn test_parse_session_entry_line_empty() {
1974        assert!(parse_session_entry_line("").is_none());
1975        assert!(parse_session_entry_line("   ").is_none());
1976    }
1977
1978    #[test]
1979    fn test_parse_session_entry_line_malformed() {
1980        assert!(parse_session_entry_line("not valid json").is_none());
1981    }
1982
1983    #[test]
1984    fn test_parse_session_header_line() {
1985        let header = SessionHeader {
1986            type_: "session".to_string(),
1987            version: Some(3),
1988            id: "session123".to_string(),
1989            timestamp: "2026-06-19T12:00:00Z".to_string(),
1990            cwd: "/home/user/project".to_string(),
1991            parent_session: None,
1992        };
1993        let json = serde_json::to_string(&header).unwrap();
1994        let parsed = parse_session_header_line(&json);
1995        assert!(parsed.is_some());
1996        assert_eq!(parsed.unwrap().id, "session123");
1997    }
1998
1999    #[test]
2000    fn test_parse_session_header_line_wrong_type() {
2001        // parse_session_header_line validates type == "session"
2002        let json =
2003            r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2004        let result = parse_session_header_line(json);
2005        assert!(result.is_none());
2006    }
2007
2008    #[test]
2009    fn test_write_and_read_entries() {
2010        let tmp = TempDir::new().unwrap();
2011        let file_path = tmp.path().join("test.jsonl");
2012
2013        let header = SessionHeader {
2014            type_: "session".to_string(),
2015            version: Some(3),
2016            id: "session1".to_string(),
2017            timestamp: "2026-06-19T12:00:00Z".to_string(),
2018            cwd: "/home/user/project".to_string(),
2019            parent_session: None,
2020        };
2021
2022        let entries: Vec<SessionEntry> = vec![
2023            SessionEntry::Message(MessageEntry {
2024                id: "msg1".to_string(),
2025                parent_id: None,
2026                timestamp: "2026-06-19T12:00:01Z".to_string(),
2027                message: make_user_msg("hello"),
2028            }),
2029            SessionEntry::Message(MessageEntry {
2030                id: "msg2".to_string(),
2031                parent_id: Some("msg1".to_string()),
2032                timestamp: "2026-06-19T12:00:02Z".to_string(),
2033                message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2034                    content: vec![yoagent::types::Content::Text {
2035                        text: "hi there".to_string(),
2036                    }],
2037                    stop_reason: yoagent::types::StopReason::Stop,
2038                    model: String::new(),
2039                    provider: String::new(),
2040                    usage: yoagent::types::Usage {
2041                        input: 10,
2042                        output: 5,
2043                        ..Default::default()
2044                    },
2045                    timestamp: 0,
2046                    error_message: None,
2047                }),
2048            }),
2049        ];
2050
2051        write_entries_to_file(&file_path, &header, &entries).unwrap();
2052
2053        // Read back header
2054        let read_header = read_session_header(&file_path).unwrap();
2055        assert_eq!(read_header.id, "session1");
2056
2057        // Read back entries
2058        let read_entries = load_entries_from_file(&file_path);
2059        assert_eq!(read_entries.len(), 2);
2060
2061        match &read_entries[0] {
2062            SessionEntry::Message(e) => {
2063                assert_eq!(e.id, "msg1");
2064                assert!(crate::agent::types::message_is_user(&e.message));
2065                assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2066            }
2067            _ => panic!("Expected Message"),
2068        }
2069
2070        match &read_entries[1] {
2071            SessionEntry::Message(e) => {
2072                assert_eq!(e.id, "msg2");
2073                assert!(crate::agent::types::message_is_assistant(&e.message));
2074                assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2075                assert!(crate::agent::types::message_usage(&e.message).is_some());
2076            }
2077            _ => panic!("Expected Message"),
2078        }
2079    }
2080
2081    #[test]
2082    fn test_append_entry_to_file() {
2083        let tmp = TempDir::new().unwrap();
2084        let file_path = tmp.path().join("append_test.jsonl");
2085
2086        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2087            id: "abc12345".to_string(),
2088            parent_id: None,
2089            timestamp: "2026-06-19T12:00:00Z".to_string(),
2090            name: "Test".to_string(),
2091        });
2092
2093        append_entry_to_file(&file_path, &entry).unwrap();
2094
2095        let content = fs::read_to_string(&file_path).unwrap();
2096        assert!(content.contains("Test"));
2097        assert!(content.contains("abc12345"));
2098    }
2099
2100    #[test]
2101    fn test_load_entries_missing_file() {
2102        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2103        assert!(entries.is_empty());
2104    }
2105
2106    #[test]
2107    fn test_read_session_header_missing_file() {
2108        let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2109        assert!(header.is_none());
2110    }
2111
2112    // ── CWD encoding ────────────────────────────────────────────────
2113
2114    #[test]
2115    fn test_encode_cwd() {
2116        assert_eq!(
2117            encode_cwd_for_dir(Path::new("/home/user/project")),
2118            "--home-user-project--"
2119        );
2120    }
2121
2122    #[test]
2123    fn test_encode_cwd_windows_style() {
2124        assert_eq!(
2125            encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2126            "--C--Users-user-project--"
2127        );
2128    }
2129
2130    #[test]
2131    fn test_encode_cwd_no_leading_slash() {
2132        assert_eq!(
2133            encode_cwd_for_dir(Path::new("home/user/project")),
2134            "--home-user-project--"
2135        );
2136    }
2137
2138    #[test]
2139    fn test_encode_cwd_special_chars() {
2140        assert_eq!(
2141            encode_cwd_for_dir(Path::new("/home/user/my:project")),
2142            "--home-user-my-project--"
2143        );
2144    }
2145
2146    // ── SessionEntry accessors ───────────────────────────────────────
2147
2148    #[test]
2149    fn test_entry_id_accessor() {
2150        let entry = SessionEntry::Message(MessageEntry {
2151            id: "myid".to_string(),
2152            parent_id: None,
2153            timestamp: "2026-06-19T12:00:00Z".to_string(),
2154            message: make_user_msg("hello"),
2155        });
2156        assert_eq!(entry.id(), "myid");
2157    }
2158
2159    #[test]
2160    fn test_entry_parent_id_accessor() {
2161        let entry = SessionEntry::Message(MessageEntry {
2162            id: "myid".to_string(),
2163            parent_id: Some("parent".to_string()),
2164            timestamp: "2026-06-19T12:00:00Z".to_string(),
2165            message: make_user_msg("hello"),
2166        });
2167        assert_eq!(entry.parent_id(), Some("parent"));
2168    }
2169
2170    #[test]
2171    fn test_entry_timestamp_accessor() {
2172        let entry = SessionEntry::Message(MessageEntry {
2173            id: "myid".to_string(),
2174            parent_id: None,
2175            timestamp: "2026-06-19T12:00:00Z".to_string(),
2176            message: make_user_msg("hello"),
2177        });
2178        assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2179    }
2180
2181    // ── generate_entry_id ────────────────────────────────────────────
2182
2183    #[test]
2184    fn test_generate_entry_id_length() {
2185        let map = HashMap::new();
2186        let id = generate_entry_id(&map);
2187        assert_eq!(id.len(), 8);
2188    }
2189
2190    #[test]
2191    fn test_generate_entry_id_hex() {
2192        let map = HashMap::new();
2193        let id = generate_entry_id(&map);
2194        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2195    }
2196
2197    #[test]
2198    fn test_generate_entry_id_collision_fallback() {
2199        // Create a map that has all possible 8-char hex IDs - impossible
2200        // but we test the fallback behavior by only having a collision
2201        // on the first generated ID (unlikely but the code handles it).
2202        // This is more of a smoke test that the function doesn't panic.
2203        let map = HashMap::new();
2204        let id1 = generate_entry_id(&map);
2205        assert!(!id1.is_empty());
2206    }
2207
2208    // ── Deserialize from pi-compatible JSON ──────────────────────────
2209
2210    #[test]
2211    fn test_deserialize_pi_format_message() {
2212        // pi format uses camelCase and "type": "message"
2213        // Message uses yoagent format: role-tagged enum with Vec<Content>
2214        let json = r#"{"type":"message","id":"abc12345","parentId":null,"timestamp":"2026-06-19T12:00:00Z","message":{"role":"user","content":[{"type":"text","text":"hello"}],"timestamp":1718800000000}}"#;
2215        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2216        match entry {
2217            SessionEntry::Message(e) => {
2218                assert_eq!(e.id, "abc12345");
2219                assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2220            }
2221            _ => panic!("Expected Message"),
2222        }
2223    }
2224
2225    #[test]
2226    fn test_deserialize_pi_format_thinking_level() {
2227        let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2228        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2229        match entry {
2230            SessionEntry::ThinkingLevelChange(e) => {
2231                assert_eq!(e.thinking_level, "high");
2232            }
2233            _ => panic!("Expected ThinkingLevelChange"),
2234        }
2235    }
2236
2237    #[test]
2238    fn test_deserialize_pi_format_model_change() {
2239        let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2240        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2241        match entry {
2242            SessionEntry::ModelChange(e) => {
2243                assert_eq!(e.provider, "opencode_go");
2244                assert_eq!(e.model_id, "deepseek-v4-pro");
2245            }
2246            _ => panic!("Expected ModelChange"),
2247        }
2248    }
2249
2250    #[test]
2251    fn test_deserialize_pi_format_compaction() {
2252        let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2253        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2254        match entry {
2255            SessionEntry::Compaction(e) => {
2256                assert_eq!(e.summary, "Earlier conversation summarized");
2257                assert_eq!(e.first_kept_entry_id, "entry123");
2258                assert_eq!(e.tokens_before, 5000);
2259            }
2260            _ => panic!("Expected Compaction"),
2261        }
2262    }
2263
2264    #[test]
2265    fn test_deserialize_pi_format_session_info() {
2266        let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2267        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2268        match entry {
2269            SessionEntry::SessionInfo(e) => {
2270                assert_eq!(e.name, "My session");
2271            }
2272            _ => panic!("Expected SessionInfo"),
2273        }
2274    }
2275
2276    // ── SessionManager ───────────────────────────────────────────────
2277
2278    #[test]
2279    fn test_session_create_in_memory() {
2280        let cwd = Path::new("/tmp/test-project");
2281        let sm = SessionManager::in_memory(cwd);
2282        assert!(!sm.is_persisted());
2283        assert!(!sm.session_id().is_empty());
2284        assert_eq!(sm.cwd(), cwd);
2285        assert!(sm.leaf_id().is_none());
2286        assert!(sm.entries().is_empty());
2287    }
2288
2289    #[test]
2290    fn test_session_create_persisted() {
2291        let tmp = TempDir::new().unwrap();
2292        let sessions_dir = tmp.path().join("sessions");
2293        let cwd = tmp.path().join("project");
2294        std::fs::create_dir_all(&cwd).unwrap();
2295
2296        let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2297        assert!(sm.is_persisted());
2298        assert!(!sm.session_id().is_empty());
2299        // File should NOT exist yet (lazy write: no file path until first assistant)
2300        assert!(
2301            sm.session_file().is_none(),
2302            "session file should not be created until first assistant message (lazy write)"
2303        );
2304        assert!(!sm.flushed);
2305    }
2306
2307    #[test]
2308    fn test_session_append_and_build_context() {
2309        let tmp = TempDir::new().unwrap();
2310        let sessions_dir = tmp.path().join("sessions");
2311        let cwd = tmp.path().join("project");
2312        std::fs::create_dir_all(&cwd).unwrap();
2313
2314        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2315
2316        let user_msg = make_user_msg("hello");
2317        let user_id = sm.append_message(&user_msg);
2318        assert_eq!(sm.leaf_id().as_deref(), Some(user_id.as_str()));
2319
2320        // In-memory entries exist even before flush
2321        assert_eq!(sm.entries().len(), 1);
2322
2323        let assistant_msg = make_asst_msg("hi there");
2324        sm.append_message(&assistant_msg);
2325        assert_eq!(sm.entries().len(), 2);
2326
2327        // After assistant message, file should be created (lazy write)
2328        assert!(
2329            sm.session_file().unwrap().exists(),
2330            "session file should exist after first assistant message"
2331        );
2332
2333        let context = sm.build_session_context();
2334        assert_eq!(context.messages.len(), 2);
2335        assert_eq!(
2336            crate::agent::types::message_text(&context.messages[0]),
2337            "hello"
2338        );
2339        assert_eq!(
2340            crate::agent::types::message_text(&context.messages[1]),
2341            "hi there"
2342        );
2343    }
2344
2345    #[test]
2346    fn test_session_open_existing() {
2347        let tmp = TempDir::new().unwrap();
2348        let sessions_dir = tmp.path().join("sessions");
2349        let cwd = tmp.path().join("project");
2350        std::fs::create_dir_all(&cwd).unwrap();
2351
2352        // Create and populate a session
2353        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2354        sm.append_message(&make_user_msg("first"));
2355        sm.append_message(&make_asst_msg("response"));
2356
2357        let file_path = sm.session_file().unwrap().to_path_buf();
2358        let session_id = sm.session_id().to_string();
2359        drop(sm);
2360
2361        // Open it
2362        let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2363        assert_eq!(sm2.session_id(), session_id);
2364        let context = sm2.build_session_context();
2365        assert_eq!(context.messages.len(), 2);
2366        assert_eq!(
2367            crate::agent::types::message_text(&context.messages[0]),
2368            "first"
2369        );
2370        assert_eq!(
2371            crate::agent::types::message_text(&context.messages[1]),
2372            "response"
2373        );
2374    }
2375
2376    #[test]
2377    fn test_session_continue_recent() {
2378        let tmp = TempDir::new().unwrap();
2379        let sessions_dir = tmp.path().join("sessions");
2380        let cwd = tmp.path().join("project");
2381        std::fs::create_dir_all(&cwd).unwrap();
2382
2383        // First session
2384        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2385        sm1.append_message(&make_user_msg("old session"));
2386        sm1.append_message(&make_asst_msg("old response"));
2387        let _old_id = sm1.session_id().to_string();
2388        drop(sm1);
2389
2390        // Small delay to ensure different mtime
2391        std::thread::sleep(std::time::Duration::from_millis(10));
2392
2393        // Second session (more recent)
2394        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2395        sm2.append_message(&make_user_msg("new session"));
2396        sm2.append_message(&make_asst_msg("new response"));
2397        let new_id = sm2.session_id().to_string();
2398        drop(sm2);
2399
2400        // Continue recent - should get the new one
2401        let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2402        assert_eq!(sm3.session_id(), new_id);
2403        let context = sm3.build_session_context();
2404        assert_eq!(
2405            crate::agent::types::message_text(&context.messages[0]),
2406            "new session"
2407        );
2408    }
2409
2410    #[test]
2411    fn test_session_continue_recent_none_exist() {
2412        let tmp = TempDir::new().unwrap();
2413        let sessions_dir = tmp.path().join("sessions");
2414        let cwd = tmp.path().join("project");
2415        std::fs::create_dir_all(&sessions_dir).unwrap();
2416        std::fs::create_dir_all(&cwd).unwrap();
2417
2418        // No sessions exist - should create new
2419        let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2420        assert!(!sm.session_id().is_empty());
2421        assert!(sm.entries().is_empty());
2422    }
2423
2424    #[test]
2425    fn test_session_name() {
2426        let tmp = TempDir::new().unwrap();
2427        let sessions_dir = tmp.path().join("sessions");
2428        let cwd = tmp.path().join("project");
2429        std::fs::create_dir_all(&cwd).unwrap();
2430
2431        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2432        assert!(sm.session_name().is_none());
2433
2434        sm.append_session_info("My Task");
2435        sm.append_message(&make_user_msg("hello"));
2436        sm.append_message(&make_asst_msg("hi"));
2437        assert_eq!(sm.session_name().as_deref(), Some("My Task"));
2438
2439        // Setting empty name clears it
2440        sm.append_session_info("");
2441        assert!(sm.session_name().is_none());
2442    }
2443
2444    #[test]
2445    fn test_session_thinking_level_change() {
2446        let tmp = TempDir::new().unwrap();
2447        let sessions_dir = tmp.path().join("sessions");
2448        let cwd = tmp.path().join("project");
2449        std::fs::create_dir_all(&cwd).unwrap();
2450
2451        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2452        sm.append_thinking_level_change("high");
2453
2454        assert_eq!(sm.entries().len(), 1);
2455        match &sm.entries()[0] {
2456            SessionEntry::ThinkingLevelChange(e) => {
2457                assert_eq!(e.thinking_level, "high");
2458            }
2459            _ => panic!("Expected ThinkingLevelChange"),
2460        }
2461    }
2462
2463    #[test]
2464    fn test_session_model_change() {
2465        let tmp = TempDir::new().unwrap();
2466        let sessions_dir = tmp.path().join("sessions");
2467        let cwd = tmp.path().join("project");
2468        std::fs::create_dir_all(&cwd).unwrap();
2469
2470        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2471        sm.append_model_change("opencode_go", "deepseek-v4-pro");
2472
2473        assert_eq!(sm.entries().len(), 1);
2474        match &sm.entries()[0] {
2475            SessionEntry::ModelChange(e) => {
2476                assert_eq!(e.provider, "opencode_go");
2477                assert_eq!(e.model_id, "deepseek-v4-pro");
2478            }
2479            _ => panic!("Expected ModelChange"),
2480        }
2481    }
2482
2483    #[test]
2484    fn test_session_compaction() {
2485        let tmp = TempDir::new().unwrap();
2486        let sessions_dir = tmp.path().join("sessions");
2487        let cwd = tmp.path().join("project");
2488        std::fs::create_dir_all(&cwd).unwrap();
2489
2490        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2491        sm.append_compaction("Earlier work summarized", "entry_kept", 5000, None, None);
2492
2493        match &sm.entries()[0] {
2494            SessionEntry::Compaction(e) => {
2495                assert_eq!(e.summary, "Earlier work summarized");
2496                assert_eq!(e.first_kept_entry_id, "entry_kept");
2497                assert_eq!(e.tokens_before, 5000);
2498            }
2499            _ => panic!("Expected Compaction"),
2500        }
2501    }
2502
2503    #[test]
2504    fn test_session_label() {
2505        let tmp = TempDir::new().unwrap();
2506        let sessions_dir = tmp.path().join("sessions");
2507        let cwd = tmp.path().join("project");
2508        std::fs::create_dir_all(&cwd).unwrap();
2509
2510        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2511        let msg_id = sm.append_message(&make_user_msg("important message"));
2512        sm.append_message(&make_asst_msg("ok"));
2513
2514        // Set label
2515        sm.append_label_change(&msg_id, Some("important"));
2516        assert_eq!(sm.label(&msg_id).as_deref(), Some("important"));
2517
2518        // Clear label
2519        sm.append_label_change(&msg_id, None);
2520        assert_eq!(sm.label(&msg_id), None);
2521    }
2522
2523    #[test]
2524    fn test_session_branch_navigation() {
2525        let tmp = TempDir::new().unwrap();
2526        let sessions_dir = tmp.path().join("sessions");
2527        let cwd = tmp.path().join("project");
2528        std::fs::create_dir_all(&cwd).unwrap();
2529
2530        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2531        let m1 = sm.append_message(&make_user_msg("one"));
2532        sm.append_message(&make_asst_msg("response one"));
2533        let _m2 = sm.append_message(&make_user_msg("two"));
2534        sm.append_message(&make_asst_msg("response two"));
2535
2536        // Current leaf is after last message
2537        assert_eq!(sm.entries().len(), 4);
2538
2539        // Branch back to first user message (writes a persistent LeafEntry, pi-compatible)
2540        sm.set_branch(&m1).unwrap();
2541        assert_eq!(sm.entries().len(), 5); // 4 original + 1 leaf entry
2542        assert_eq!(sm.leaf_id().as_deref(), Some(m1.as_str()));
2543
2544        // Append a new branch
2545        sm.append_message(&make_asst_msg("alternate response"));
2546        // Now 6 entries (original 4 + leaf + 1 new message)
2547        assert_eq!(sm.entries().len(), 6);
2548
2549        // Build context from current leaf - should have 2 messages (m1, branch asst)
2550        let context = sm.build_session_context();
2551        assert_eq!(context.messages.len(), 2); // user "one" + assistant "alternate response"
2552        // Verify metadata in context
2553        assert_eq!(context.thinking_level, "off");
2554        assert!(context.model.is_none());
2555        assert!(context.active_tool_names.is_none());
2556    }
2557
2558    #[test]
2559    fn test_session_reset_leaf() {
2560        let tmp = TempDir::new().unwrap();
2561        let sessions_dir = tmp.path().join("sessions");
2562        let cwd = tmp.path().join("project");
2563        std::fs::create_dir_all(&cwd).unwrap();
2564
2565        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2566        sm.append_message(&make_user_msg("one"));
2567        sm.append_message(&make_asst_msg("response"));
2568        assert_eq!(sm.entries().len(), 2);
2569
2570        // Reset leaf (persistent leaf entry, pi-compatible)
2571        sm.reset_leaf();
2572        // Leaf entry was written (type: "leaf", targetId: null)
2573        assert_eq!(sm.entries().len(), 3);
2574        assert!(sm.leaf_id().is_none());
2575
2576        // Append from reset state (parentId should be None since leaf is None)
2577        sm.append_message(&make_user_msg("fresh start"));
2578        assert_eq!(sm.entries().len(), 4);
2579        // Verify fresh start has no parent
2580        match &sm.entries()[3] {
2581            SessionEntry::Message(m) => {
2582                assert!(m.parent_id.is_none());
2583            }
2584            _ => panic!("Expected Message"),
2585        }
2586    }
2587
2588    #[test]
2589    fn test_session_branch_summary() {
2590        let tmp = TempDir::new().unwrap();
2591        let sessions_dir = tmp.path().join("sessions");
2592        let cwd = tmp.path().join("project");
2593        std::fs::create_dir_all(&cwd).unwrap();
2594
2595        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2596        sm.append_message(&make_user_msg("one"));
2597        sm.append_message(&make_asst_msg("response"));
2598
2599        sm.append_branch_summary("root", "Abandoned path summary", None, None);
2600
2601        match &sm.entries()[2] {
2602            SessionEntry::BranchSummary(e) => {
2603                assert_eq!(e.summary, "Abandoned path summary");
2604                assert_eq!(e.from_id, "root");
2605            }
2606            _ => panic!("Expected BranchSummary"),
2607        }
2608    }
2609
2610    #[test]
2611    fn test_session_children() {
2612        let tmp = TempDir::new().unwrap();
2613        let sessions_dir = tmp.path().join("sessions");
2614        let cwd = tmp.path().join("project");
2615        std::fs::create_dir_all(&cwd).unwrap();
2616
2617        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2618        let m1 = sm.append_message(&make_user_msg("one"));
2619        sm.append_message(&make_asst_msg("response"));
2620
2621        // m1 should have the assistant as child
2622        let children = sm.children(&m1);
2623        assert_eq!(children.len(), 1);
2624    }
2625
2626    #[test]
2627    fn test_session_custom_entry() {
2628        let tmp = TempDir::new().unwrap();
2629        let sessions_dir = tmp.path().join("sessions");
2630        let cwd = tmp.path().join("project");
2631        std::fs::create_dir_all(&cwd).unwrap();
2632
2633        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2634        sm.append_message(&make_user_msg("one"));
2635        sm.append_message(&make_asst_msg("ok"));
2636        sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2637
2638        match &sm.entries()[2] {
2639            SessionEntry::Custom(e) => {
2640                assert_eq!(e.custom_type, "my_ext");
2641                assert_eq!(e.data["key"], "value");
2642            }
2643            _ => panic!("Expected Custom"),
2644        }
2645    }
2646
2647    #[test]
2648    fn test_find_most_recent_session() {
2649        let tmp = TempDir::new().unwrap();
2650        let sessions_dir = tmp.path().join("sessions");
2651        let cwd = tmp.path().join("project");
2652        std::fs::create_dir_all(&sessions_dir).unwrap();
2653        std::fs::create_dir_all(&cwd).unwrap();
2654
2655        // Create first session
2656        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2657        sm1.append_message(&make_user_msg("old"));
2658        sm1.append_message(&make_asst_msg("old"));
2659        let _path1 = sm1.session_file().unwrap().to_path_buf();
2660        drop(sm1);
2661
2662        std::thread::sleep(std::time::Duration::from_millis(10));
2663
2664        // Create second session (more recent)
2665        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2666        sm2.append_message(&make_user_msg("new"));
2667        sm2.append_message(&make_asst_msg("new"));
2668        let path2 = sm2.session_file().unwrap().to_path_buf();
2669        drop(sm2);
2670
2671        let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2672        assert_eq!(most_recent, path2);
2673    }
2674
2675    // ── Corruption handling ───────────────────────────────────────────
2676
2677    #[test]
2678    fn test_corrupt_empty_file_is_recovered() {
2679        let tmp = TempDir::new().unwrap();
2680        let sessions_dir = tmp.path().join("sessions");
2681        let cwd = tmp.path().join("project");
2682        std::fs::create_dir_all(&sessions_dir).unwrap();
2683        std::fs::create_dir_all(&cwd).unwrap();
2684
2685        // Create an empty JSONL file
2686        let file_path = sessions_dir.join("empty.jsonl");
2687        std::fs::write(&file_path, "").unwrap();
2688
2689        // Opening an empty file should not panic - should start fresh
2690        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2691        assert!(!sm.session_id().is_empty());
2692        assert!(sm.entries().is_empty());
2693        assert_eq!(sm.session_file().unwrap(), file_path);
2694    }
2695
2696    #[test]
2697    fn test_corrupt_garbage_file_is_recovered() {
2698        let tmp = TempDir::new().unwrap();
2699        let sessions_dir = tmp.path().join("sessions");
2700        let cwd = tmp.path().join("project");
2701        std::fs::create_dir_all(&sessions_dir).unwrap();
2702        std::fs::create_dir_all(&cwd).unwrap();
2703
2704        // Write complete garbage
2705        let file_path = sessions_dir.join("garbage.jsonl");
2706        std::fs::write(
2707            &file_path,
2708            "this is not json\nneither is this\n{half-json\n",
2709        )
2710        .unwrap();
2711
2712        // Should recover gracefully
2713        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2714        assert!(!sm.session_id().is_empty());
2715        assert!(sm.entries().is_empty());
2716    }
2717
2718    #[test]
2719    fn test_corrupt_header_only_file_is_kept() {
2720        let tmp = TempDir::new().unwrap();
2721        let sessions_dir = tmp.path().join("sessions");
2722        let cwd = tmp.path().join("project");
2723        std::fs::create_dir_all(&sessions_dir).unwrap();
2724        std::fs::create_dir_all(&cwd).unwrap();
2725
2726        // Create a session, get its header, then write just the header line
2727        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2728        sm.append_message(&make_user_msg("test"));
2729        sm.append_message(&make_asst_msg("ok"));
2730        let original_id = sm.session_id().to_string();
2731        let file_path = sm.session_file().unwrap().to_path_buf();
2732        drop(sm);
2733
2734        // Read the header line and write only that
2735        let content = std::fs::read_to_string(&file_path).unwrap();
2736        let header_line = content.lines().next().unwrap();
2737        std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2738
2739        // Open - should keep the session (header exists, just no entries)
2740        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2741        assert_eq!(sm.session_id(), original_id);
2742        assert!(sm.entries().is_empty());
2743    }
2744
2745    #[test]
2746    fn test_corrupt_malformed_lines_are_skipped() {
2747        let tmp = TempDir::new().unwrap();
2748        let sessions_dir = tmp.path().join("sessions");
2749        let cwd = tmp.path().join("project");
2750        std::fs::create_dir_all(&sessions_dir).unwrap();
2751        std::fs::create_dir_all(&cwd).unwrap();
2752
2753        // Create a valid session
2754        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2755        sm.append_message(&make_user_msg("valid message"));
2756        sm.append_message(&make_asst_msg("valid response"));
2757        let file_path = sm.session_file().unwrap().to_path_buf();
2758        drop(sm);
2759
2760        // Append garbage lines to the file
2761        let mut content = std::fs::read_to_string(&file_path).unwrap();
2762        content.push_str("this is garbage\n");
2763        content.push_str("{incomplete json\n");
2764        content.push('\n'); // blank line
2765        std::fs::write(&file_path, &content).unwrap();
2766
2767        // Open - valid entries should be loaded, garbage skipped
2768        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2769        let ctx = sm.build_session_context();
2770        assert_eq!(ctx.messages.len(), 2);
2771        assert_eq!(
2772            crate::agent::types::message_text(&ctx.messages[0]),
2773            "valid message"
2774        );
2775        assert_eq!(
2776            crate::agent::types::message_text(&ctx.messages[1]),
2777            "valid response"
2778        );
2779    }
2780
2781    #[test]
2782    fn test_corrupt_missing_header_uses_new_id() {
2783        let tmp = TempDir::new().unwrap();
2784        let sessions_dir = tmp.path().join("sessions");
2785        let cwd = tmp.path().join("project");
2786        std::fs::create_dir_all(&sessions_dir).unwrap();
2787        std::fs::create_dir_all(&cwd).unwrap();
2788
2789        // Write only valid entries but no session header
2790        let entry = SessionEntry::Message(MessageEntry {
2791            id: "msg1".to_string(),
2792            parent_id: None,
2793            timestamp: "2026-01-01T00:00:00Z".to_string(),
2794            message: make_user_msg("orphan message"),
2795        });
2796        let json = serde_json::to_string(&entry).unwrap();
2797        let file_path = sessions_dir.join("no_header.jsonl");
2798        std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2799
2800        // Pi-compatible: no valid session header means the file is invalid.
2801        // Should generate new ID, empty entries (fresh start).
2802        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2803        assert!(!sm.session_id().is_empty());
2804        assert_eq!(sm.entries().len(), 0);
2805    }
2806
2807    #[test]
2808    fn test_corrupt_file_then_append_works() {
2809        let tmp = TempDir::new().unwrap();
2810        let sessions_dir = tmp.path().join("sessions");
2811        let cwd = tmp.path().join("project");
2812        std::fs::create_dir_all(&sessions_dir).unwrap();
2813        std::fs::create_dir_all(&cwd).unwrap();
2814
2815        // Start with a corrupted file
2816        let file_path = sessions_dir.join("recovered.jsonl");
2817        std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2818
2819        // Open - recovers
2820        let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2821        assert!(sm.entries().is_empty());
2822
2823        // Should be able to append normally
2824        sm.append_message(&make_user_msg("fresh start"));
2825        sm.append_message(&make_asst_msg("fresh response"));
2826
2827        let ctx = sm.build_session_context();
2828        assert_eq!(ctx.messages.len(), 2);
2829        assert_eq!(
2830            crate::agent::types::message_text(&ctx.messages[0]),
2831            "fresh start"
2832        );
2833
2834        // Verify file was rewritten with valid content
2835        let content = std::fs::read_to_string(&file_path).unwrap();
2836        assert!(content.contains("fresh start"));
2837        assert!(!content.contains("garbage"));
2838    }
2839
2840    #[test]
2841    fn test_corrupt_all_lines_malformed_is_empty() {
2842        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2843        assert!(entries.is_empty());
2844    }
2845
2846    #[test]
2847    fn test_corrupt_malformed_line_returns_none() {
2848        let result = parse_session_entry_line("not valid json");
2849        assert!(result.is_none());
2850    }
2851
2852    #[test]
2853    fn test_corrupt_blank_lines_are_skipped() {
2854        let result = parse_session_entry_line("");
2855        assert!(result.is_none());
2856        let result = parse_session_entry_line("   ");
2857        assert!(result.is_none());
2858    }
2859
2860    #[test]
2861    fn test_corrupt_header_line_malformed_returns_none() {
2862        let result = read_session_header(Path::new("/nonexistent"));
2863        assert!(result.is_none());
2864    }
2865}