Skip to main content

pi/core/sessions/
mod.rs

1//! Append-only JSONL v3 session store.
2//!
3//! Ports `.references/pi/packages/coding-agent/src/core/session-manager.ts` and
4//! `session-cwd.ts`. Disk create is deferred until the first assistant message
5//! (`create_new` / `wx`); historical lines are rewritten only on migration load,
6//! empty-file init, or [`SessionManager::create_branched_session`].
7
8pub mod context;
9pub mod cwd;
10pub mod entries;
11pub mod list;
12
13use std::collections::{HashMap, HashSet};
14use std::fs::{self, File, OpenOptions};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17use std::sync::atomic::{AtomicU64, Ordering};
18
19use pi_agent::AgentMessage;
20use serde_json::Value;
21use thiserror::Error;
22
23use super::config::{
24    PathInputOptions, get_agent_dir, get_sessions_dir, normalize_path, resolve_path,
25};
26use super::messages::{CustomMessageContent, MessageConversionError};
27
28pub use context::{
29    DEFAULT_THINKING_LEVEL, LeafRef, SessionContext, SessionModel, build_context_entries,
30    build_session_context, build_session_path, get_latest_compaction_entry,
31    session_entry_to_context_messages,
32};
33pub use cwd::{
34    MissingSessionCwdError, SessionCwdIssue, SessionCwdSource, assert_session_cwd_exists,
35    format_missing_session_cwd_error, format_missing_session_cwd_prompt,
36    get_missing_session_cwd_issue,
37};
38pub use entries::{
39    BranchSummaryEntry, CURRENT_SESSION_VERSION, CompactionEntry, CustomEntry, CustomMessageEntry,
40    FileEntry, LabelEntry, ModelChangeEntry, NO_MESSAGES_PLACEHOLDER, SessionEntry, SessionHeader,
41    SessionInfoEntry, SessionMessageEntry, ThinkingLevelChangeEntry, assert_valid_session_id,
42    create_session_id, generate_id, load_entries_from_file, migrate_session_entries, now_iso,
43    parse_session_entries, parse_session_entry_line, read_session_header,
44};
45pub use list::{
46    MAX_CONCURRENT_SESSION_INFO_LOADS, SessionInfo, SessionListProgress, build_session_info,
47    find_most_recent_session, list_all_sessions, list_sessions_for_cwd, list_sessions_from_dir,
48    session_cwd_matches,
49};
50
51use entries::{
52    file_entry_to_line, iso_to_millis, load_values_from_file, migrate_values_to_current,
53    path_exists, session_entry_to_line,
54};
55
56// ---------------------------------------------------------------------------
57// Errors
58// ---------------------------------------------------------------------------
59
60/// Errors from session manager operations.
61#[derive(Debug, Error)]
62pub enum SessionError {
63    /// Custom session id failed validation.
64    #[error(
65        "Session id must be non-empty, contain only alphanumeric characters, '-', '_', and '.', and start and end with an alphanumeric character"
66    )]
67    InvalidSessionId,
68    /// Referenced entry id is not in the tree.
69    #[error("Entry {0} not found")]
70    EntryNotFound(String),
71    /// Non-empty file that is not a valid pi session.
72    #[error("Session file is not a valid pi session: {0}")]
73    InvalidSessionFile(String),
74    /// Fork source is empty or invalid.
75    #[error("Cannot fork: source session file is empty or invalid: {0}")]
76    ForkSourceEmpty(String),
77    /// Fork source has no session header.
78    #[error("Cannot fork: source session has no header: {0}")]
79    ForkSourceNoHeader(String),
80    /// Filesystem IO failure.
81    #[error("I/O error on {path}: {source}")]
82    Io {
83        /// Path involved in the failure.
84        path: String,
85        /// Underlying IO error.
86        source: io::Error,
87    },
88    /// JSON serialization failure.
89    #[error("JSON error: {0}")]
90    Json(#[from] serde_json::Error),
91    /// Context projection / message conversion failure.
92    #[error(transparent)]
93    Message(#[from] MessageConversionError),
94}
95
96// assert_valid_session_id in entries.rs returns SessionError defined above.
97
98// ---------------------------------------------------------------------------
99// Options / tree node
100// ---------------------------------------------------------------------------
101
102/// Options for creating a new session.
103#[derive(Clone, Debug, Default)]
104pub struct NewSessionOptions {
105    /// Optional custom session id (validated).
106    pub id: Option<String>,
107    /// Optional parent session file path stored in the header.
108    pub parent_session: Option<String>,
109}
110
111/// Tree node returned by [`SessionManager::get_tree`].
112#[derive(Clone, Debug, PartialEq)]
113pub struct SessionTreeNode {
114    /// Entry at this node.
115    pub entry: SessionEntry,
116    /// Children sorted by timestamp ascending.
117    pub children: Vec<SessionTreeNode>,
118    /// Resolved label for this entry, if any.
119    pub label: Option<String>,
120    /// Timestamp of the latest label change for this entry, if any.
121    pub label_timestamp: Option<String>,
122}
123
124// ---------------------------------------------------------------------------
125// Leaf + labels
126// ---------------------------------------------------------------------------
127
128#[derive(Clone, Debug)]
129enum Leaf {
130    Null,
131    Id(String),
132    /// Last non-session entry had no string id (TS `undefined` leaf).
133    Tail,
134}
135
136/// Insertion-ordered label map (`target_id` → label + timestamp).
137#[derive(Clone, Debug, Default)]
138struct LabelMap {
139    /// Insertion-ordered (target, label, timestamp).
140    entries: Vec<(String, String, String)>,
141    /// target → index into `entries`.
142    index: HashMap<String, usize>,
143}
144
145impl LabelMap {
146    fn clear(&mut self) {
147        self.entries.clear();
148        self.index.clear();
149    }
150
151    fn get(&self, target: &str) -> Option<&str> {
152        self.index.get(target).map(|&i| self.entries[i].1.as_str())
153    }
154
155    fn get_timestamp(&self, target: &str) -> Option<&str> {
156        self.index.get(target).map(|&i| self.entries[i].2.as_str())
157    }
158
159    /// Set or update a label. Existing targets keep their insertion position.
160    fn set(&mut self, target: String, label: String, timestamp: String) {
161        if let Some(&i) = self.index.get(&target) {
162            self.entries[i].1 = label;
163            self.entries[i].2 = timestamp;
164        } else {
165            let i = self.entries.len();
166            self.index.insert(target.clone(), i);
167            self.entries.push((target, label, timestamp));
168        }
169    }
170
171    fn delete(&mut self, target: &str) {
172        let Some(i) = self.index.remove(target) else {
173            return;
174        };
175        self.entries.remove(i);
176        // Rebuild index after removal.
177        self.index.clear();
178        for (idx, (t, _, _)) in self.entries.iter().enumerate() {
179            self.index.insert(t.clone(), idx);
180        }
181    }
182
183    fn iter(&self) -> impl Iterator<Item = (&str, &str, &str)> {
184        self.entries
185            .iter()
186            .map(|(t, l, ts)| (t.as_str(), l.as_str(), ts.as_str()))
187    }
188}
189
190// ---------------------------------------------------------------------------
191// SessionManager
192// ---------------------------------------------------------------------------
193
194/// Append-only JSONL v3 session tree.
195///
196/// Single-writer, no interior locking (matches TypeScript). Historical lines are
197/// never rewritten on normal append.
198#[derive(Debug)]
199pub struct SessionManager {
200    session_id: String,
201    session_file: Option<String>,
202    session_dir: String,
203    cwd: String,
204    persist: bool,
205    flushed: bool,
206    file_entries: Vec<FileEntry>,
207    /// id → index into `file_entries` (Entry variants only).
208    by_id: HashMap<String, usize>,
209    labels: LabelMap,
210    leaf: Leaf,
211}
212
213fn assemble_tree_node(
214    id: &str,
215    node_map: &HashMap<String, SessionTreeNode>,
216    children_of: &HashMap<String, Vec<String>>,
217) -> Option<SessionTreeNode> {
218    let mut node = node_map.get(id)?.clone();
219    node.children.clear();
220    if let Some(kids) = children_of.get(id) {
221        for kid in kids {
222            if let Some(child) = assemble_tree_node(kid, node_map, children_of) {
223                node.children.push(child);
224            }
225        }
226        node.children.sort_by(|a, b| {
227            let ta = a.entry.timestamp().and_then(iso_to_millis);
228            let tb = b.entry.timestamp().and_then(iso_to_millis);
229            match (ta, tb) {
230                (Some(x), Some(y)) => x.cmp(&y),
231                _ => std::cmp::Ordering::Equal,
232            }
233        });
234    }
235    Some(node)
236}
237
238impl SessionManager {
239    fn construct(
240        cwd: &str,
241        session_dir: &str,
242        session_file: Option<String>,
243        persist: bool,
244        options: Option<NewSessionOptions>,
245    ) -> Result<Self, SessionError> {
246        let cwd = path_to_string(&resolve_path(cwd));
247        let session_dir = path_to_string(&normalize_path(session_dir, PathInputOptions::new()));
248        if persist && !session_dir.is_empty() && !path_exists(Path::new(&session_dir)) {
249            fs::create_dir_all(&session_dir).map_err(|source| SessionError::Io {
250                path: session_dir.clone(),
251                source,
252            })?;
253        }
254
255        let mut sm = Self {
256            session_id: String::new(),
257            session_file: None,
258            session_dir,
259            cwd,
260            persist,
261            flushed: false,
262            file_entries: Vec::new(),
263            by_id: HashMap::new(),
264            labels: LabelMap::default(),
265            leaf: Leaf::Null,
266        };
267
268        if let Some(file) = session_file {
269            sm.set_session_file(&file)?;
270        } else {
271            sm.new_session(options)?;
272        }
273        Ok(sm)
274    }
275
276    /// Switch to a different session file (resume / open).
277    ///
278    /// # Errors
279    ///
280    /// Returns IO errors when reading or rewriting the file, invalid-session when a
281    /// non-empty file is not a pi session, and JSON errors during migration.
282    pub fn set_session_file(&mut self, session_file: &str) -> Result<(), SessionError> {
283        let resolved = path_to_string(&resolve_path(session_file));
284        self.session_file = Some(resolved.clone());
285
286        if path_exists(Path::new(&resolved)) {
287            let mut values =
288                load_values_from_file(Path::new(&resolved)).map_err(|source| SessionError::Io {
289                    path: resolved.clone(),
290                    source,
291                })?;
292
293            if values.is_empty() {
294                let size = fs::metadata(&resolved)
295                    .map_err(|source| SessionError::Io {
296                        path: resolved.clone(),
297                        source,
298                    })?
299                    .len();
300                if size > 0 {
301                    return Err(SessionError::InvalidSessionFile(resolved));
302                }
303                // Empty file: init header and rewrite.
304                self.new_session(None)?;
305                self.session_file = Some(resolved);
306                self.rewrite_file()?;
307                self.flushed = true;
308                return Ok(());
309            }
310
311            let header_id = values
312                .iter()
313                .find(|v| v.get("type").and_then(Value::as_str) == Some("session"))
314                .and_then(|h| h.get("id").and_then(Value::as_str))
315                .map(str::to_owned);
316            self.session_id = header_id.unwrap_or_else(create_session_id);
317
318            let migrated = migrate_values_to_current(&mut values);
319            self.file_entries = values
320                .into_iter()
321                .map(entries::file_entry_from_value)
322                .collect();
323            if migrated {
324                self.rewrite_file()?;
325            }
326            self.build_index();
327            self.flushed = true;
328        } else {
329            let explicit = resolved;
330            self.new_session(None)?;
331            self.session_file = Some(explicit);
332        }
333        Ok(())
334    }
335
336    /// Start a new in-memory session (optionally with a deferred file path).
337    ///
338    /// Returns the session file path when persisting, else `None`.
339    ///
340    /// # Errors
341    ///
342    /// Returns a validation error when a custom session id is invalid.
343    pub fn new_session(
344        &mut self,
345        options: Option<NewSessionOptions>,
346    ) -> Result<Option<String>, SessionError> {
347        if let Some(id) = options.as_ref().and_then(|opts| opts.id.as_ref()) {
348            assert_valid_session_id(id)?;
349        }
350        let options = options.unwrap_or_default();
351        self.session_id = options.id.unwrap_or_else(create_session_id);
352        let timestamp = now_iso();
353        let header = SessionHeader::new(
354            self.session_id.clone(),
355            timestamp.clone(),
356            self.cwd.clone(),
357            options.parent_session,
358        );
359        self.file_entries = vec![FileEntry::Header(header)];
360        self.by_id.clear();
361        self.labels.clear();
362        self.leaf = Leaf::Null;
363        self.flushed = false;
364
365        if self.persist {
366            let file_name = session_file_name(&timestamp, &self.session_id);
367            self.session_file = Some(
368                Path::new(&self.session_dir)
369                    .join(file_name)
370                    .to_string_lossy()
371                    .into_owned(),
372            );
373        } else {
374            self.session_file = None;
375        }
376        Ok(self.session_file.clone())
377    }
378
379    fn build_index(&mut self) {
380        self.by_id.clear();
381        self.labels.clear();
382        self.leaf = Leaf::Null;
383        for (idx, fe) in self.file_entries.iter().enumerate() {
384            if fe.is_session_header() {
385                continue;
386            }
387            let Some(entry) = fe.entry() else {
388                continue;
389            };
390            if let Some(id) = entry.id() {
391                self.by_id.insert(id.to_owned(), idx);
392                self.leaf = Leaf::Id(id.to_owned());
393            } else {
394                self.leaf = Leaf::Tail;
395            }
396            if let Some((target, label)) = entry.label_fields() {
397                match (target, label) {
398                    (Some(t), Some(l)) if !l.is_empty() => {
399                        let ts = entry.timestamp().unwrap_or("").to_owned();
400                        self.labels.set(t.to_owned(), l.to_owned(), ts);
401                    }
402                    (Some(t), _) => {
403                        self.labels.delete(t);
404                    }
405                    _ => {}
406                }
407            }
408        }
409    }
410
411    fn rewrite_file(&self) -> Result<(), SessionError> {
412        if !self.persist {
413            return Ok(());
414        }
415        let Some(ref path) = self.session_file else {
416            return Ok(());
417        };
418        atomic_replace_file(Path::new(path), |file| {
419            for entry in &self.file_entries {
420                let line = file_entry_to_line(entry)?;
421                writeln!(file, "{line}").map_err(|source| SessionError::Io {
422                    path: path.clone(),
423                    source,
424                })?;
425            }
426            Ok(())
427        })
428    }
429
430    fn persist_entry_at(&mut self, idx: usize) -> Result<(), SessionError> {
431        if !self.persist {
432            return Ok(());
433        }
434        let Some(path) = self.session_file.clone() else {
435            return Ok(());
436        };
437
438        let has_assistant = self
439            .file_entries
440            .iter()
441            .filter_map(FileEntry::entry)
442            .any(SessionEntry::is_assistant_message);
443
444        if !has_assistant {
445            if self.flushed
446                && let Some(entry) = self.file_entries.get(idx).and_then(FileEntry::entry)
447            {
448                append_line(&path, entry)?;
449            }
450            // else: hold in memory (flushed stays false)
451            return Ok(());
452        }
453
454        if self.flushed
455            && let Some(entry) = self.file_entries.get(idx).and_then(FileEntry::entry)
456        {
457            append_line(&path, entry)?;
458        } else {
459            // Exclusive create (wx) + write ALL lines. Serialize before creating
460            // the destination so JSON failures cannot leave a partial file.
461            let mut contents = String::new();
462            for fe in &self.file_entries {
463                contents.push_str(&file_entry_to_line(fe)?);
464                contents.push('\n');
465            }
466            let mut file = OpenOptions::new()
467                .write(true)
468                .create_new(true)
469                .open(&path)
470                .map_err(|source| SessionError::Io {
471                    path: path.clone(),
472                    source,
473                })?;
474            if let Err(source) = file
475                .write_all(contents.as_bytes())
476                .and_then(|()| file.sync_all())
477            {
478                drop(file);
479                let _ = fs::remove_file(&path);
480                return Err(SessionError::Io {
481                    path: path.clone(),
482                    source,
483                });
484            }
485            self.flushed = true;
486        }
487        Ok(())
488    }
489
490    fn append_entry(&mut self, entry: SessionEntry) -> Result<String, SessionError> {
491        let id = entry.id().unwrap_or("").to_owned();
492        let previous_leaf = self.leaf.clone();
493        let previous_flushed = self.flushed;
494        let previous_index = if id.is_empty() {
495            None
496        } else {
497            self.by_id.insert(id.clone(), self.file_entries.len())
498        };
499        self.file_entries.push(FileEntry::Entry(entry));
500        let idx = self.file_entries.len() - 1;
501        self.leaf = if id.is_empty() {
502            Leaf::Tail
503        } else {
504            Leaf::Id(id.clone())
505        };
506        if let Err(err) = self.persist_entry_at(idx) {
507            self.file_entries.pop();
508            if !id.is_empty() {
509                match previous_index {
510                    Some(index) => {
511                        self.by_id.insert(id.clone(), index);
512                    }
513                    None => {
514                        self.by_id.remove(&id);
515                    }
516                }
517            }
518            self.leaf = previous_leaf;
519            self.flushed = previous_flushed;
520            return Err(err);
521        }
522        Ok(id)
523    }
524
525    fn next_id(&self) -> String {
526        generate_id(|c| self.by_id.contains_key(c))
527    }
528
529    // ----- accessors -----
530
531    /// Whether this manager persists to disk.
532    #[must_use]
533    pub const fn is_persisted(&self) -> bool {
534        self.persist
535    }
536
537    /// Resolved session working directory.
538    #[must_use]
539    pub fn get_cwd(&self) -> &str {
540        &self.cwd
541    }
542
543    /// Session directory used for new/branch files.
544    #[must_use]
545    pub fn get_session_dir(&self) -> &str {
546        &self.session_dir
547    }
548
549    /// True when `session_dir` equals the default encoded path for `cwd`.
550    #[must_use]
551    pub fn uses_default_session_dir(&self) -> bool {
552        let default = default_session_dir_path(&self.cwd, &get_agent_dir());
553        self.session_dir == default
554    }
555
556    /// Session UUID.
557    #[must_use]
558    pub fn get_session_id(&self) -> &str {
559        &self.session_id
560    }
561
562    /// Session file path, if any.
563    #[must_use]
564    pub fn get_session_file(&self) -> Option<&str> {
565        self.session_file.as_deref()
566    }
567
568    /// Current leaf entry id (`None` when null/tail).
569    #[must_use]
570    pub fn get_leaf_id(&self) -> Option<&str> {
571        match &self.leaf {
572            Leaf::Id(id) => Some(id.as_str()),
573            Leaf::Null | Leaf::Tail => None,
574        }
575    }
576
577    /// Current leaf entry.
578    #[must_use]
579    pub fn get_leaf_entry(&self) -> Option<&SessionEntry> {
580        match &self.leaf {
581            Leaf::Id(id) => self.get_entry(id),
582            Leaf::Null | Leaf::Tail => None,
583        }
584    }
585
586    /// Look up an entry by id.
587    #[must_use]
588    pub fn get_entry(&self, id: &str) -> Option<&SessionEntry> {
589        self.by_id
590            .get(id)
591            .and_then(|&i| self.file_entries.get(i))
592            .and_then(FileEntry::entry)
593    }
594
595    /// Direct children of `parent_id` (file order).
596    #[must_use]
597    pub fn get_children(&self, parent_id: &str) -> Vec<&SessionEntry> {
598        self.file_entries
599            .iter()
600            .filter_map(FileEntry::entry)
601            .filter(|e| e.parent_id() == Some(parent_id))
602            .collect()
603    }
604
605    /// Resolved label for an entry id.
606    #[must_use]
607    pub fn get_label(&self, id: &str) -> Option<&str> {
608        self.labels.get(id)
609    }
610
611    /// Session header, if typed parse succeeded.
612    #[must_use]
613    pub fn get_header(&self) -> Option<&SessionHeader> {
614        self.file_entries.iter().find_map(FileEntry::header)
615    }
616
617    /// All non-header entries (file order).
618    #[must_use]
619    pub fn get_entries(&self) -> Vec<&SessionEntry> {
620        self.file_entries
621            .iter()
622            .filter_map(FileEntry::entry)
623            .collect()
624    }
625
626    /// Latest session display name (empty clears).
627    #[must_use]
628    pub fn get_session_name(&self) -> Option<String> {
629        for entry in self.get_entries().into_iter().rev() {
630            if let Some(name) = entry.session_info_name() {
631                return name
632                    .map(str::trim)
633                    .filter(|s| !s.is_empty())
634                    .map(str::to_owned);
635            }
636        }
637        None
638    }
639
640    // ----- append -----
641
642    /// Append a transcript message as child of the current leaf.
643    ///
644    /// # Errors
645    ///
646    /// Returns JSON serialization errors or IO errors when persisting.
647    pub fn append_message(&mut self, message: &AgentMessage) -> Result<String, SessionError> {
648        let id = self.next_id();
649        let parent = self.leaf_parent();
650        let timestamp = now_iso();
651        let value = serde_json::json!({
652            "type": "message",
653            "id": id,
654            "parentId": parent,
655            "timestamp": timestamp,
656            "message": message_to_value(message)?,
657        });
658        let entry: SessionEntry = serde_json::from_value(value)?;
659        self.append_entry(entry)
660    }
661
662    /// Append a thinking-level change.
663    ///
664    /// # Errors
665    ///
666    /// Returns JSON or IO errors when encoding or persisting the entry.
667    pub fn append_thinking_level_change(
668        &mut self,
669        thinking_level: &str,
670    ) -> Result<String, SessionError> {
671        let id = self.next_id();
672        let value = serde_json::json!({
673            "type": "thinking_level_change",
674            "id": id,
675            "parentId": self.leaf_parent(),
676            "timestamp": now_iso(),
677            "thinkingLevel": thinking_level,
678        });
679        let entry: SessionEntry = serde_json::from_value(value)?;
680        self.append_entry(entry)
681    }
682
683    /// Append a model change.
684    ///
685    /// # Errors
686    ///
687    /// Returns JSON or IO errors when encoding or persisting the entry.
688    pub fn append_model_change(
689        &mut self,
690        provider: &str,
691        model_id: &str,
692    ) -> Result<String, SessionError> {
693        let id = self.next_id();
694        let value = serde_json::json!({
695            "type": "model_change",
696            "id": id,
697            "parentId": self.leaf_parent(),
698            "timestamp": now_iso(),
699            "provider": provider,
700            "modelId": model_id,
701        });
702        let entry: SessionEntry = serde_json::from_value(value)?;
703        self.append_entry(entry)
704    }
705
706    /// Append a compaction summary.
707    ///
708    /// # Errors
709    ///
710    /// Returns JSON or IO errors when encoding or persisting the entry.
711    pub fn append_compaction(
712        &mut self,
713        summary: &str,
714        first_kept_entry_id: &str,
715        tokens_before: i64,
716        details: Option<Value>,
717        from_hook: Option<bool>,
718    ) -> Result<String, SessionError> {
719        let id = self.next_id();
720        let mut value = serde_json::json!({
721            "type": "compaction",
722            "id": id,
723            "parentId": self.leaf_parent(),
724            "timestamp": now_iso(),
725            "summary": summary,
726            "firstKeptEntryId": first_kept_entry_id,
727            "tokensBefore": tokens_before,
728        });
729        if let Some(d) = details
730            && let Some(obj) = value.as_object_mut()
731        {
732            obj.insert("details".to_owned(), d);
733        }
734        if let Some(fh) = from_hook
735            && let Some(obj) = value.as_object_mut()
736        {
737            obj.insert("fromHook".to_owned(), Value::Bool(fh));
738        }
739        let entry: SessionEntry = serde_json::from_value(value)?;
740        self.append_entry(entry)
741    }
742
743    /// Append an extension custom state entry (not in LLM context).
744    ///
745    /// # Errors
746    ///
747    /// Returns JSON or IO errors when encoding or persisting the entry.
748    pub fn append_custom_entry(
749        &mut self,
750        custom_type: &str,
751        data: Option<Value>,
752    ) -> Result<String, SessionError> {
753        let id = self.next_id();
754        let mut value = serde_json::json!({
755            "type": "custom",
756            "customType": custom_type,
757            "id": id,
758            "parentId": self.leaf_parent(),
759            "timestamp": now_iso(),
760        });
761        if let Some(d) = data
762            && let Some(obj) = value.as_object_mut()
763        {
764            obj.insert("data".to_owned(), d);
765        }
766        let entry: SessionEntry = serde_json::from_value(value)?;
767        self.append_entry(entry)
768    }
769
770    /// Append a session display-name entry (sanitized).
771    ///
772    /// # Errors
773    ///
774    /// Returns JSON or IO errors when encoding or persisting the entry.
775    pub fn append_session_info(&mut self, name: &str) -> Result<String, SessionError> {
776        // TS: name.replace(/[\r\n]+/g, " ").trim()
777        let sanitized = {
778            let mut result = String::with_capacity(name.len());
779            let mut chars = name.chars().peekable();
780            while let Some(c) = chars.next() {
781                if c == '\r' || c == '\n' {
782                    while matches!(chars.peek(), Some('\r' | '\n')) {
783                        chars.next();
784                    }
785                    result.push(' ');
786                } else {
787                    result.push(c);
788                }
789            }
790            result.trim().to_owned()
791        };
792        let id = self.next_id();
793        let value = serde_json::json!({
794            "type": "session_info",
795            "id": id,
796            "parentId": self.leaf_parent(),
797            "timestamp": now_iso(),
798            "name": sanitized,
799        });
800        let entry: SessionEntry = serde_json::from_value(value)?;
801        self.append_entry(entry)
802    }
803
804    /// Append an extension custom message (participates in LLM context).
805    ///
806    /// # Errors
807    ///
808    /// Returns JSON or IO errors when encoding or persisting the entry.
809    pub fn append_custom_message_entry(
810        &mut self,
811        custom_type: &str,
812        content: &CustomMessageContent,
813        display: bool,
814        details: Option<Value>,
815    ) -> Result<String, SessionError> {
816        let id = self.next_id();
817        let mut value = serde_json::json!({
818            "type": "custom_message",
819            "customType": custom_type,
820            "content": content,
821            "display": display,
822            "id": id,
823            "parentId": self.leaf_parent(),
824            "timestamp": now_iso(),
825        });
826        if let Some(d) = details
827            && let Some(obj) = value.as_object_mut()
828        {
829            obj.insert("details".to_owned(), d);
830        }
831        let entry: SessionEntry = serde_json::from_value(value)?;
832        self.append_entry(entry)
833    }
834
835    /// Set or clear a label on an entry. Empty/None clears.
836    ///
837    /// # Errors
838    ///
839    /// Returns not-found when `target_id` is missing, or JSON/IO errors on persist.
840    pub fn append_label_change(
841        &mut self,
842        target_id: &str,
843        label: Option<&str>,
844    ) -> Result<String, SessionError> {
845        if !self.by_id.contains_key(target_id) {
846            return Err(SessionError::EntryNotFound(target_id.to_owned()));
847        }
848        let id = self.next_id();
849        let timestamp = now_iso();
850        let mut value = serde_json::json!({
851            "type": "label",
852            "id": id,
853            "parentId": self.leaf_parent(),
854            "timestamp": timestamp,
855            "targetId": target_id,
856        });
857        if let Some(l) = label
858            && let Some(obj) = value.as_object_mut()
859        {
860            obj.insert("label".to_owned(), Value::String(l.to_owned()));
861        }
862        let entry: SessionEntry = serde_json::from_value(value)?;
863        let result_id = self.append_entry(entry)?;
864        match label {
865            Some(l) if !l.is_empty() => {
866                self.labels
867                    .set(target_id.to_owned(), l.to_owned(), timestamp);
868            }
869            _ => {
870                self.labels.delete(target_id);
871            }
872        }
873        Ok(result_id)
874    }
875
876    // ----- tree -----
877
878    /// Walk from `from_id` (or current leaf) to root; root→leaf order.
879    ///
880    /// Unlike context path construction, a missing id returns an empty path
881    /// (no last-entry fallback).
882    #[must_use]
883    pub fn get_branch(&self, from_id: Option<&str>) -> Vec<&SessionEntry> {
884        let start = from_id.or(match &self.leaf {
885            Leaf::Id(id) => Some(id.as_str()),
886            Leaf::Null | Leaf::Tail => None,
887        });
888        let Some(start_id) = start.filter(|s| !s.is_empty()) else {
889            return Vec::new();
890        };
891        let mut path = Vec::new();
892        let mut current = self.get_entry(start_id);
893        while let Some(entry) = current {
894            path.push(entry);
895            current = entry.parent_id().and_then(|pid| self.get_entry(pid));
896        }
897        path.reverse();
898        path
899    }
900
901    /// Compaction-aware context entries for the current leaf.
902    #[must_use]
903    pub fn build_context_entries(&self) -> Vec<&SessionEntry> {
904        let entries = self.get_entries();
905        let leaf = self.leaf_ref();
906        context::build_context_entries(&entries, leaf)
907    }
908
909    /// Full session context for the LLM (current leaf).
910    ///
911    /// # Errors
912    ///
913    /// Returns message conversion errors when projecting entries to context messages.
914    pub fn build_session_context(&self) -> Result<SessionContext, MessageConversionError> {
915        let entries = self.get_entries();
916        let leaf = self.leaf_ref();
917        context::build_session_context(&entries, leaf)
918    }
919
920    /// Session as a tree (orphans become roots; children sorted by timestamp).
921    #[must_use]
922    pub fn get_tree(&self) -> Vec<SessionTreeNode> {
923        let entries = self.get_entries();
924        let mut node_map: HashMap<String, SessionTreeNode> = HashMap::new();
925        let mut order: Vec<String> = Vec::new();
926
927        for entry in &entries {
928            let Some(id) = entry.id() else {
929                continue;
930            };
931            if !node_map.contains_key(id) {
932                order.push(id.to_owned());
933            }
934            let label = self.labels.get(id).map(str::to_owned);
935            let label_timestamp = self.labels.get_timestamp(id).map(str::to_owned);
936            node_map.insert(
937                id.to_owned(),
938                SessionTreeNode {
939                    entry: (*entry).clone(),
940                    children: Vec::new(),
941                    label,
942                    label_timestamp,
943                },
944            );
945        }
946
947        let mut roots: Vec<String> = Vec::new();
948        let mut child_links: Vec<(String, String)> = Vec::new(); // (parent, child)
949
950        for entry in &entries {
951            let Some(id) = entry.id() else {
952                continue;
953            };
954            match entry.parent_id() {
955                Some(pid) if pid != id && node_map.contains_key(pid) => {
956                    child_links.push((pid.to_owned(), id.to_owned()));
957                }
958                None | Some(_) => roots.push(id.to_owned()),
959            }
960        }
961
962        let mut children_of: HashMap<String, Vec<String>> = HashMap::new();
963        for (parent, child) in child_links {
964            children_of.entry(parent).or_default().push(child);
965        }
966
967        // Deduplicate roots (dup ids).
968        let mut seen = HashSet::new();
969        let mut root_nodes = Vec::new();
970        for id in roots {
971            if seen.insert(id.clone())
972                && let Some(node) = assemble_tree_node(&id, &node_map, &children_of)
973            {
974                root_nodes.push(node);
975            }
976        }
977        // Sort root children already done per-node; roots themselves unsorted in TS
978        // (file order of first encounter). Keep as-is.
979        let _ = order;
980        root_nodes
981    }
982
983    // ----- branching -----
984
985    /// Move the leaf pointer to an existing entry.
986    ///
987    /// # Errors
988    ///
989    /// Returns not-found when `branch_from_id` is missing.
990    pub fn branch(&mut self, branch_from_id: &str) -> Result<(), SessionError> {
991        if !self.by_id.contains_key(branch_from_id) {
992            return Err(SessionError::EntryNotFound(branch_from_id.to_owned()));
993        }
994        self.leaf = Leaf::Id(branch_from_id.to_owned());
995        Ok(())
996    }
997
998    /// Reset the leaf pointer to null (next append is a new root).
999    pub fn reset_leaf(&mut self) {
1000        self.leaf = Leaf::Null;
1001    }
1002
1003    /// Branch from an entry (or null root) and append a `branch_summary` child.
1004    ///
1005    /// # Errors
1006    ///
1007    /// Returns not-found when a branch id is missing, or JSON/IO errors on persist.
1008    pub fn branch_with_summary(
1009        &mut self,
1010        branch_from_id: Option<&str>,
1011        summary: &str,
1012        details: Option<Value>,
1013        from_hook: Option<bool>,
1014    ) -> Result<String, SessionError> {
1015        if let Some(id) = branch_from_id {
1016            if !self.by_id.contains_key(id) {
1017                return Err(SessionError::EntryNotFound(id.to_owned()));
1018            }
1019            self.leaf = Leaf::Id(id.to_owned());
1020        } else {
1021            self.leaf = Leaf::Null;
1022        }
1023        let id = self.next_id();
1024        let from_id = branch_from_id.unwrap_or("root");
1025        let mut value = serde_json::json!({
1026            "type": "branch_summary",
1027            "id": id,
1028            "parentId": branch_from_id,
1029            "timestamp": now_iso(),
1030            "fromId": from_id,
1031            "summary": summary,
1032        });
1033        if let Some(d) = details
1034            && let Some(obj) = value.as_object_mut()
1035        {
1036            obj.insert("details".to_owned(), d);
1037        }
1038        if let Some(fh) = from_hook
1039            && let Some(obj) = value.as_object_mut()
1040        {
1041            obj.insert("fromHook".to_owned(), Value::Bool(fh));
1042        }
1043        let entry: SessionEntry = serde_json::from_value(value)?;
1044        self.append_entry(entry)
1045    }
1046
1047    /// Create a new session containing only the path to `leaf_id`.
1048    ///
1049    /// Strips label entries, rechains parent ids, re-appends resolved labels
1050    /// with new ids but preserved timestamps. Returns the new file path when
1051    /// persisting (or `None` in-memory).
1052    ///
1053    /// # Errors
1054    ///
1055    /// Returns not-found when `leaf_id` is missing, or JSON/IO errors while writing.
1056    pub fn create_branched_session(
1057        &mut self,
1058        leaf_id: &str,
1059    ) -> Result<Option<String>, SessionError> {
1060        let previous_session_file = self.session_file.clone();
1061        let path: Vec<SessionEntry> = self
1062            .get_branch(Some(leaf_id))
1063            .into_iter()
1064            .cloned()
1065            .collect();
1066        if path.is_empty() {
1067            return Err(SessionError::EntryNotFound(leaf_id.to_owned()));
1068        }
1069
1070        let mut path_without_labels: Vec<SessionEntry> = Vec::new();
1071        let mut path_parent: Option<String> = None;
1072        for entry in path {
1073            if entry.discriminant() == "label" {
1074                continue;
1075            }
1076            let mut cloned = entry;
1077            cloned.set_parent_id(path_parent.clone());
1078            path_parent = cloned.id().map(str::to_owned);
1079            path_without_labels.push(cloned);
1080        }
1081
1082        let new_session_id = create_session_id();
1083        let timestamp = now_iso();
1084        let file_name = session_file_name(&timestamp, &new_session_id);
1085        let new_session_file = Path::new(&self.session_dir)
1086            .join(&file_name)
1087            .to_string_lossy()
1088            .into_owned();
1089
1090        let header = SessionHeader::new(
1091            new_session_id.clone(),
1092            timestamp,
1093            self.cwd.clone(),
1094            if self.persist {
1095                previous_session_file
1096            } else {
1097                None
1098            },
1099        );
1100
1101        let path_ids: HashSet<String> = path_without_labels
1102            .iter()
1103            .filter_map(|e| e.id().map(str::to_owned))
1104            .collect();
1105
1106        let labels_to_write: Vec<(String, String, String)> = self
1107            .labels
1108            .iter()
1109            .filter(|(t, _, _)| path_ids.contains(*t))
1110            .map(|(t, l, ts)| (t.to_owned(), l.to_owned(), ts.to_owned()))
1111            .collect();
1112
1113        let mut collision: HashSet<String> = path_ids;
1114        let mut parent = path_without_labels
1115            .last()
1116            .and_then(|e| e.id().map(str::to_owned));
1117        let mut label_entries: Vec<SessionEntry> = Vec::new();
1118        for (target, label, ts) in labels_to_write {
1119            let lid = generate_id(|c| collision.contains(c));
1120            collision.insert(lid.clone());
1121            let value = serde_json::json!({
1122                "type": "label",
1123                "id": lid,
1124                "parentId": parent,
1125                "timestamp": ts,
1126                "targetId": target,
1127                "label": label,
1128            });
1129            let entry: SessionEntry = serde_json::from_value(value)?;
1130            parent = entry.id().map(str::to_owned);
1131            label_entries.push(entry);
1132        }
1133
1134        let mut file_entries = vec![FileEntry::Header(header)];
1135        file_entries.extend(path_without_labels.into_iter().map(FileEntry::Entry));
1136        file_entries.extend(label_entries.into_iter().map(FileEntry::Entry));
1137
1138        self.file_entries = file_entries;
1139        self.session_id = new_session_id;
1140        self.build_index();
1141
1142        if self.persist {
1143            self.session_file = Some(new_session_file.clone());
1144            let has_assistant = self
1145                .file_entries
1146                .iter()
1147                .filter_map(FileEntry::entry)
1148                .any(SessionEntry::is_assistant_message);
1149            if has_assistant {
1150                self.rewrite_file()?;
1151                self.flushed = true;
1152            } else {
1153                self.flushed = false;
1154            }
1155            Ok(Some(new_session_file))
1156        } else {
1157            // In-memory: session_file and flushed untouched.
1158            Ok(None)
1159        }
1160    }
1161
1162    // ----- static factories -----
1163
1164    /// Create a new persisted session.
1165    ///
1166    /// # Errors
1167    ///
1168    /// Returns validation errors for custom ids, or IO errors creating the session dir.
1169    pub fn create(
1170        cwd: &str,
1171        session_dir: Option<&str>,
1172        options: Option<NewSessionOptions>,
1173    ) -> Result<Self, SessionError> {
1174        let dir = match session_dir {
1175            Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1176            None => default_session_dir(cwd, &get_agent_dir())?,
1177        };
1178        Self::construct(cwd, &dir, None, true, options)
1179    }
1180
1181    /// Open a specific session file.
1182    ///
1183    /// # Errors
1184    ///
1185    /// Returns IO/invalid-session errors while loading the file, or validation/IO
1186    /// errors while constructing the manager.
1187    pub fn open(
1188        path: &str,
1189        session_dir: Option<&str>,
1190        cwd_override: Option<&str>,
1191    ) -> Result<Self, SessionError> {
1192        let resolved = path_to_string(&resolve_path(path));
1193        let entries = load_entries_from_file(Path::new(&resolved));
1194        let header_cwd = entries
1195            .iter()
1196            .find_map(FileEntry::header)
1197            .and_then(|h| h.cwd.clone());
1198        let cwd = cwd_override.map_or_else(
1199            || {
1200                header_cwd.unwrap_or_else(|| {
1201                    std::env::current_dir()
1202                        .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
1203                })
1204            },
1205            str::to_owned,
1206        );
1207        let dir = match session_dir {
1208            Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1209            None => Path::new(&resolved)
1210                .parent()
1211                .map_or_else(|| ".".to_owned(), |p| p.to_string_lossy().into_owned()),
1212        };
1213        Self::construct(&cwd, &dir, Some(resolved), true, None)
1214    }
1215
1216    /// Continue the most recent session, or create new if none.
1217    ///
1218    /// # Errors
1219    ///
1220    /// Returns IO errors discovering sessions or constructing a new manager.
1221    pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Result<Self, SessionError> {
1222        let dir = match session_dir {
1223            Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1224            None => default_session_dir(cwd, &get_agent_dir())?,
1225        };
1226        let filter_cwd =
1227            session_dir.is_some() && dir != default_session_dir_path(cwd, &get_agent_dir());
1228        let most_recent =
1229            find_most_recent_session(Path::new(&dir), if filter_cwd { Some(cwd) } else { None });
1230        match most_recent {
1231            Some(f) => Self::construct(
1232                cwd,
1233                &dir,
1234                Some(f.to_string_lossy().into_owned()),
1235                true,
1236                None,
1237            ),
1238            None => Self::construct(cwd, &dir, None, true, None),
1239        }
1240    }
1241
1242    /// Create an in-memory session (no file persistence).
1243    ///
1244    /// # Errors
1245    ///
1246    /// Returns a validation error when a custom session id is invalid.
1247    pub fn in_memory(
1248        cwd: Option<&str>,
1249        options: Option<NewSessionOptions>,
1250    ) -> Result<Self, SessionError> {
1251        let cwd = cwd.map_or_else(
1252            || {
1253                std::env::current_dir()
1254                    .map_or_else(|_| ".".to_owned(), |p| p.to_string_lossy().into_owned())
1255            },
1256            str::to_owned,
1257        );
1258        Self::construct(&cwd, "", None, false, options)
1259    }
1260
1261    /// Fork a session into a new file under `target_cwd`.
1262    ///
1263    /// Copies **all** non-header source entries as-is (IDs preserved) under a
1264    /// new v3 header with `parentSession = source path`.
1265    ///
1266    /// # Errors
1267    ///
1268    /// Returns fork-source/validation errors, or IO/JSON errors while writing the fork.
1269    pub fn fork_from(
1270        source_path: &str,
1271        target_cwd: &str,
1272        session_dir: Option<&str>,
1273        options: Option<NewSessionOptions>,
1274    ) -> Result<Self, SessionError> {
1275        let resolved_source = path_to_string(&resolve_path(source_path));
1276        let resolved_target = path_to_string(&resolve_path(target_cwd));
1277        let values = load_values_from_file(Path::new(&resolved_source)).map_err(|source| {
1278            SessionError::Io {
1279                path: resolved_source.clone(),
1280                source,
1281            }
1282        })?;
1283        if values.is_empty() {
1284            return Err(SessionError::ForkSourceEmpty(resolved_source));
1285        }
1286        if !values
1287            .iter()
1288            .any(|v| v.get("type").and_then(Value::as_str) == Some("session"))
1289        {
1290            return Err(SessionError::ForkSourceNoHeader(resolved_source));
1291        }
1292
1293        let dir = match session_dir {
1294            Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1295            None => default_session_dir(&resolved_target, &get_agent_dir())?,
1296        };
1297        if !path_exists(Path::new(&dir)) {
1298            fs::create_dir_all(&dir).map_err(|source| SessionError::Io {
1299                path: dir.clone(),
1300                source,
1301            })?;
1302        }
1303
1304        if let Some(id) = options.as_ref().and_then(|opts| opts.id.as_ref()) {
1305            assert_valid_session_id(id)?;
1306        }
1307        let options = options.unwrap_or_default();
1308        let new_session_id = options.id.unwrap_or_else(create_session_id);
1309        let timestamp = now_iso();
1310        let file_name = session_file_name(&timestamp, &new_session_id);
1311        let new_session_file = Path::new(&dir)
1312            .join(file_name)
1313            .to_string_lossy()
1314            .into_owned();
1315
1316        let header = SessionHeader::new(
1317            new_session_id,
1318            timestamp,
1319            resolved_target.clone(),
1320            Some(resolved_source),
1321        );
1322
1323        let mut file = OpenOptions::new()
1324            .write(true)
1325            .create_new(true)
1326            .open(&new_session_file)
1327            .map_err(|source| SessionError::Io {
1328                path: new_session_file.clone(),
1329                source,
1330            })?;
1331        writeln!(file, "{}", serde_json::to_string(&header)?).map_err(|source| {
1332            SessionError::Io {
1333                path: new_session_file.clone(),
1334                source,
1335            }
1336        })?;
1337        for value in &values {
1338            if value.get("type").and_then(Value::as_str) == Some("session") {
1339                continue;
1340            }
1341            writeln!(file, "{}", serde_json::to_string(value)?).map_err(|source| {
1342                SessionError::Io {
1343                    path: new_session_file.clone(),
1344                    source,
1345                }
1346            })?;
1347        }
1348        drop(file);
1349
1350        Self::construct(&resolved_target, &dir, Some(new_session_file), true, None)
1351    }
1352
1353    /// List sessions for a cwd.
1354    ///
1355    /// # Errors
1356    ///
1357    /// Returns IO errors while enumerating or reading session files.
1358    pub async fn list(
1359        cwd: &str,
1360        session_dir: Option<&str>,
1361        on_progress: Option<SessionListProgress<'_>>,
1362    ) -> Result<Vec<SessionInfo>, SessionError> {
1363        let dir = match session_dir {
1364            Some(d) => path_to_string(&normalize_path(d, PathInputOptions::new())),
1365            None => default_session_dir(cwd, &get_agent_dir())?,
1366        };
1367        let filter_cwd =
1368            session_dir.is_some() && dir != default_session_dir_path(cwd, &get_agent_dir());
1369        Ok(list_sessions_for_cwd(cwd, Path::new(&dir), filter_cwd, on_progress).await)
1370    }
1371
1372    /// List all sessions under the agent sessions root (or a custom dir).
1373    pub async fn list_all(
1374        session_dir: Option<&str>,
1375        on_progress: Option<SessionListProgress<'_>>,
1376    ) -> Vec<SessionInfo> {
1377        let custom =
1378            session_dir.map(|d| path_to_string(&normalize_path(d, PathInputOptions::new())));
1379        let root = get_sessions_dir();
1380        list_all_sessions(&root, custom.as_deref().map(Path::new), on_progress).await
1381    }
1382
1383    // ----- helpers -----
1384
1385    fn leaf_parent(&self) -> Option<String> {
1386        match &self.leaf {
1387            Leaf::Id(id) => Some(id.clone()),
1388            Leaf::Null | Leaf::Tail => None,
1389        }
1390    }
1391
1392    fn leaf_ref(&self) -> LeafRef<'_> {
1393        match &self.leaf {
1394            Leaf::Null => LeafRef::Null,
1395            Leaf::Id(id) => LeafRef::Id(id.as_str()),
1396            Leaf::Tail => LeafRef::Last,
1397        }
1398    }
1399}
1400
1401impl SessionCwdSource for SessionManager {
1402    fn get_cwd(&self) -> &str {
1403        &self.cwd
1404    }
1405    fn get_session_file(&self) -> Option<&str> {
1406        self.session_file.as_deref()
1407    }
1408}
1409
1410// ---------------------------------------------------------------------------
1411// Path encoding / helpers
1412// ---------------------------------------------------------------------------
1413
1414/// Encode a resolved cwd into a safe session-directory name segment.
1415///
1416/// Algorithm: strip one leading `/` or `\`, replace `/`, `\`, `:` with `-`,
1417/// wrap in `--…--`.
1418#[must_use]
1419pub fn encode_cwd_for_session_dir(resolved_cwd: &str) -> String {
1420    let stripped = resolved_cwd
1421        .strip_prefix('/')
1422        .or_else(|| resolved_cwd.strip_prefix('\\'))
1423        .unwrap_or(resolved_cwd);
1424    let safe: String = stripped
1425        .chars()
1426        .map(|c| {
1427            if matches!(c, '/' | '\\' | ':') {
1428                '-'
1429            } else {
1430                c
1431            }
1432        })
1433        .collect();
1434    format!("--{safe}--")
1435}
1436
1437/// Default session directory path for a cwd (does not create).
1438#[must_use]
1439pub fn default_session_dir_path(cwd: &str, agent_dir: &Path) -> String {
1440    let resolved_cwd = path_to_string(&resolve_path(cwd));
1441    let resolved_agent = path_to_string(&resolve_path(agent_dir.to_string_lossy()));
1442    let name = encode_cwd_for_session_dir(&resolved_cwd);
1443    Path::new(&resolved_agent)
1444        .join("sessions")
1445        .join(name)
1446        .to_string_lossy()
1447        .into_owned()
1448}
1449
1450/// Default session directory for a cwd (creates if missing).
1451///
1452/// # Errors
1453///
1454/// Returns IO errors when creating the directory.
1455pub fn default_session_dir(cwd: &str, agent_dir: &Path) -> Result<String, SessionError> {
1456    let dir = default_session_dir_path(cwd, agent_dir);
1457    if !path_exists(Path::new(&dir)) {
1458        fs::create_dir_all(&dir).map_err(|source| SessionError::Io {
1459            path: dir.clone(),
1460            source,
1461        })?;
1462    }
1463    Ok(dir)
1464}
1465
1466fn session_file_name(timestamp_iso: &str, session_id: &str) -> String {
1467    let file_ts: String = timestamp_iso
1468        .chars()
1469        .map(|c| if c == ':' || c == '.' { '-' } else { c })
1470        .collect();
1471    format!("{file_ts}_{session_id}.jsonl")
1472}
1473
1474fn path_to_string(path: &Path) -> String {
1475    path.to_string_lossy().into_owned()
1476}
1477
1478fn append_line(path: &str, entry: &SessionEntry) -> Result<(), SessionError> {
1479    let line = session_entry_to_line(entry)?;
1480    let mut record = String::with_capacity(line.len() + 1);
1481    record.push_str(&line);
1482    record.push('\n');
1483    let mut file = OpenOptions::new()
1484        .append(true)
1485        .create(true)
1486        .open(path)
1487        .map_err(|source| SessionError::Io {
1488            path: path.to_owned(),
1489            source,
1490        })?;
1491    file.write_all(record.as_bytes())
1492        .map_err(|source| SessionError::Io {
1493            path: path.to_owned(),
1494            source,
1495        })
1496}
1497
1498fn message_to_value(message: &AgentMessage) -> Result<Value, SessionError> {
1499    Ok(serde_json::to_value(message)?)
1500}
1501
1502static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0);
1503
1504fn atomic_replace_file(
1505    path: &Path,
1506    write_contents: impl FnOnce(&mut File) -> Result<(), SessionError>,
1507) -> Result<(), SessionError> {
1508    let path_text = path.to_string_lossy().into_owned();
1509    let parent = path.parent().unwrap_or_else(|| Path::new("."));
1510    let file_name = path
1511        .file_name()
1512        .and_then(|name| name.to_str())
1513        .unwrap_or("session");
1514    let mut temp_path = PathBuf::new();
1515    let mut temp_file = None;
1516    for _ in 0..100 {
1517        let suffix = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed);
1518        temp_path = parent.join(format!(".{file_name}.tmp-{}-{suffix}", std::process::id()));
1519        match OpenOptions::new()
1520            .write(true)
1521            .create_new(true)
1522            .open(&temp_path)
1523        {
1524            Ok(file) => {
1525                temp_file = Some(file);
1526                break;
1527            }
1528            Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {}
1529            Err(source) => {
1530                return Err(SessionError::Io {
1531                    path: temp_path.to_string_lossy().into_owned(),
1532                    source,
1533                });
1534            }
1535        }
1536    }
1537    let Some(mut file) = temp_file else {
1538        return Err(SessionError::Io {
1539            path: path_text,
1540            source: io::Error::new(
1541                io::ErrorKind::AlreadyExists,
1542                "unable to allocate temporary session file",
1543            ),
1544        });
1545    };
1546
1547    let result = write_contents(&mut file)
1548        .and_then(|()| {
1549            file.flush().map_err(|source| SessionError::Io {
1550                path: temp_path.to_string_lossy().into_owned(),
1551                source,
1552            })
1553        })
1554        .and_then(|()| {
1555            file.sync_all().map_err(|source| SessionError::Io {
1556                path: temp_path.to_string_lossy().into_owned(),
1557                source,
1558            })
1559        });
1560    drop(file);
1561    if let Err(err) = result {
1562        let _ = fs::remove_file(&temp_path);
1563        return Err(err);
1564    }
1565
1566    if let Err(source) = fs::rename(&temp_path, path) {
1567        let _ = fs::remove_file(&temp_path);
1568        return Err(SessionError::Io {
1569            path: path_text,
1570            source,
1571        });
1572    }
1573    sync_parent_directory(parent).map_err(|source| SessionError::Io {
1574        path: parent.to_string_lossy().into_owned(),
1575        source,
1576    })
1577}
1578
1579#[cfg(unix)]
1580fn sync_parent_directory(parent: &Path) -> io::Result<()> {
1581    File::open(parent)?.sync_all()
1582}
1583
1584#[cfg(not(unix))]
1585fn sync_parent_directory(_parent: &Path) -> io::Result<()> {
1586    Ok(())
1587}
1588
1589// ---------------------------------------------------------------------------
1590// Tests
1591// ---------------------------------------------------------------------------
1592
1593#[cfg(test)]
1594mod tests {
1595    use super::*;
1596    use pi_ai::{Message, TextContent, UserMessage, UserMessageContent};
1597    use serde_json::json;
1598    use tempfile::tempdir;
1599
1600    type TestResult = Result<(), Box<dyn std::error::Error>>;
1601
1602    #[test]
1603    fn atomic_replace_failure_preserves_live_file() -> TestResult {
1604        let dir = tempdir()?;
1605        let file = dir.path().join("atomic.jsonl");
1606        fs::write(&file, "original\n")?;
1607
1608        let result = atomic_replace_file(&file, |temp| {
1609            temp.write_all(b"partial replacement\n")
1610                .map_err(|source| SessionError::Io {
1611                    path: file.to_string_lossy().into_owned(),
1612                    source,
1613                })?;
1614            Err(SessionError::Io {
1615                path: file.to_string_lossy().into_owned(),
1616                source: io::Error::other("injected rewrite failure"),
1617            })
1618        });
1619
1620        assert!(result.is_err());
1621        assert_eq!(fs::read_to_string(&file)?, "original\n");
1622        let leftovers: Vec<_> = fs::read_dir(dir.path())?
1623            .flatten()
1624            .filter(|entry| entry.file_name().to_string_lossy().contains(".tmp-"))
1625            .collect();
1626        assert!(leftovers.is_empty(), "failed rewrite must remove temp file");
1627        Ok(())
1628    }
1629
1630    fn path_str(path: &Path) -> Result<&str, Box<dyn std::error::Error>> {
1631        path.to_str()
1632            .ok_or_else(|| "path is not valid UTF-8".into())
1633    }
1634
1635    fn user_agent(text: &str, ts: i64) -> AgentMessage {
1636        AgentMessage::Llm(Box::new(Message::User(UserMessage::new(
1637            UserMessageContent::Text(text.to_owned()),
1638            ts,
1639        ))))
1640    }
1641
1642    fn assistant_agent(text: &str, ts: i64) -> AgentMessage {
1643        let mut msg =
1644            pi_ai::AssistantMessage::new("anthropic-messages", "anthropic", "claude-test", ts);
1645        msg.content = vec![pi_ai::AssistantContent::Text(TextContent::new(text))];
1646        msg.usage = pi_ai::Usage {
1647            input: 1,
1648            output: 1,
1649            cache_read: 0,
1650            cache_write: 0,
1651            total_tokens: 2,
1652            cost: pi_ai::UsageCost::default(),
1653            ..Default::default()
1654        };
1655        AgentMessage::Llm(Box::new(Message::Assistant(msg)))
1656    }
1657
1658    #[test]
1659    fn deferred_write_until_first_assistant() -> TestResult {
1660        let dir = tempdir()?;
1661        let mut sm =
1662            SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1663        let file = sm.get_session_file().ok_or("file")?.to_owned();
1664        assert!(!path_exists(Path::new(&file)), "file must not exist yet");
1665
1666        sm.append_message(&user_agent("hello", 1))?;
1667        assert!(!path_exists(Path::new(&file)), "still deferred after user");
1668
1669        sm.append_message(&assistant_agent("hi", 2))?;
1670        assert!(path_exists(Path::new(&file)), "created on assistant");
1671
1672        let content = fs::read_to_string(&file)?;
1673        let lines: Vec<_> = content.lines().filter(|l| !l.is_empty()).collect();
1674        assert_eq!(lines.len(), 3); // header + user + assistant
1675        let header: Value = serde_json::from_str(lines[0])?;
1676        assert_eq!(header["type"], "session");
1677        assert_eq!(header["version"], 3);
1678        Ok(())
1679    }
1680
1681    #[test]
1682    fn append_prefix_stability() -> TestResult {
1683        let dir = tempdir()?;
1684        let file = dir.path().join("stable.jsonl");
1685        let original = concat!(
1686            r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1687            "\n",
1688            r#"{"type":"message","id":"aaaaaaaa","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1689            "\n",
1690            r#"{"type":"message","id":"bbbbbbbb","parentId":"aaaaaaaa","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1691            "\n",
1692        );
1693        fs::write(&file, original)?;
1694
1695        let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1696        sm.append_message(&user_agent("next", 3))?;
1697
1698        let after = fs::read(&file)?;
1699        assert!(
1700            after.starts_with(original.as_bytes()),
1701            "original lines must be byte-stable on append"
1702        );
1703        Ok(())
1704    }
1705
1706    #[test]
1707    fn failed_append_does_not_advance_tree_and_can_retry() -> TestResult {
1708        let dir = tempdir()?;
1709        let file = dir.path().join("retry.jsonl");
1710        let original = concat!(
1711            r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1712            "\n",
1713            r#"{"type":"message","id":"aaaaaaaa","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1714            "\n",
1715            r#"{"type":"message","id":"bbbbbbbb","parentId":"aaaaaaaa","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1716            "\n",
1717        );
1718        fs::write(&file, original)?;
1719        let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1720        let before_count = sm.get_entries().len();
1721        let before_leaf = sm.get_leaf_id().map(str::to_owned);
1722
1723        let backup = dir.path().join("retry.backup");
1724        fs::rename(&file, &backup)?;
1725        fs::create_dir(&file)?;
1726        let result = sm.append_message(&user_agent("retry me", 3));
1727        assert!(matches!(result, Err(SessionError::Io { .. })));
1728        assert_eq!(sm.get_entries().len(), before_count);
1729        assert_eq!(sm.get_leaf_id(), before_leaf.as_deref());
1730
1731        fs::remove_dir(&file)?;
1732        fs::rename(&backup, &file)?;
1733        let id = sm.append_message(&user_agent("retry me", 3))?;
1734        assert_eq!(sm.get_entries().len(), before_count + 1);
1735        assert_eq!(sm.get_leaf_id(), Some(id.as_str()));
1736        let persisted = fs::read_to_string(&file)?;
1737        assert_eq!(persisted.matches("retry me").count(), 1);
1738        Ok(())
1739    }
1740
1741    #[test]
1742    fn invalid_file_preserved() -> TestResult {
1743        let dir = tempdir()?;
1744        let file = dir.path().join("bad.jsonl");
1745        let original = r#"{"type":"event","data":"not a session"}
1746"#;
1747        fs::write(&file, original)?;
1748        let Err(err) = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)
1749        else {
1750            return Err("expected invalid session error".into());
1751        };
1752        match err {
1753            SessionError::InvalidSessionFile(p) => {
1754                assert!(p.contains("bad.jsonl"));
1755            }
1756            other => return Err(format!("wrong error: {other}").into()),
1757        }
1758        assert_eq!(fs::read_to_string(&file)?, original);
1759        Ok(())
1760    }
1761
1762    #[test]
1763    fn empty_file_gets_header() -> TestResult {
1764        let dir = tempdir()?;
1765        let file = dir.path().join("empty.jsonl");
1766        fs::write(&file, "")?;
1767        let sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1768        assert!(!sm.get_session_id().is_empty());
1769        let content = fs::read_to_string(&file)?;
1770        let header: Value = serde_json::from_str(content.trim())?;
1771        assert_eq!(header["type"], "session");
1772        assert_eq!(header["id"], sm.get_session_id());
1773        Ok(())
1774    }
1775
1776    #[test]
1777    fn unknown_entry_roundtrip_and_excluded_from_context() -> TestResult {
1778        let dir = tempdir()?;
1779        let file = dir.path().join("unknown.jsonl");
1780        let original = concat!(
1781            r#"{"type":"session","version":3,"id":"sess-1","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1782            "\n",
1783            r#"{"type":"future_thing","id":"u1","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","customField":{"a":1}}"#,
1784            "\n",
1785            r#"{"type":"message","id":"m1","parentId":"u1","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"user","content":"hi","timestamp":1}}"#,
1786            "\n",
1787            r#"{"type":"message","id":"m2","parentId":"m1","timestamp":"2025-01-01T00:00:03.000Z","message":{"role":"assistant","content":[{"type":"text","text":"yo"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1788            "\n",
1789        );
1790        fs::write(&file, original)?;
1791
1792        let mut sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1793        assert!(sm.get_entry("u1").is_some());
1794        assert_eq!(
1795            sm.get_entry("u1").map(SessionEntry::discriminant),
1796            Some("future_thing")
1797        );
1798        let ctx = sm.build_session_context()?;
1799        assert_eq!(ctx.messages.len(), 2); // unknown excluded
1800        assert_eq!(ctx.messages[0].role(), "user");
1801
1802        sm.append_message(&user_agent("next", 3))?;
1803        let after = fs::read(&file)?;
1804        assert!(after.starts_with(original.as_bytes()));
1805        Ok(())
1806    }
1807
1808    #[test]
1809    fn leaf_is_last_file_order_not_deepest() -> TestResult {
1810        let dir = tempdir()?;
1811        let file = dir.path().join("leaf.jsonl");
1812        // A → B → C (deep), then D child of A written last.
1813        let content = concat!(
1814            r#"{"type":"session","version":3,"id":"s","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
1815            "\n",
1816            r#"{"type":"message","id":"A","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"user","content":"a","timestamp":1}}"#,
1817            "\n",
1818            r#"{"type":"message","id":"B","parentId":"A","timestamp":"2025-01-01T00:00:02.000Z","message":{"role":"assistant","content":[{"type":"text","text":"b"}],"api":"test","provider":"test","model":"test","usage":{"input":1,"output":1,"cacheRead":0,"cacheWrite":0,"totalTokens":2,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":2}}"#,
1819            "\n",
1820            r#"{"type":"message","id":"C","parentId":"B","timestamp":"2025-01-01T00:00:03.000Z","message":{"role":"user","content":"c","timestamp":3}}"#,
1821            "\n",
1822            r#"{"type":"message","id":"D","parentId":"A","timestamp":"2025-01-01T00:00:04.000Z","message":{"role":"user","content":"d","timestamp":4}}"#,
1823            "\n",
1824        );
1825        fs::write(&file, content)?;
1826        let sm = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1827        assert_eq!(sm.get_leaf_id(), Some("D"));
1828        let branch: Vec<&str> = sm.get_branch(None).iter().filter_map(|e| e.id()).collect();
1829        assert_eq!(branch, vec!["A", "D"]);
1830        Ok(())
1831    }
1832
1833    #[test]
1834    fn labels_and_rechain_on_branched_session() -> TestResult {
1835        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
1836        let msg1 = sm.append_message(&user_agent("hello", 1))?;
1837        sm.append_label_change(&msg1, Some("checkpoint"))?;
1838        let model = sm.append_model_change("anthropic", "claude-test")?;
1839        let msg2 = sm.append_message(&user_agent("followup", 2))?;
1840
1841        sm.create_branched_session(&msg2)?;
1842
1843        assert_eq!(
1844            sm.get_entry(&model).and_then(|e| e.parent_id()),
1845            Some(msg1.as_str())
1846        );
1847        assert_eq!(sm.get_label(&msg1), Some("checkpoint"));
1848        Ok(())
1849    }
1850
1851    #[test]
1852    fn create_branched_session_defers_without_assistant() -> TestResult {
1853        let dir = tempdir()?;
1854        let mut sm =
1855            SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1856        let id1 = sm.append_message(&user_agent("first", 1))?;
1857        sm.append_message(&assistant_agent("answer", 2))?;
1858        sm.append_message(&user_agent("second", 3))?;
1859        sm.append_message(&assistant_agent("answer2", 4))?;
1860
1861        let new_file = sm.create_branched_session(&id1)?.ok_or("path")?;
1862        assert!(
1863            !path_exists(Path::new(&new_file)),
1864            "no assistant on path → deferred"
1865        );
1866        sm.append_custom_entry("preset-state", Some(json!({"name": "plan"})))?;
1867        sm.append_message(&assistant_agent("new answer", 5))?;
1868        assert!(path_exists(Path::new(&new_file)));
1869
1870        let content = fs::read_to_string(&new_file)?;
1871        let records: Vec<Value> = content
1872            .lines()
1873            .filter(|l| !l.is_empty())
1874            .map(serde_json::from_str)
1875            .collect::<Result<_, _>>()?;
1876        assert_eq!(
1877            records
1878                .iter()
1879                .filter(|r| r.get("type").and_then(Value::as_str) == Some("session"))
1880                .count(),
1881            1
1882        );
1883        let ids: Vec<&str> = records
1884            .iter()
1885            .filter(|r| r.get("type").and_then(Value::as_str) != Some("session"))
1886            .filter_map(|r| r.get("id").and_then(Value::as_str))
1887            .collect();
1888        let set: HashSet<&str> = ids.iter().copied().collect();
1889        assert_eq!(set.len(), ids.len(), "no duplicate ids");
1890        Ok(())
1891    }
1892
1893    #[test]
1894    fn create_branched_session_writes_with_assistant() -> TestResult {
1895        let dir = tempdir()?;
1896        let mut sm =
1897            SessionManager::create(path_str(dir.path())?, Some(path_str(dir.path())?), None)?;
1898        sm.append_message(&user_agent("first", 1))?;
1899        let id2 = sm.append_message(&assistant_agent("answer", 2))?;
1900        sm.append_message(&user_agent("second", 3))?;
1901        sm.append_message(&assistant_agent("answer2", 4))?;
1902
1903        let new_file = sm.create_branched_session(&id2)?.ok_or("path")?;
1904        assert!(path_exists(Path::new(&new_file)));
1905        Ok(())
1906    }
1907
1908    #[test]
1909    fn fork_header_and_entries() -> TestResult {
1910        let dir = tempdir()?;
1911        let source = dir.path().join("source.jsonl");
1912        fs::write(
1913            &source,
1914            concat!(
1915                r#"{"type":"session","version":3,"id":"legacy-session-id","timestamp":"2025-01-01T00:00:00.000Z","cwd":"/old"}"#,
1916                "\n",
1917                r#"{"type":"message","id":"entry-1","parentId":null,"timestamp":"2025-01-01T00:00:01.000Z","message":{"role":"assistant","content":[{"type":"text","text":"hello"}],"api":"openai-responses","provider":"openai","model":"gpt-5.4","usage":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"totalTokens":0,"cost":{"input":0,"output":0,"cacheRead":0,"cacheWrite":0,"total":0}},"stopReason":"stop","timestamp":1}}"#,
1918                "\n",
1919            ),
1920        )?;
1921        let forked = SessionManager::fork_from(
1922            path_str(&source)?,
1923            path_str(dir.path())?,
1924            Some(path_str(dir.path())?),
1925            None,
1926        )?;
1927        let header = forked.get_header().ok_or("header")?;
1928        assert_ne!(header.id.as_deref(), Some("legacy-session-id"));
1929        // uuid v7 shape
1930        let id = header.id.as_deref().ok_or("id")?;
1931        assert!(id.contains('-'));
1932        assert_eq!(header.parent_session.as_deref(), Some(path_str(&source)?));
1933        assert!(header.cwd.is_some());
1934        // Entry ids preserved
1935        assert!(forked.get_entry("entry-1").is_some());
1936        Ok(())
1937    }
1938
1939    #[test]
1940    fn encoded_cwd() {
1941        assert_eq!(
1942            encode_cwd_for_session_dir("/home/user/project"),
1943            "--home-user-project--"
1944        );
1945        assert_eq!(encode_cwd_for_session_dir("C:\\Users\\x"), "--C--Users-x--");
1946        let agent = Path::new("/tmp/agent");
1947        let dir = default_session_dir_path("/a/b", agent);
1948        assert!(dir.ends_with("sessions/--a-b--") || dir.contains("sessions/--a-b--"));
1949    }
1950
1951    #[test]
1952    fn custom_session_id_and_filename() -> TestResult {
1953        let dir = tempdir()?;
1954        let sm = SessionManager::create(
1955            path_str(dir.path())?,
1956            Some(path_str(dir.path())?),
1957            Some(NewSessionOptions {
1958                id: Some("created-session-id".to_owned()),
1959                parent_session: None,
1960            }),
1961        )?;
1962        assert_eq!(sm.get_session_id(), "created-session-id");
1963        let file = sm.get_session_file().ok_or("file")?;
1964        let base = Path::new(file)
1965            .file_name()
1966            .ok_or("file name")?
1967            .to_string_lossy();
1968        // YYYY-MM-DDTHH-MM-SS-mmmZ_created-session-id.jsonl
1969        assert!(base.ends_with("_created-session-id.jsonl"));
1970        assert!(!path_exists(Path::new(file)));
1971        Ok(())
1972    }
1973
1974    #[test]
1975    fn open_preserves_explicit_missing_session_path() -> TestResult {
1976        let dir = tempdir()?;
1977        let file = dir.path().join("explicit-session.jsonl");
1978
1979        let manager = SessionManager::open(path_str(&file)?, Some(path_str(dir.path())?), None)?;
1980
1981        assert_eq!(manager.get_session_file(), file.to_str());
1982        assert!(!file.exists());
1983        Ok(())
1984    }
1985
1986    #[test]
1987    fn invalid_session_ids_rejected() -> TestResult {
1988        for id in [
1989            "", "-abc", "abc-", "_abc", "abc_", ".abc", "abc.", "abc/def", "abc\\def", "abc def",
1990        ] {
1991            let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
1992            let Err(err) = sm.new_session(Some(NewSessionOptions {
1993                id: Some(id.to_owned()),
1994                parent_session: None,
1995            })) else {
1996                return Err(format!("expected invalid id for {id:?}").into());
1997            };
1998            assert!(matches!(err, SessionError::InvalidSessionId));
1999        }
2000        Ok(())
2001    }
2002
2003    #[test]
2004    fn tree_and_branch() -> TestResult {
2005        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2006        let id1 = sm.append_message(&user_agent("1", 1))?;
2007        let id2 = sm.append_message(&assistant_agent("2", 2))?;
2008        let id3 = sm.append_message(&user_agent("3", 3))?;
2009        assert_eq!(sm.get_leaf_id(), Some(id3.as_str()));
2010
2011        sm.branch(&id2)?;
2012        let id4 = sm.append_message(&user_agent("4-branch", 4))?;
2013        let tree = sm.get_tree();
2014        assert_eq!(tree.len(), 1);
2015        assert_eq!(tree[0].entry.id(), Some(id1.as_str()));
2016        let node2 = &tree[0].children[0];
2017        assert_eq!(node2.entry.id(), Some(id2.as_str()));
2018        assert_eq!(node2.children.len(), 2);
2019        let child_ids: HashSet<&str> = node2.children.iter().filter_map(|c| c.entry.id()).collect();
2020        assert!(child_ids.contains(id3.as_str()));
2021        assert!(child_ids.contains(id4.as_str()));
2022        Ok(())
2023    }
2024
2025    #[test]
2026    fn model_and_thinking_context() -> TestResult {
2027        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2028        sm.append_message(&user_agent("hello", 1))?;
2029        sm.append_thinking_level_change("high")?;
2030        sm.append_model_change("openai", "gpt-4")?;
2031        sm.append_message(&assistant_agent("hi", 2))?;
2032        let ctx = sm.build_session_context()?;
2033        assert_eq!(ctx.thinking_level, "high");
2034        // assistant overwrites model_change
2035        let model = ctx.model.ok_or("model")?;
2036        assert_eq!(model.provider, "anthropic");
2037        assert_eq!(model.model_id, "claude-test");
2038        assert_eq!(ctx.messages.len(), 2);
2039        Ok(())
2040    }
2041
2042    #[test]
2043    fn compaction_reconstruction() -> TestResult {
2044        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2045        let id1 = sm.append_message(&user_agent("first", 1))?;
2046        sm.append_message(&assistant_agent("r1", 2))?;
2047        let id3 = sm.append_message(&user_agent("second", 3))?;
2048        sm.append_message(&assistant_agent("r2", 4))?;
2049        sm.append_compaction("Summary of first two turns", &id3, 1000, None, None)?;
2050        sm.append_message(&user_agent("third", 5))?;
2051        sm.append_message(&assistant_agent("r3", 6))?;
2052
2053        let ids: Vec<&str> = sm
2054            .build_context_entries()
2055            .iter()
2056            .filter_map(|e| e.id())
2057            .collect();
2058        // compaction + kept from id3 + post
2059        let compaction_id = sm
2060            .get_entries()
2061            .iter()
2062            .find(|e| e.discriminant() == "compaction")
2063            .and_then(|e| e.id())
2064            .ok_or("compaction entry")?;
2065        assert_eq!(ids[0], compaction_id);
2066        assert!(ids.contains(&id3.as_str()));
2067        assert!(!ids.contains(&id1.as_str())); // summarized away
2068
2069        let ctx = sm.build_session_context()?;
2070        assert_eq!(ctx.messages[0].role(), "compactionSummary");
2071        assert_eq!(ctx.messages.len(), 5);
2072        Ok(())
2073    }
2074
2075    #[test]
2076    fn custom_entries_in_tree_not_context() -> TestResult {
2077        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2078        let msg_id = sm.append_message(&user_agent("hello", 1))?;
2079        let custom_id = sm.append_custom_entry("my_data", Some(json!({"foo": "bar"})))?;
2080        sm.append_message(&assistant_agent("hi", 2))?;
2081
2082        let entries = sm.get_entries();
2083        assert_eq!(entries.len(), 3);
2084        assert_eq!(entries[1].discriminant(), "custom");
2085        assert_eq!(entries[1].id(), Some(custom_id.as_str()));
2086        assert_eq!(entries[1].parent_id(), Some(msg_id.as_str()));
2087
2088        let path = sm.get_branch(None);
2089        assert_eq!(path.len(), 3);
2090
2091        let ctx = sm.build_session_context()?;
2092        assert_eq!(ctx.messages.len(), 2);
2093        Ok(())
2094    }
2095
2096    #[test]
2097    fn label_not_found_throws() -> TestResult {
2098        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2099        let Err(err) = sm.append_label_change("non-existent", Some("label")) else {
2100            return Err("expected entry not found".into());
2101        };
2102        assert!(matches!(err, SessionError::EntryNotFound(_)));
2103        assert_eq!(err.to_string(), "Entry non-existent not found");
2104        Ok(())
2105    }
2106
2107    #[test]
2108    fn branch_not_found_throws() -> TestResult {
2109        let mut sm = SessionManager::in_memory(Some("/tmp"), None)?;
2110        sm.append_message(&user_agent("hello", 1))?;
2111        let Err(err) = sm.branch("nonexistent") else {
2112            return Err("expected entry not found".into());
2113        };
2114        assert_eq!(err.to_string(), "Entry nonexistent not found");
2115        Ok(())
2116    }
2117}