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