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 on first message (lazy write) to ensure session is persisted
1242        // even if the agent never produces an assistant message (e.g. provider error).
1243        if !self.flushed && self.persist {
1244            self.ensure_flushed();
1245        }
1246        self.session.append_message(message)
1247    }
1248
1249    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1250        self.session.append_thinking_level_change(thinking_level)
1251    }
1252
1253    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1254        self.session.append_model_change(provider, model_id)
1255    }
1256
1257    pub fn append_session_info(&mut self, name: &str) -> String {
1258        self.session.append_session_info(name)
1259    }
1260
1261    pub fn append_compaction(
1262        &mut self,
1263        summary: &str,
1264        first_kept_entry_id: &str,
1265        tokens_before: u64,
1266        details: Option<serde_json::Value>,
1267        from_hook: Option<bool>,
1268    ) -> String {
1269        self.session.append_compaction(
1270            summary,
1271            first_kept_entry_id,
1272            tokens_before,
1273            details,
1274            from_hook,
1275        )
1276    }
1277
1278    pub fn append_branch_summary(
1279        &mut self,
1280        from_id: &str,
1281        summary: &str,
1282        details: Option<serde_json::Value>,
1283        from_hook: Option<bool>,
1284    ) -> String {
1285        self.session
1286            .append_branch_summary(from_id, summary, details, from_hook)
1287    }
1288
1289    pub fn append_label_change(&mut self, target_id: &str, label: Option<&str>) -> String {
1290        self.session.append_label_change(target_id, label)
1291    }
1292
1293    pub fn append_custom_entry(&mut self, custom_type: &str, data: serde_json::Value) -> String {
1294        self.session.append_custom_entry(custom_type, data)
1295    }
1296
1297    pub fn append_active_tools_change(&mut self, active_tool_names: &[String]) -> String {
1298        self.session.append_active_tools_change(active_tool_names)
1299    }
1300
1301    pub fn append_custom_message_entry(
1302        &mut self,
1303        custom_type: &str,
1304        content: serde_json::Value,
1305        display: bool,
1306        details: Option<serde_json::Value>,
1307    ) -> String {
1308        self.session
1309            .append_custom_message_entry(custom_type, content, display, details)
1310    }
1311
1312    // ── Public: Querying (delegated to Session) ──────────────────
1313
1314    /// Find all entries of a given type (pi-compatible).
1315    pub fn find_entries_by_type(&self, type_name: &str) -> Vec<SessionEntry> {
1316        self.session.find_entries(type_name)
1317    }
1318
1319    /// Get all entries (excludes header).
1320    pub fn entries(&self) -> Vec<SessionEntry> {
1321        self.session.get_entries()
1322    }
1323
1324    /// Look up an entry by id.
1325    pub fn entry(&self, id: &str) -> Option<SessionEntry> {
1326        self.session.get_entry(id)
1327    }
1328
1329    /// Get all direct children of an entry.
1330    pub fn children(&self, parent_id: &str) -> Vec<SessionEntry> {
1331        self.session
1332            .get_entries()
1333            .into_iter()
1334            .filter(|e| e.parent_id() == Some(parent_id))
1335            .collect()
1336    }
1337
1338    /// Walk from entry to root, returning all entries in path order.
1339    pub fn branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1340        self.session.get_branch(from_id).unwrap_or_default()
1341    }
1342
1343    /// Build the session context (messages for LLM with compaction handling).
1344    /// Pi-compatible: delegates to Session::build_context.
1345    pub fn build_session_context(&self) -> SessionContext {
1346        self.session.build_context()
1347    }
1348
1349    /// Get the label for an entry, if any.
1350    pub fn label(&self, id: &str) -> Option<String> {
1351        self.session.get_label(id)
1352    }
1353
1354    // ── Public: Branching ─────────────────────────────────────────
1355
1356    /// Move leaf pointer to an earlier entry (starts a new branch).
1357    /// Pi-compatible: delegates to Session::set_leaf_id.
1358    pub fn set_branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1359        self.session.set_leaf_id(Some(branch_from_id))
1360    }
1361
1362    /// Reset leaf pointer to null (before any entries).
1363    pub fn reset_leaf(&mut self) {
1364        let _ = self.session.reset_leaf();
1365    }
1366
1367    /// Move leaf pointer with a branch summary entry.
1368    /// Pi-compatible: delegates to Session::move_to.
1369    pub fn branch_with_summary(
1370        &mut self,
1371        branch_from_id: Option<&str>,
1372        summary: &str,
1373        details: Option<serde_json::Value>,
1374        from_hook: Option<bool>,
1375    ) -> Result<String, String> {
1376        let summary_tuple = Some((summary.to_string(), details, from_hook));
1377        self.session
1378            .move_to(branch_from_id, summary_tuple)
1379            .map(|opt| opt.unwrap_or_default())
1380    }
1381
1382    // ── Static factories ──────────────────────────────────────────
1383
1384    /// Create a new session.
1385    pub fn create(cwd: &Path, session_dir: Option<&Path>) -> Self {
1386        let dir = session_dir
1387            .map(|p| p.to_path_buf())
1388            .unwrap_or_else(|| get_default_session_dir(cwd));
1389        Self::create_persisted(cwd, &dir, None)
1390    }
1391
1392    /// Create a new session with options (pi-compatible).
1393    pub fn create_with_options(
1394        cwd: &Path,
1395        session_dir: Option<&Path>,
1396        options: Option<&NewSessionOptions>,
1397    ) -> Self {
1398        let dir = session_dir
1399            .map(|p| p.to_path_buf())
1400            .unwrap_or_else(|| get_default_session_dir(cwd));
1401        Self::create_persisted(cwd, &dir, options)
1402    }
1403
1404    /// Open a specific session file.
1405    pub fn open(path: &Path, session_dir: Option<&Path>, cwd_override: Option<&Path>) -> Self {
1406        let dir = session_dir.map(|p| p.to_path_buf()).unwrap_or_else(|| {
1407            path.parent()
1408                .map(|p| p.to_path_buf())
1409                .unwrap_or_else(|| get_default_session_dir(&PathBuf::from("/")))
1410        });
1411        Self::open_session(path, &dir, cwd_override)
1412    }
1413
1414    /// Create an in-memory session (no file persistence).
1415    pub fn in_memory(cwd: &Path) -> Self {
1416        let dir = get_default_session_dir(cwd);
1417        Self::create_in_memory(cwd, &dir)
1418    }
1419
1420    /// Continue the most recent session, or create new if none.
1421    pub fn continue_recent(cwd: &Path, session_dir: Option<&Path>) -> Self {
1422        let dir = session_dir
1423            .map(|p| p.to_path_buf())
1424            .unwrap_or_else(|| get_default_session_dir(cwd));
1425        let filter_cwd = session_dir.is_some_and(|sd| sd != get_default_session_dir(cwd));
1426        let most_recent = find_most_recent_session(&dir, if filter_cwd { Some(cwd) } else { None });
1427        if let Some(path) = most_recent {
1428            Self::open_session(&path, &dir, Some(cwd))
1429        } else {
1430            Self::create_persisted(cwd, &dir, None)
1431        }
1432    }
1433
1434    /// Fork a session from another project directory into the current one.
1435    /// Pi-compatible: creates a new session with the full history from the source session.
1436    pub fn fork_from(
1437        source_path: &Path,
1438        target_cwd: &Path,
1439        session_dir: Option<&Path>,
1440        options: Option<&NewSessionOptions>,
1441    ) -> std::io::Result<Self> {
1442        let resolved_source = source_path;
1443        let resolved_target = target_cwd.to_path_buf();
1444        let dir = session_dir
1445            .map(|p| p.to_path_buf())
1446            .unwrap_or_else(|| get_default_session_dir(&resolved_target));
1447
1448        let source_entries = load_entries_from_file(resolved_source);
1449        if source_entries.is_empty() {
1450            return Err(std::io::Error::new(
1451                std::io::ErrorKind::InvalidData,
1452                "Cannot fork: source session is empty or invalid",
1453            ));
1454        }
1455
1456        let _source_header = read_session_header(resolved_source).ok_or_else(|| {
1457            std::io::Error::new(
1458                std::io::ErrorKind::InvalidData,
1459                "Cannot fork: source session has no header",
1460            )
1461        })?;
1462
1463        // Create new session
1464        let id = options
1465            .and_then(|o| o.id.clone())
1466            .unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
1467        let timestamp = chrono::Utc::now().to_rfc3339();
1468        let file_ts = timestamp.replace([':', '.'], "-");
1469        let file_name = format!("{}_{}.jsonl", file_ts, id);
1470        let target_path = dir.join(&file_name);
1471
1472        // Create storage and write immediately
1473        let mut storage = JsonlSessionStorage::create(
1474            target_path.clone(),
1475            &resolved_target.to_string_lossy(),
1476            &id,
1477            Some(resolved_source.to_string_lossy().to_string()),
1478        )
1479        .map_err(std::io::Error::other)?;
1480
1481        // Push all source entries (re-chaining through append_entry)
1482        for entry in &source_entries {
1483            storage
1484                .append_entry(entry.clone())
1485                .map_err(std::io::Error::other)?;
1486        }
1487
1488        let session = Session::new(Box::new(storage));
1489        Ok(Self::with_session(session, dir, resolved_target, true))
1490    }
1491
1492    /// Create a branched session from a specific leaf path.
1493    /// Extracts the linear path from root to leaf into a new session file.
1494    /// Pi-compatible: creates a new session file, preserving labels.
1495    pub fn create_branched_session(&mut self, leaf_id: &str) -> Option<PathBuf> {
1496        let path = self.branch(Some(leaf_id));
1497        if path.is_empty() {
1498            return None;
1499        }
1500
1501        // Filter out label entries and leaf entries, re-chain parentIds
1502        let mut path_clean: Vec<SessionEntry> = Vec::new();
1503        let mut path_parent_id: Option<String> = None;
1504        for entry in &path {
1505            if matches!(entry, SessionEntry::Label(_) | SessionEntry::Leaf(_)) {
1506                continue;
1507            }
1508            let mut e = entry.clone();
1509            match &mut e {
1510                SessionEntry::Message(m) => m.parent_id = path_parent_id.clone(),
1511                SessionEntry::ThinkingLevelChange(m) => m.parent_id = path_parent_id.clone(),
1512                SessionEntry::ModelChange(m) => m.parent_id = path_parent_id.clone(),
1513                SessionEntry::ActiveToolsChange(m) => m.parent_id = path_parent_id.clone(),
1514                SessionEntry::Compaction(m) => m.parent_id = path_parent_id.clone(),
1515                SessionEntry::BranchSummary(m) => m.parent_id = path_parent_id.clone(),
1516                SessionEntry::SessionInfo(m) => m.parent_id = path_parent_id.clone(),
1517                SessionEntry::Custom(m) => m.parent_id = path_parent_id.clone(),
1518                SessionEntry::CustomMessage(m) => m.parent_id = path_parent_id.clone(),
1519                _ => {}
1520            }
1521            path_parent_id = Some(e.id().to_string());
1522            path_clean.push(e);
1523        }
1524
1525        // Collect labels for entries in the path
1526        let path_entry_ids: std::collections::HashSet<String> =
1527            path_clean.iter().map(|e| e.id().to_string()).collect();
1528        let mut labels_to_write: Vec<(String, String)> = Vec::new();
1529        for id in &path_entry_ids {
1530            if let Some(label) = self.session.get_label(id) {
1531                labels_to_write.push((id.clone(), label));
1532            }
1533        }
1534
1535        let new_session_id = uuid::Uuid::new_v4().to_string();
1536        let timestamp = chrono::Utc::now().to_rfc3339();
1537        let file_ts = timestamp.replace([':', '.'], "-");
1538        let new_session_file = self
1539            .session_dir
1540            .join(format!("{}_{}.jsonl", file_ts, new_session_id));
1541
1542        let cwd_str = self.cwd.to_string_lossy().to_string();
1543
1544        // Write header + cleaned path + label entries to file
1545        if self.persist {
1546            let header = SessionHeader {
1547                type_: "session".to_string(),
1548                version: Some(CURRENT_SESSION_VERSION),
1549                id: new_session_id,
1550                timestamp,
1551                cwd: cwd_str,
1552                parent_session: self
1553                    .session
1554                    .metadata()
1555                    .path
1556                    .map(|p| p.to_string_lossy().to_string()),
1557            };
1558
1559            if let Some(parent) = new_session_file.parent() {
1560                let _ = std::fs::create_dir_all(parent);
1561            }
1562            let mut content = serde_json::to_string(&header).unwrap_or_default();
1563            content.push('\n');
1564            for entry in &path_clean {
1565                let line = serde_json::to_string(entry).unwrap_or_default();
1566                content.push_str(&line);
1567                content.push('\n');
1568            }
1569            for (target_id, label) in &labels_to_write {
1570                let label_entry = SessionEntry::Label(LabelEntry {
1571                    id: uuid::Uuid::new_v4().to_string()[..8].to_string(),
1572                    parent_id: path_parent_id.clone(),
1573                    timestamp: chrono::Utc::now().to_rfc3339(),
1574                    target_id: target_id.clone(),
1575                    label: Some(label.clone()),
1576                });
1577                let line = serde_json::to_string(&label_entry).unwrap_or_default();
1578                content.push_str(&line);
1579                content.push('\n');
1580            }
1581            let _ = std::fs::write(&new_session_file, &content);
1582        }
1583
1584        Some(new_session_file)
1585    }
1586
1587    /// List all sessions across all project directories (pi-compatible).
1588    pub fn list_all(session_dir: Option<&Path>) -> Vec<SessionInfo> {
1589        let dir = if let Some(d) = session_dir {
1590            d.to_path_buf()
1591        } else {
1592            directories::BaseDirs::new()
1593                .expect("Could not determine home directory")
1594                .home_dir()
1595                .join(".rab")
1596                .join("sessions")
1597        };
1598
1599        let mut all_sessions: Vec<SessionInfo> = Vec::new();
1600
1601        if let Ok(read_dir) = std::fs::read_dir(&dir) {
1602            for entry in read_dir.flatten() {
1603                let path = entry.path();
1604                if path.is_dir() {
1605                    let sessions = list_sessions(&path);
1606                    all_sessions.extend(sessions);
1607                }
1608            }
1609        }
1610
1611        // Also check the root dir itself for sessions
1612        let root_sessions = list_sessions(&dir);
1613        all_sessions.extend(root_sessions);
1614
1615        all_sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1616        all_sessions
1617    }
1618}
1619
1620/// Find the most recent session file by mtime.
1621pub fn find_most_recent_session(session_dir: &Path, filter_cwd: Option<&Path>) -> Option<PathBuf> {
1622    let resolved_cwd = filter_cwd.map(|c| c.to_path_buf());
1623    let mut files: Vec<(PathBuf, std::time::SystemTime)> = Vec::new();
1624
1625    let entries = std::fs::read_dir(session_dir).ok()?;
1626    for entry in entries.flatten() {
1627        let path = entry.path();
1628        if path.extension().is_some_and(|ext| ext == "jsonl") {
1629            let header = read_session_header(&path);
1630            if let Some(ref h) = header {
1631                if let Some(ref rcwd) = resolved_cwd
1632                    && h.cwd != rcwd.to_string_lossy().as_ref()
1633                {
1634                    continue;
1635                }
1636            } else {
1637                continue;
1638            }
1639            if let Ok(meta) = path.metadata()
1640                && let Ok(mtime) = meta.modified()
1641            {
1642                files.push((path, mtime));
1643            }
1644        }
1645    }
1646
1647    files.sort_by_key(|b| std::cmp::Reverse(b.1));
1648    files.into_iter().next().map(|(path, _)| path)
1649}
1650
1651// ── Session repository (list / delete / fork) ───────────────────────
1652
1653/// List all session metadata in a session directory, newest first.
1654/// Pi-compatible: returns metadata for all valid `.jsonl` sessions.
1655pub fn list_sessions(session_dir: &Path) -> Vec<SessionInfo> {
1656    let mut sessions: Vec<SessionInfo> = Vec::new();
1657    let dir = match std::fs::read_dir(session_dir) {
1658        Ok(d) => d,
1659        Err(_) => return sessions,
1660    };
1661    for entry in dir.flatten() {
1662        let path = entry.path();
1663        if path.extension().is_some_and(|ext| ext == "jsonl")
1664            && let Some(info) = load_session_info(&path)
1665        {
1666            sessions.push(info);
1667        }
1668    }
1669    sessions.sort_by_key(|b| std::cmp::Reverse(b.created));
1670    sessions
1671}
1672
1673/// Load session info from a session file.
1674pub fn load_session_info(path: &Path) -> Option<SessionInfo> {
1675    let header = read_session_header(path)?;
1676    let created = DateTime::parse_from_rfc3339(&header.timestamp)
1677        .ok()?
1678        .with_timezone(&Utc);
1679    let modified = path.metadata().ok()?.modified().ok()?;
1680    let modified_dt: DateTime<Utc> = modified.into();
1681    let entries = load_entries_from_file(path);
1682    let name = entries.iter().rev().find_map(|e| {
1683        if let SessionEntry::SessionInfo(si) = e {
1684            let n = si.name.trim();
1685            if n.is_empty() {
1686                None
1687            } else {
1688                Some(n.to_string())
1689            }
1690        } else {
1691            None
1692        }
1693    });
1694    let message_count = entries
1695        .iter()
1696        .filter(|e| matches!(e, SessionEntry::Message(_)))
1697        .count();
1698    let first_message = entries
1699        .iter()
1700        .find_map(|e| {
1701            if let SessionEntry::Message(m) = e {
1702                Some(crate::agent::types::message_text(&m.message))
1703            } else {
1704                None
1705            }
1706        })
1707        .unwrap_or_default();
1708    let all_messages_text = entries
1709        .iter()
1710        .filter_map(|e| {
1711            if let SessionEntry::Message(m) = e {
1712                Some(crate::agent::types::message_text(&m.message))
1713            } else {
1714                None
1715            }
1716        })
1717        .collect::<Vec<_>>()
1718        .join("\n");
1719
1720    Some(SessionInfo {
1721        path: path.to_path_buf(),
1722        id: header.id,
1723        cwd: header.cwd,
1724        name,
1725        parent_session_path: header.parent_session,
1726        created,
1727        modified: modified_dt,
1728        message_count,
1729        first_message,
1730        all_messages_text,
1731    })
1732}
1733
1734/// Delete a session file.
1735pub fn delete_session(path: &Path) -> std::io::Result<()> {
1736    if path.exists() {
1737        std::fs::remove_file(path)?;
1738    }
1739    Ok(())
1740}
1741
1742/// Fork a session: create a new session file containing a copy of entries from the source session
1743/// up to (and including) the entry with the given `entry_id`, or all entries if `entry_id` is None.
1744/// If `entry_id` is provided and `position` is "at", the copy goes up to and including that entry.
1745/// If `position` is "before" (default), the copy goes up to but not including the entry
1746/// (which must be a user message). Pi-compatible.
1747pub fn fork_session(
1748    source_path: &Path,
1749    target_dir: &Path,
1750    entry_id: Option<&str>,
1751    position: Option<&str>,
1752) -> std::io::Result<String> {
1753    let header = read_session_header(source_path).ok_or_else(|| {
1754        std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing session header")
1755    })?;
1756    let entries = load_entries_from_file(source_path);
1757
1758    // Build by_id map for parent traversal
1759    let by_id: HashMap<String, &SessionEntry> =
1760        entries.iter().map(|e| (e.id().to_string(), e)).collect();
1761
1762    let forked_entries: Vec<SessionEntry> = if let Some(target_id) = entry_id {
1763        // Find the target entry
1764        let target = by_id.get(target_id).ok_or_else(|| {
1765            std::io::Error::new(std::io::ErrorKind::InvalidInput, "Entry not found")
1766        })?;
1767
1768        // Determine the effective leaf ID for the fork
1769        let effective_leaf_id = match position.unwrap_or("before") {
1770            "at" => Some(target.id().to_string()),
1771            _ => {
1772                if !matches!(target, SessionEntry::Message(m) if crate::agent::types::message_is_user(&m.message))
1773                {
1774                    return Err(std::io::Error::new(
1775                        std::io::ErrorKind::InvalidInput,
1776                        "Entry is not a user message",
1777                    ));
1778                }
1779                target.parent_id().map(|s| s.to_string())
1780            }
1781        };
1782
1783        // Collect path from effective leaf to root
1784        let mut path: Vec<&SessionEntry> = Vec::new();
1785        let mut current = effective_leaf_id.as_ref().and_then(|id| by_id.get(id));
1786        while let Some(entry) = current {
1787            path.push(entry);
1788            current = entry.parent_id().and_then(|pid| by_id.get(pid));
1789        }
1790        path.reverse();
1791        path.into_iter().cloned().collect()
1792    } else {
1793        entries.clone()
1794    };
1795
1796    // Create the new session
1797    let session_id = uuid::Uuid::new_v4().to_string();
1798    let timestamp = chrono::Utc::now().to_rfc3339();
1799    let file_ts = timestamp.replace([':', '.'], "-");
1800    let file_name = format!("{}_{}.jsonl", file_ts, session_id);
1801    let target_path = target_dir.join(&file_name);
1802
1803    std::fs::create_dir_all(target_dir)?;
1804
1805    let new_header = SessionHeader {
1806        type_: "session".to_string(),
1807        version: Some(CURRENT_SESSION_VERSION),
1808        id: session_id.clone(),
1809        timestamp,
1810        cwd: header.cwd.clone(),
1811        parent_session: Some(source_path.to_string_lossy().to_string()),
1812    };
1813    write_entries_to_file(&target_path, &new_header, &forked_entries)?;
1814
1815    Ok(session_id)
1816}
1817
1818// ── Tests ───────────────────────────────────────────────────────────
1819
1820#[cfg(test)]
1821mod tests {
1822    use super::*;
1823    use crate::agent::types::user_message;
1824    use tempfile::TempDir;
1825
1826    fn make_user_msg(content: &str) -> AgentMessage {
1827        user_message(content)
1828    }
1829
1830    fn make_asst_msg(content: &str) -> AgentMessage {
1831        crate::agent::types::assistant_message(content)
1832    }
1833
1834    // ── Entry serialization round-trip ──────────────────────────────
1835
1836    #[test]
1837    fn test_build_context_tracks_metadata() {
1838        let tmp = TempDir::new().unwrap();
1839        let sessions_dir = tmp.path().join("sessions");
1840        let cwd = tmp.path().join("project");
1841        std::fs::create_dir_all(&cwd).unwrap();
1842
1843        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1844        sm.append_thinking_level_change("high");
1845        sm.append_model_change("opencode_go", "deepseek-v4-pro");
1846        sm.append_active_tools_change(&["read".to_string(), "write".to_string()]);
1847        sm.append_message(&make_user_msg("hello"));
1848        sm.append_message(&make_asst_msg("hi"));
1849
1850        let context = sm.build_session_context();
1851        assert_eq!(context.thinking_level, "high");
1852        assert_eq!(
1853            context.model,
1854            Some(("opencode_go".to_string(), "deepseek-v4-pro".to_string()))
1855        );
1856        assert_eq!(
1857            context.active_tool_names,
1858            Some(vec!["read".to_string(), "write".to_string()])
1859        );
1860        assert_eq!(context.messages.len(), 2);
1861    }
1862
1863    #[test]
1864    fn test_build_context_defaults_when_no_metadata() {
1865        let cwd = Path::new("/tmp/test");
1866        let sm = SessionManager::in_memory(cwd);
1867        let context = sm.build_session_context();
1868        assert_eq!(context.thinking_level, "off");
1869        assert!(context.model.is_none());
1870        assert!(context.active_tool_names.is_none());
1871        assert!(context.messages.is_empty());
1872    }
1873
1874    // ── Find entries test ────────────────────────────────────────────
1875
1876    #[test]
1877    fn test_find_entries_by_type() {
1878        let cwd = Path::new("/tmp/test");
1879        let mut sm = SessionManager::in_memory(cwd);
1880        sm.append_message(&make_user_msg("hello"));
1881        sm.append_thinking_level_change("high");
1882        sm.append_model_change("p", "m");
1883        sm.append_session_info("test session");
1884
1885        let messages = sm.find_entries_by_type("message");
1886        assert_eq!(messages.len(), 1);
1887
1888        let thinking = sm.find_entries_by_type("thinking_level_change");
1889        assert_eq!(thinking.len(), 1);
1890
1891        let models = sm.find_entries_by_type("model_change");
1892        assert_eq!(models.len(), 1);
1893
1894        let infos = sm.find_entries_by_type("session_info");
1895        assert_eq!(infos.len(), 1);
1896    }
1897
1898    // ── Session listing / forking tests ──────────────────────────────
1899
1900    #[test]
1901    fn test_list_sessions_empty_dir() {
1902        let tmp = TempDir::new().unwrap();
1903        let sessions = list_sessions(tmp.path());
1904        assert!(sessions.is_empty());
1905    }
1906
1907    #[test]
1908    fn test_list_sessions() {
1909        let tmp = TempDir::new().unwrap();
1910        let sessions_dir = tmp.path().join("sessions");
1911        let cwd = tmp.path().join("project");
1912        std::fs::create_dir_all(&cwd).unwrap();
1913
1914        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1915        sm.append_message(&make_user_msg("first"));
1916        sm.append_message(&make_asst_msg("response"));
1917        let path = sm.session_file().unwrap().to_path_buf();
1918        drop(sm);
1919
1920        let sessions = list_sessions(&sessions_dir);
1921        assert_eq!(sessions.len(), 1);
1922        assert_eq!(sessions[0].path, path);
1923        assert_eq!(sessions[0].message_count, 2);
1924    }
1925
1926    #[test]
1927    fn test_fork_session_all_entries() {
1928        let tmp = TempDir::new().unwrap();
1929        let sessions_dir = tmp.path().join("sessions");
1930        let cwd = tmp.path().join("project");
1931        std::fs::create_dir_all(&cwd).unwrap();
1932
1933        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
1934        sm.append_message(&make_user_msg("hello"));
1935        sm.append_message(&make_asst_msg("world"));
1936        let source_path = sm.session_file().unwrap().to_path_buf();
1937        drop(sm);
1938
1939        let target_dir = tmp.path().join("forked");
1940        let new_id = fork_session(&source_path, &target_dir, None, None).unwrap();
1941        assert!(!new_id.is_empty());
1942
1943        let sessions = list_sessions(&target_dir);
1944        assert_eq!(sessions.len(), 1);
1945        assert_eq!(sessions[0].message_count, 2);
1946    }
1947
1948    #[test]
1949    fn test_delete_session() {
1950        let tmp = TempDir::new().unwrap();
1951        let path = tmp.path().join("test.jsonl");
1952        std::fs::write(&path, "{\"type\":\"session\",\"id\":\"test\",\"timestamp\":\"2026-01-01T00:00:00Z\",\"cwd\":\"/\"}\n").unwrap();
1953        assert!(path.exists());
1954        delete_session(&path).unwrap();
1955        assert!(!path.exists());
1956        // deleting non-existent file should be ok
1957        delete_session(&path).unwrap();
1958    }
1959
1960    #[test]
1961    fn test_parse_session_entry_line() {
1962        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
1963            id: "abc12345".to_string(),
1964            parent_id: None,
1965            timestamp: "2026-06-19T12:00:00Z".to_string(),
1966            name: "Test session".to_string(),
1967        });
1968        let json = serde_json::to_string(&entry).unwrap();
1969        let parsed = parse_session_entry_line(&json);
1970        assert!(parsed.is_some());
1971    }
1972
1973    #[test]
1974    fn test_parse_session_entry_line_empty() {
1975        assert!(parse_session_entry_line("").is_none());
1976        assert!(parse_session_entry_line("   ").is_none());
1977    }
1978
1979    #[test]
1980    fn test_parse_session_entry_line_malformed() {
1981        assert!(parse_session_entry_line("not valid json").is_none());
1982    }
1983
1984    #[test]
1985    fn test_parse_session_header_line() {
1986        let header = SessionHeader {
1987            type_: "session".to_string(),
1988            version: Some(3),
1989            id: "session123".to_string(),
1990            timestamp: "2026-06-19T12:00:00Z".to_string(),
1991            cwd: "/home/user/project".to_string(),
1992            parent_session: None,
1993        };
1994        let json = serde_json::to_string(&header).unwrap();
1995        let parsed = parse_session_header_line(&json);
1996        assert!(parsed.is_some());
1997        assert_eq!(parsed.unwrap().id, "session123");
1998    }
1999
2000    #[test]
2001    fn test_parse_session_header_line_wrong_type() {
2002        // parse_session_header_line validates type == "session"
2003        let json =
2004            r#"{"type":"message","id":"abc","timestamp":"2026-06-19T12:00:00Z","cwd":"/home"}"#;
2005        let result = parse_session_header_line(json);
2006        assert!(result.is_none());
2007    }
2008
2009    #[test]
2010    fn test_write_and_read_entries() {
2011        let tmp = TempDir::new().unwrap();
2012        let file_path = tmp.path().join("test.jsonl");
2013
2014        let header = SessionHeader {
2015            type_: "session".to_string(),
2016            version: Some(3),
2017            id: "session1".to_string(),
2018            timestamp: "2026-06-19T12:00:00Z".to_string(),
2019            cwd: "/home/user/project".to_string(),
2020            parent_session: None,
2021        };
2022
2023        let entries: Vec<SessionEntry> = vec![
2024            SessionEntry::Message(MessageEntry {
2025                id: "msg1".to_string(),
2026                parent_id: None,
2027                timestamp: "2026-06-19T12:00:01Z".to_string(),
2028                message: make_user_msg("hello"),
2029            }),
2030            SessionEntry::Message(MessageEntry {
2031                id: "msg2".to_string(),
2032                parent_id: Some("msg1".to_string()),
2033                timestamp: "2026-06-19T12:00:02Z".to_string(),
2034                message: AgentMessage::Llm(yoagent::types::Message::Assistant {
2035                    content: vec![yoagent::types::Content::Text {
2036                        text: "hi there".to_string(),
2037                    }],
2038                    stop_reason: yoagent::types::StopReason::Stop,
2039                    model: String::new(),
2040                    provider: String::new(),
2041                    usage: yoagent::types::Usage {
2042                        input: 10,
2043                        output: 5,
2044                        ..Default::default()
2045                    },
2046                    timestamp: 0,
2047                    error_message: None,
2048                }),
2049            }),
2050        ];
2051
2052        write_entries_to_file(&file_path, &header, &entries).unwrap();
2053
2054        // Read back header
2055        let read_header = read_session_header(&file_path).unwrap();
2056        assert_eq!(read_header.id, "session1");
2057
2058        // Read back entries
2059        let read_entries = load_entries_from_file(&file_path);
2060        assert_eq!(read_entries.len(), 2);
2061
2062        match &read_entries[0] {
2063            SessionEntry::Message(e) => {
2064                assert_eq!(e.id, "msg1");
2065                assert!(crate::agent::types::message_is_user(&e.message));
2066                assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2067            }
2068            _ => panic!("Expected Message"),
2069        }
2070
2071        match &read_entries[1] {
2072            SessionEntry::Message(e) => {
2073                assert_eq!(e.id, "msg2");
2074                assert!(crate::agent::types::message_is_assistant(&e.message));
2075                assert_eq!(crate::agent::types::message_text(&e.message), "hi there");
2076                assert!(crate::agent::types::message_usage(&e.message).is_some());
2077            }
2078            _ => panic!("Expected Message"),
2079        }
2080    }
2081
2082    #[test]
2083    fn test_append_entry_to_file() {
2084        let tmp = TempDir::new().unwrap();
2085        let file_path = tmp.path().join("append_test.jsonl");
2086
2087        let entry = SessionEntry::SessionInfo(SessionInfoEntry {
2088            id: "abc12345".to_string(),
2089            parent_id: None,
2090            timestamp: "2026-06-19T12:00:00Z".to_string(),
2091            name: "Test".to_string(),
2092        });
2093
2094        append_entry_to_file(&file_path, &entry).unwrap();
2095
2096        let content = fs::read_to_string(&file_path).unwrap();
2097        assert!(content.contains("Test"));
2098        assert!(content.contains("abc12345"));
2099    }
2100
2101    #[test]
2102    fn test_load_entries_missing_file() {
2103        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2104        assert!(entries.is_empty());
2105    }
2106
2107    #[test]
2108    fn test_read_session_header_missing_file() {
2109        let header = read_session_header(Path::new("/nonexistent/file.jsonl"));
2110        assert!(header.is_none());
2111    }
2112
2113    // ── CWD encoding ────────────────────────────────────────────────
2114
2115    #[test]
2116    fn test_encode_cwd() {
2117        assert_eq!(
2118            encode_cwd_for_dir(Path::new("/home/user/project")),
2119            "--home-user-project--"
2120        );
2121    }
2122
2123    #[test]
2124    fn test_encode_cwd_windows_style() {
2125        assert_eq!(
2126            encode_cwd_for_dir(Path::new("C:\\Users\\user\\project")),
2127            "--C--Users-user-project--"
2128        );
2129    }
2130
2131    #[test]
2132    fn test_encode_cwd_no_leading_slash() {
2133        assert_eq!(
2134            encode_cwd_for_dir(Path::new("home/user/project")),
2135            "--home-user-project--"
2136        );
2137    }
2138
2139    #[test]
2140    fn test_encode_cwd_special_chars() {
2141        assert_eq!(
2142            encode_cwd_for_dir(Path::new("/home/user/my:project")),
2143            "--home-user-my-project--"
2144        );
2145    }
2146
2147    // ── SessionEntry accessors ───────────────────────────────────────
2148
2149    #[test]
2150    fn test_entry_id_accessor() {
2151        let entry = SessionEntry::Message(MessageEntry {
2152            id: "myid".to_string(),
2153            parent_id: None,
2154            timestamp: "2026-06-19T12:00:00Z".to_string(),
2155            message: make_user_msg("hello"),
2156        });
2157        assert_eq!(entry.id(), "myid");
2158    }
2159
2160    #[test]
2161    fn test_entry_parent_id_accessor() {
2162        let entry = SessionEntry::Message(MessageEntry {
2163            id: "myid".to_string(),
2164            parent_id: Some("parent".to_string()),
2165            timestamp: "2026-06-19T12:00:00Z".to_string(),
2166            message: make_user_msg("hello"),
2167        });
2168        assert_eq!(entry.parent_id(), Some("parent"));
2169    }
2170
2171    #[test]
2172    fn test_entry_timestamp_accessor() {
2173        let entry = SessionEntry::Message(MessageEntry {
2174            id: "myid".to_string(),
2175            parent_id: None,
2176            timestamp: "2026-06-19T12:00:00Z".to_string(),
2177            message: make_user_msg("hello"),
2178        });
2179        assert_eq!(entry.timestamp(), "2026-06-19T12:00:00Z");
2180    }
2181
2182    // ── generate_entry_id ────────────────────────────────────────────
2183
2184    #[test]
2185    fn test_generate_entry_id_length() {
2186        let map = HashMap::new();
2187        let id = generate_entry_id(&map);
2188        assert_eq!(id.len(), 8);
2189    }
2190
2191    #[test]
2192    fn test_generate_entry_id_hex() {
2193        let map = HashMap::new();
2194        let id = generate_entry_id(&map);
2195        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
2196    }
2197
2198    #[test]
2199    fn test_generate_entry_id_collision_fallback() {
2200        // Create a map that has all possible 8-char hex IDs - impossible
2201        // but we test the fallback behavior by only having a collision
2202        // on the first generated ID (unlikely but the code handles it).
2203        // This is more of a smoke test that the function doesn't panic.
2204        let map = HashMap::new();
2205        let id1 = generate_entry_id(&map);
2206        assert!(!id1.is_empty());
2207    }
2208
2209    // ── Deserialize from pi-compatible JSON ──────────────────────────
2210
2211    #[test]
2212    fn test_deserialize_pi_format_message() {
2213        // pi format uses camelCase and "type": "message"
2214        // Message uses yoagent format: role-tagged enum with Vec<Content>
2215        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}}"#;
2216        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2217        match entry {
2218            SessionEntry::Message(e) => {
2219                assert_eq!(e.id, "abc12345");
2220                assert_eq!(crate::agent::types::message_text(&e.message), "hello");
2221            }
2222            _ => panic!("Expected Message"),
2223        }
2224    }
2225
2226    #[test]
2227    fn test_deserialize_pi_format_thinking_level() {
2228        let json = r#"{"type":"thinking_level_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","thinkingLevel":"high"}"#;
2229        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2230        match entry {
2231            SessionEntry::ThinkingLevelChange(e) => {
2232                assert_eq!(e.thinking_level, "high");
2233            }
2234            _ => panic!("Expected ThinkingLevelChange"),
2235        }
2236    }
2237
2238    #[test]
2239    fn test_deserialize_pi_format_model_change() {
2240        let json = r#"{"type":"model_change","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","provider":"opencode_go","modelId":"deepseek-v4-pro"}"#;
2241        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2242        match entry {
2243            SessionEntry::ModelChange(e) => {
2244                assert_eq!(e.provider, "opencode_go");
2245                assert_eq!(e.model_id, "deepseek-v4-pro");
2246            }
2247            _ => panic!("Expected ModelChange"),
2248        }
2249    }
2250
2251    #[test]
2252    fn test_deserialize_pi_format_compaction() {
2253        let json = r#"{"type":"compaction","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","summary":"Earlier conversation summarized","firstKeptEntryId":"entry123","tokensBefore":5000}"#;
2254        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2255        match entry {
2256            SessionEntry::Compaction(e) => {
2257                assert_eq!(e.summary, "Earlier conversation summarized");
2258                assert_eq!(e.first_kept_entry_id, "entry123");
2259                assert_eq!(e.tokens_before, 5000);
2260            }
2261            _ => panic!("Expected Compaction"),
2262        }
2263    }
2264
2265    #[test]
2266    fn test_deserialize_pi_format_session_info() {
2267        let json = r#"{"type":"session_info","id":"abc12345","parentId":"parent1","timestamp":"2026-06-19T12:00:00Z","name":"My session"}"#;
2268        let entry: SessionEntry = serde_json::from_str(json).unwrap();
2269        match entry {
2270            SessionEntry::SessionInfo(e) => {
2271                assert_eq!(e.name, "My session");
2272            }
2273            _ => panic!("Expected SessionInfo"),
2274        }
2275    }
2276
2277    // ── SessionManager ───────────────────────────────────────────────
2278
2279    #[test]
2280    fn test_session_create_in_memory() {
2281        let cwd = Path::new("/tmp/test-project");
2282        let sm = SessionManager::in_memory(cwd);
2283        assert!(!sm.is_persisted());
2284        assert!(!sm.session_id().is_empty());
2285        assert_eq!(sm.cwd(), cwd);
2286        assert!(sm.leaf_id().is_none());
2287        assert!(sm.entries().is_empty());
2288    }
2289
2290    #[test]
2291    fn test_session_create_persisted() {
2292        let tmp = TempDir::new().unwrap();
2293        let sessions_dir = tmp.path().join("sessions");
2294        let cwd = tmp.path().join("project");
2295        std::fs::create_dir_all(&cwd).unwrap();
2296
2297        let sm = SessionManager::create(&cwd, Some(&sessions_dir));
2298        assert!(sm.is_persisted());
2299        assert!(!sm.session_id().is_empty());
2300        // File should NOT exist yet (lazy write: no file path until first assistant)
2301        assert!(
2302            sm.session_file().is_none(),
2303            "session file should not be created until first assistant message (lazy write)"
2304        );
2305        assert!(!sm.flushed);
2306    }
2307
2308    #[test]
2309    fn test_session_append_and_build_context() {
2310        let tmp = TempDir::new().unwrap();
2311        let sessions_dir = tmp.path().join("sessions");
2312        let cwd = tmp.path().join("project");
2313        std::fs::create_dir_all(&cwd).unwrap();
2314
2315        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2316
2317        let user_msg = make_user_msg("hello");
2318        let user_id = sm.append_message(&user_msg);
2319        assert_eq!(sm.leaf_id().as_deref(), Some(user_id.as_str()));
2320
2321        // In-memory entries exist even before flush
2322        assert_eq!(sm.entries().len(), 1);
2323
2324        let assistant_msg = make_asst_msg("hi there");
2325        sm.append_message(&assistant_msg);
2326        assert_eq!(sm.entries().len(), 2);
2327
2328        // After assistant message, file should be created (lazy write)
2329        assert!(
2330            sm.session_file().unwrap().exists(),
2331            "session file should exist after first assistant message"
2332        );
2333
2334        let context = sm.build_session_context();
2335        assert_eq!(context.messages.len(), 2);
2336        assert_eq!(
2337            crate::agent::types::message_text(&context.messages[0]),
2338            "hello"
2339        );
2340        assert_eq!(
2341            crate::agent::types::message_text(&context.messages[1]),
2342            "hi there"
2343        );
2344    }
2345
2346    #[test]
2347    fn test_session_open_existing() {
2348        let tmp = TempDir::new().unwrap();
2349        let sessions_dir = tmp.path().join("sessions");
2350        let cwd = tmp.path().join("project");
2351        std::fs::create_dir_all(&cwd).unwrap();
2352
2353        // Create and populate a session
2354        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2355        sm.append_message(&make_user_msg("first"));
2356        sm.append_message(&make_asst_msg("response"));
2357
2358        let file_path = sm.session_file().unwrap().to_path_buf();
2359        let session_id = sm.session_id().to_string();
2360        drop(sm);
2361
2362        // Open it
2363        let sm2 = SessionManager::open(&file_path, Some(&sessions_dir), None);
2364        assert_eq!(sm2.session_id(), session_id);
2365        let context = sm2.build_session_context();
2366        assert_eq!(context.messages.len(), 2);
2367        assert_eq!(
2368            crate::agent::types::message_text(&context.messages[0]),
2369            "first"
2370        );
2371        assert_eq!(
2372            crate::agent::types::message_text(&context.messages[1]),
2373            "response"
2374        );
2375    }
2376
2377    #[test]
2378    fn test_session_continue_recent() {
2379        let tmp = TempDir::new().unwrap();
2380        let sessions_dir = tmp.path().join("sessions");
2381        let cwd = tmp.path().join("project");
2382        std::fs::create_dir_all(&cwd).unwrap();
2383
2384        // First session
2385        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2386        sm1.append_message(&make_user_msg("old session"));
2387        sm1.append_message(&make_asst_msg("old response"));
2388        let _old_id = sm1.session_id().to_string();
2389        drop(sm1);
2390
2391        // Small delay to ensure different mtime
2392        std::thread::sleep(std::time::Duration::from_millis(10));
2393
2394        // Second session (more recent)
2395        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2396        sm2.append_message(&make_user_msg("new session"));
2397        sm2.append_message(&make_asst_msg("new response"));
2398        let new_id = sm2.session_id().to_string();
2399        drop(sm2);
2400
2401        // Continue recent - should get the new one
2402        let sm3 = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2403        assert_eq!(sm3.session_id(), new_id);
2404        let context = sm3.build_session_context();
2405        assert_eq!(
2406            crate::agent::types::message_text(&context.messages[0]),
2407            "new session"
2408        );
2409    }
2410
2411    #[test]
2412    fn test_session_continue_recent_none_exist() {
2413        let tmp = TempDir::new().unwrap();
2414        let sessions_dir = tmp.path().join("sessions");
2415        let cwd = tmp.path().join("project");
2416        std::fs::create_dir_all(&sessions_dir).unwrap();
2417        std::fs::create_dir_all(&cwd).unwrap();
2418
2419        // No sessions exist - should create new
2420        let sm = SessionManager::continue_recent(&cwd, Some(&sessions_dir));
2421        assert!(!sm.session_id().is_empty());
2422        assert!(sm.entries().is_empty());
2423    }
2424
2425    #[test]
2426    fn test_session_name() {
2427        let tmp = TempDir::new().unwrap();
2428        let sessions_dir = tmp.path().join("sessions");
2429        let cwd = tmp.path().join("project");
2430        std::fs::create_dir_all(&cwd).unwrap();
2431
2432        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2433        assert!(sm.session_name().is_none());
2434
2435        sm.append_session_info("My Task");
2436        sm.append_message(&make_user_msg("hello"));
2437        sm.append_message(&make_asst_msg("hi"));
2438        assert_eq!(sm.session_name().as_deref(), Some("My Task"));
2439
2440        // Setting empty name clears it
2441        sm.append_session_info("");
2442        assert!(sm.session_name().is_none());
2443    }
2444
2445    #[test]
2446    fn test_session_thinking_level_change() {
2447        let tmp = TempDir::new().unwrap();
2448        let sessions_dir = tmp.path().join("sessions");
2449        let cwd = tmp.path().join("project");
2450        std::fs::create_dir_all(&cwd).unwrap();
2451
2452        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2453        sm.append_thinking_level_change("high");
2454
2455        assert_eq!(sm.entries().len(), 1);
2456        match &sm.entries()[0] {
2457            SessionEntry::ThinkingLevelChange(e) => {
2458                assert_eq!(e.thinking_level, "high");
2459            }
2460            _ => panic!("Expected ThinkingLevelChange"),
2461        }
2462    }
2463
2464    #[test]
2465    fn test_session_model_change() {
2466        let tmp = TempDir::new().unwrap();
2467        let sessions_dir = tmp.path().join("sessions");
2468        let cwd = tmp.path().join("project");
2469        std::fs::create_dir_all(&cwd).unwrap();
2470
2471        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2472        sm.append_model_change("opencode_go", "deepseek-v4-pro");
2473
2474        assert_eq!(sm.entries().len(), 1);
2475        match &sm.entries()[0] {
2476            SessionEntry::ModelChange(e) => {
2477                assert_eq!(e.provider, "opencode_go");
2478                assert_eq!(e.model_id, "deepseek-v4-pro");
2479            }
2480            _ => panic!("Expected ModelChange"),
2481        }
2482    }
2483
2484    #[test]
2485    fn test_session_compaction() {
2486        let tmp = TempDir::new().unwrap();
2487        let sessions_dir = tmp.path().join("sessions");
2488        let cwd = tmp.path().join("project");
2489        std::fs::create_dir_all(&cwd).unwrap();
2490
2491        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2492        sm.append_compaction("Earlier work summarized", "entry_kept", 5000, None, None);
2493
2494        match &sm.entries()[0] {
2495            SessionEntry::Compaction(e) => {
2496                assert_eq!(e.summary, "Earlier work summarized");
2497                assert_eq!(e.first_kept_entry_id, "entry_kept");
2498                assert_eq!(e.tokens_before, 5000);
2499            }
2500            _ => panic!("Expected Compaction"),
2501        }
2502    }
2503
2504    #[test]
2505    fn test_session_label() {
2506        let tmp = TempDir::new().unwrap();
2507        let sessions_dir = tmp.path().join("sessions");
2508        let cwd = tmp.path().join("project");
2509        std::fs::create_dir_all(&cwd).unwrap();
2510
2511        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2512        let msg_id = sm.append_message(&make_user_msg("important message"));
2513        sm.append_message(&make_asst_msg("ok"));
2514
2515        // Set label
2516        sm.append_label_change(&msg_id, Some("important"));
2517        assert_eq!(sm.label(&msg_id).as_deref(), Some("important"));
2518
2519        // Clear label
2520        sm.append_label_change(&msg_id, None);
2521        assert_eq!(sm.label(&msg_id), None);
2522    }
2523
2524    #[test]
2525    fn test_session_branch_navigation() {
2526        let tmp = TempDir::new().unwrap();
2527        let sessions_dir = tmp.path().join("sessions");
2528        let cwd = tmp.path().join("project");
2529        std::fs::create_dir_all(&cwd).unwrap();
2530
2531        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2532        let m1 = sm.append_message(&make_user_msg("one"));
2533        sm.append_message(&make_asst_msg("response one"));
2534        let _m2 = sm.append_message(&make_user_msg("two"));
2535        sm.append_message(&make_asst_msg("response two"));
2536
2537        // Current leaf is after last message
2538        assert_eq!(sm.entries().len(), 4);
2539
2540        // Branch back to first user message (writes a persistent LeafEntry, pi-compatible)
2541        sm.set_branch(&m1).unwrap();
2542        assert_eq!(sm.entries().len(), 5); // 4 original + 1 leaf entry
2543        assert_eq!(sm.leaf_id().as_deref(), Some(m1.as_str()));
2544
2545        // Append a new branch
2546        sm.append_message(&make_asst_msg("alternate response"));
2547        // Now 6 entries (original 4 + leaf + 1 new message)
2548        assert_eq!(sm.entries().len(), 6);
2549
2550        // Build context from current leaf - should have 2 messages (m1, branch asst)
2551        let context = sm.build_session_context();
2552        assert_eq!(context.messages.len(), 2); // user "one" + assistant "alternate response"
2553        // Verify metadata in context
2554        assert_eq!(context.thinking_level, "off");
2555        assert!(context.model.is_none());
2556        assert!(context.active_tool_names.is_none());
2557    }
2558
2559    #[test]
2560    fn test_session_reset_leaf() {
2561        let tmp = TempDir::new().unwrap();
2562        let sessions_dir = tmp.path().join("sessions");
2563        let cwd = tmp.path().join("project");
2564        std::fs::create_dir_all(&cwd).unwrap();
2565
2566        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2567        sm.append_message(&make_user_msg("one"));
2568        sm.append_message(&make_asst_msg("response"));
2569        assert_eq!(sm.entries().len(), 2);
2570
2571        // Reset leaf (persistent leaf entry, pi-compatible)
2572        sm.reset_leaf();
2573        // Leaf entry was written (type: "leaf", targetId: null)
2574        assert_eq!(sm.entries().len(), 3);
2575        assert!(sm.leaf_id().is_none());
2576
2577        // Append from reset state (parentId should be None since leaf is None)
2578        sm.append_message(&make_user_msg("fresh start"));
2579        assert_eq!(sm.entries().len(), 4);
2580        // Verify fresh start has no parent
2581        match &sm.entries()[3] {
2582            SessionEntry::Message(m) => {
2583                assert!(m.parent_id.is_none());
2584            }
2585            _ => panic!("Expected Message"),
2586        }
2587    }
2588
2589    #[test]
2590    fn test_session_branch_summary() {
2591        let tmp = TempDir::new().unwrap();
2592        let sessions_dir = tmp.path().join("sessions");
2593        let cwd = tmp.path().join("project");
2594        std::fs::create_dir_all(&cwd).unwrap();
2595
2596        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2597        sm.append_message(&make_user_msg("one"));
2598        sm.append_message(&make_asst_msg("response"));
2599
2600        sm.append_branch_summary("root", "Abandoned path summary", None, None);
2601
2602        match &sm.entries()[2] {
2603            SessionEntry::BranchSummary(e) => {
2604                assert_eq!(e.summary, "Abandoned path summary");
2605                assert_eq!(e.from_id, "root");
2606            }
2607            _ => panic!("Expected BranchSummary"),
2608        }
2609    }
2610
2611    #[test]
2612    fn test_session_children() {
2613        let tmp = TempDir::new().unwrap();
2614        let sessions_dir = tmp.path().join("sessions");
2615        let cwd = tmp.path().join("project");
2616        std::fs::create_dir_all(&cwd).unwrap();
2617
2618        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2619        let m1 = sm.append_message(&make_user_msg("one"));
2620        sm.append_message(&make_asst_msg("response"));
2621
2622        // m1 should have the assistant as child
2623        let children = sm.children(&m1);
2624        assert_eq!(children.len(), 1);
2625    }
2626
2627    #[test]
2628    fn test_session_custom_entry() {
2629        let tmp = TempDir::new().unwrap();
2630        let sessions_dir = tmp.path().join("sessions");
2631        let cwd = tmp.path().join("project");
2632        std::fs::create_dir_all(&cwd).unwrap();
2633
2634        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2635        sm.append_message(&make_user_msg("one"));
2636        sm.append_message(&make_asst_msg("ok"));
2637        sm.append_custom_entry("my_ext", serde_json::json!({"key": "value"}));
2638
2639        match &sm.entries()[2] {
2640            SessionEntry::Custom(e) => {
2641                assert_eq!(e.custom_type, "my_ext");
2642                assert_eq!(e.data["key"], "value");
2643            }
2644            _ => panic!("Expected Custom"),
2645        }
2646    }
2647
2648    #[test]
2649    fn test_find_most_recent_session() {
2650        let tmp = TempDir::new().unwrap();
2651        let sessions_dir = tmp.path().join("sessions");
2652        let cwd = tmp.path().join("project");
2653        std::fs::create_dir_all(&sessions_dir).unwrap();
2654        std::fs::create_dir_all(&cwd).unwrap();
2655
2656        // Create first session
2657        let mut sm1 = SessionManager::create(&cwd, Some(&sessions_dir));
2658        sm1.append_message(&make_user_msg("old"));
2659        sm1.append_message(&make_asst_msg("old"));
2660        let _path1 = sm1.session_file().unwrap().to_path_buf();
2661        drop(sm1);
2662
2663        std::thread::sleep(std::time::Duration::from_millis(10));
2664
2665        // Create second session (more recent)
2666        let mut sm2 = SessionManager::create(&cwd, Some(&sessions_dir));
2667        sm2.append_message(&make_user_msg("new"));
2668        sm2.append_message(&make_asst_msg("new"));
2669        let path2 = sm2.session_file().unwrap().to_path_buf();
2670        drop(sm2);
2671
2672        let most_recent = find_most_recent_session(&sessions_dir, None).unwrap();
2673        assert_eq!(most_recent, path2);
2674    }
2675
2676    // ── Corruption handling ───────────────────────────────────────────
2677
2678    #[test]
2679    fn test_corrupt_empty_file_is_recovered() {
2680        let tmp = TempDir::new().unwrap();
2681        let sessions_dir = tmp.path().join("sessions");
2682        let cwd = tmp.path().join("project");
2683        std::fs::create_dir_all(&sessions_dir).unwrap();
2684        std::fs::create_dir_all(&cwd).unwrap();
2685
2686        // Create an empty JSONL file
2687        let file_path = sessions_dir.join("empty.jsonl");
2688        std::fs::write(&file_path, "").unwrap();
2689
2690        // Opening an empty file should not panic - should start fresh
2691        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2692        assert!(!sm.session_id().is_empty());
2693        assert!(sm.entries().is_empty());
2694        assert_eq!(sm.session_file().unwrap(), file_path);
2695    }
2696
2697    #[test]
2698    fn test_corrupt_garbage_file_is_recovered() {
2699        let tmp = TempDir::new().unwrap();
2700        let sessions_dir = tmp.path().join("sessions");
2701        let cwd = tmp.path().join("project");
2702        std::fs::create_dir_all(&sessions_dir).unwrap();
2703        std::fs::create_dir_all(&cwd).unwrap();
2704
2705        // Write complete garbage
2706        let file_path = sessions_dir.join("garbage.jsonl");
2707        std::fs::write(
2708            &file_path,
2709            "this is not json\nneither is this\n{half-json\n",
2710        )
2711        .unwrap();
2712
2713        // Should recover gracefully
2714        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2715        assert!(!sm.session_id().is_empty());
2716        assert!(sm.entries().is_empty());
2717    }
2718
2719    #[test]
2720    fn test_corrupt_header_only_file_is_kept() {
2721        let tmp = TempDir::new().unwrap();
2722        let sessions_dir = tmp.path().join("sessions");
2723        let cwd = tmp.path().join("project");
2724        std::fs::create_dir_all(&sessions_dir).unwrap();
2725        std::fs::create_dir_all(&cwd).unwrap();
2726
2727        // Create a session, get its header, then write just the header line
2728        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2729        sm.append_message(&make_user_msg("test"));
2730        sm.append_message(&make_asst_msg("ok"));
2731        let original_id = sm.session_id().to_string();
2732        let file_path = sm.session_file().unwrap().to_path_buf();
2733        drop(sm);
2734
2735        // Read the header line and write only that
2736        let content = std::fs::read_to_string(&file_path).unwrap();
2737        let header_line = content.lines().next().unwrap();
2738        std::fs::write(&file_path, format!("{}\n", header_line)).unwrap();
2739
2740        // Open - should keep the session (header exists, just no entries)
2741        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2742        assert_eq!(sm.session_id(), original_id);
2743        assert!(sm.entries().is_empty());
2744    }
2745
2746    #[test]
2747    fn test_corrupt_malformed_lines_are_skipped() {
2748        let tmp = TempDir::new().unwrap();
2749        let sessions_dir = tmp.path().join("sessions");
2750        let cwd = tmp.path().join("project");
2751        std::fs::create_dir_all(&sessions_dir).unwrap();
2752        std::fs::create_dir_all(&cwd).unwrap();
2753
2754        // Create a valid session
2755        let mut sm = SessionManager::create(&cwd, Some(&sessions_dir));
2756        sm.append_message(&make_user_msg("valid message"));
2757        sm.append_message(&make_asst_msg("valid response"));
2758        let file_path = sm.session_file().unwrap().to_path_buf();
2759        drop(sm);
2760
2761        // Append garbage lines to the file
2762        let mut content = std::fs::read_to_string(&file_path).unwrap();
2763        content.push_str("this is garbage\n");
2764        content.push_str("{incomplete json\n");
2765        content.push('\n'); // blank line
2766        std::fs::write(&file_path, &content).unwrap();
2767
2768        // Open - valid entries should be loaded, garbage skipped
2769        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2770        let ctx = sm.build_session_context();
2771        assert_eq!(ctx.messages.len(), 2);
2772        assert_eq!(
2773            crate::agent::types::message_text(&ctx.messages[0]),
2774            "valid message"
2775        );
2776        assert_eq!(
2777            crate::agent::types::message_text(&ctx.messages[1]),
2778            "valid response"
2779        );
2780    }
2781
2782    #[test]
2783    fn test_corrupt_missing_header_uses_new_id() {
2784        let tmp = TempDir::new().unwrap();
2785        let sessions_dir = tmp.path().join("sessions");
2786        let cwd = tmp.path().join("project");
2787        std::fs::create_dir_all(&sessions_dir).unwrap();
2788        std::fs::create_dir_all(&cwd).unwrap();
2789
2790        // Write only valid entries but no session header
2791        let entry = SessionEntry::Message(MessageEntry {
2792            id: "msg1".to_string(),
2793            parent_id: None,
2794            timestamp: "2026-01-01T00:00:00Z".to_string(),
2795            message: make_user_msg("orphan message"),
2796        });
2797        let json = serde_json::to_string(&entry).unwrap();
2798        let file_path = sessions_dir.join("no_header.jsonl");
2799        std::fs::write(&file_path, format!("{}\n", json)).unwrap();
2800
2801        // Pi-compatible: no valid session header means the file is invalid.
2802        // Should generate new ID, empty entries (fresh start).
2803        let sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2804        assert!(!sm.session_id().is_empty());
2805        assert_eq!(sm.entries().len(), 0);
2806    }
2807
2808    #[test]
2809    fn test_corrupt_file_then_append_works() {
2810        let tmp = TempDir::new().unwrap();
2811        let sessions_dir = tmp.path().join("sessions");
2812        let cwd = tmp.path().join("project");
2813        std::fs::create_dir_all(&sessions_dir).unwrap();
2814        std::fs::create_dir_all(&cwd).unwrap();
2815
2816        // Start with a corrupted file
2817        let file_path = sessions_dir.join("recovered.jsonl");
2818        std::fs::write(&file_path, "garbage\nmore garbage\n").unwrap();
2819
2820        // Open - recovers
2821        let mut sm = SessionManager::open(&file_path, Some(&sessions_dir), None);
2822        assert!(sm.entries().is_empty());
2823
2824        // Should be able to append normally
2825        sm.append_message(&make_user_msg("fresh start"));
2826        sm.append_message(&make_asst_msg("fresh response"));
2827
2828        let ctx = sm.build_session_context();
2829        assert_eq!(ctx.messages.len(), 2);
2830        assert_eq!(
2831            crate::agent::types::message_text(&ctx.messages[0]),
2832            "fresh start"
2833        );
2834
2835        // Verify file was rewritten with valid content
2836        let content = std::fs::read_to_string(&file_path).unwrap();
2837        assert!(content.contains("fresh start"));
2838        assert!(!content.contains("garbage"));
2839    }
2840
2841    #[test]
2842    fn test_corrupt_all_lines_malformed_is_empty() {
2843        let entries = load_entries_from_file(Path::new("/nonexistent/file.jsonl"));
2844        assert!(entries.is_empty());
2845    }
2846
2847    #[test]
2848    fn test_corrupt_malformed_line_returns_none() {
2849        let result = parse_session_entry_line("not valid json");
2850        assert!(result.is_none());
2851    }
2852
2853    #[test]
2854    fn test_corrupt_blank_lines_are_skipped() {
2855        let result = parse_session_entry_line("");
2856        assert!(result.is_none());
2857        let result = parse_session_entry_line("   ");
2858        assert!(result.is_none());
2859    }
2860
2861    #[test]
2862    fn test_corrupt_header_line_malformed_returns_none() {
2863        let result = read_session_header(Path::new("/nonexistent"));
2864        assert!(result.is_none());
2865    }
2866}