Skip to main content

rab/agent/session/
model.rs

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