Skip to main content

kiss_coding/session/
manager.rs

1//! SessionManager: append-only JSONL tree with leaf tracking, branching,
2//! context building, and pi-compatible on-disk layout.
3
4use super::entry::{
5    EntryBase, SESSION_VERSION, SessionEntry, SessionHeader, iso_now, new_entry_id,
6};
7use anyhow::{Context as _, Result};
8use kiss_agent::{AgentMessage, BranchSummaryMessage, CompactionSummaryMessage, CustomMessage};
9use kiss_ai::{Model, ThinkingLevel, Usage};
10use serde_json::{Map, Value};
11use std::borrow::Cow;
12use std::collections::{HashMap, VecDeque};
13use std::fs::File;
14use std::io::{BufRead, BufReader, Write};
15use std::path::{Path, PathBuf};
16use std::sync::{Mutex, OnceLock};
17
18const LISTING_CACHE_LIMIT: usize = 4096;
19
20#[derive(Clone, PartialEq, Eq)]
21struct FileVersion {
22    len: u64,
23    modified: std::time::SystemTime,
24    #[cfg(unix)]
25    identity: (u64, u64, i64, i64),
26}
27
28impl FileVersion {
29    fn from_metadata(metadata: &std::fs::Metadata) -> std::io::Result<Self> {
30        #[cfg(unix)]
31        use std::os::unix::fs::MetadataExt;
32        Ok(Self {
33            len: metadata.len(),
34            modified: metadata.modified()?,
35            #[cfg(unix)]
36            identity: (
37                metadata.dev(),
38                metadata.ino(),
39                metadata.ctime(),
40                metadata.ctime_nsec(),
41            ),
42        })
43    }
44}
45
46#[derive(Default)]
47struct ListingCache {
48    rows: HashMap<PathBuf, (FileVersion, SessionListing)>,
49    order: VecDeque<PathBuf>,
50}
51
52static LISTING_CACHE: OnceLock<Mutex<ListingCache>> = OnceLock::new();
53
54pub struct SessionManager {
55    header: SessionHeader,
56    entries: Vec<SessionEntry>,
57    index: HashMap<String, usize>,
58    leaf_id: Option<String>,
59    context_revision: u64,
60    file: Option<PathBuf>,
61    append_file: Option<File>,
62    session_dir: PathBuf,
63    cwd: PathBuf,
64}
65
66/// Slug a working directory the way pi does: `/a/b` -> `--a-b--`.
67pub fn cwd_slug(cwd: &Path) -> String {
68    let s = cwd.display().to_string().replace(['/', '\\', ':'], "-");
69    format!("-{s}-")
70}
71
72pub fn default_session_dir() -> PathBuf {
73    dirs::home_dir()
74        .unwrap_or_else(|| PathBuf::from("."))
75        .join(".kiss/agent/sessions")
76}
77
78#[derive(Debug, Clone)]
79pub struct SessionListing {
80    pub path: PathBuf,
81    pub id: String,
82    pub name: Option<String>,
83    pub first_message: Option<String>,
84    pub cwd: String,
85    pub modified: std::time::SystemTime,
86    pub entry_count: usize,
87}
88
89impl SessionManager {
90    // ----- creation -----------------------------------------------------
91
92    pub fn create(cwd: &Path, session_dir: Option<PathBuf>) -> Result<Self> {
93        let session_dir = session_dir.unwrap_or_else(default_session_dir);
94        let mut manager = Self::in_memory(cwd);
95        manager.session_dir = session_dir;
96        manager.assign_new_file()?;
97        Ok(manager)
98    }
99
100    pub fn in_memory(cwd: &Path) -> Self {
101        SessionManager {
102            header: SessionHeader {
103                entry_type: "session".into(),
104                version: SESSION_VERSION,
105                id: uuid::Uuid::new_v4().to_string(),
106                timestamp: iso_now(),
107                cwd: cwd.display().to_string(),
108                parent_session: None,
109                extra: Map::new(),
110            },
111            entries: Vec::new(),
112            index: HashMap::new(),
113            leaf_id: None,
114            context_revision: 0,
115            file: None,
116            append_file: None,
117            session_dir: default_session_dir(),
118            cwd: cwd.to_path_buf(),
119        }
120    }
121
122    /// Restore an in-memory session from entries managed by an SDK caller.
123    pub fn in_memory_with_entries(cwd: &Path, entries: Vec<SessionEntry>) -> Self {
124        let mut manager = Self::in_memory(cwd);
125        for entry in entries {
126            manager.insert_entry(entry);
127        }
128        manager.leaf_id = manager.entries.last().map(|entry| entry.id().to_string());
129        manager
130    }
131
132    pub fn open(path: &Path) -> Result<Self> {
133        let file = File::open(path).with_context(|| format!("open session {}", path.display()))?;
134        let mut reader = BufReader::with_capacity(64 * 1024, file);
135        let large_line_capacity = reader.get_ref().metadata()?.len().min(16 * 1024 * 1024) as usize;
136        let mut line = String::new();
137        loop {
138            line.clear();
139            if reader.read_line(&mut line)? == 0 {
140                anyhow::bail!(
141                    "The session file {} is empty. Select a JSONL session with a session header, or start a new session.",
142                    path.display()
143                );
144            }
145            if !line.trim().is_empty() {
146                break;
147            }
148        }
149        let mut needs_trailing_newline = !line.ends_with('\n');
150        let header: SessionHeader = serde_json::from_str(&line).context("parse session header")?;
151        let cwd = PathBuf::from(&header.cwd);
152        let mut manager = SessionManager {
153            header,
154            entries: Vec::new(),
155            index: HashMap::new(),
156            leaf_id: None,
157            context_revision: 0,
158            file: Some(path.to_path_buf()),
159            append_file: None,
160            session_dir: path
161                .parent()
162                .and_then(|p| p.parent())
163                .map(|p| p.to_path_buf())
164                .unwrap_or_else(default_session_dir),
165            cwd,
166        };
167        let mut all_entries_valid = true;
168        loop {
169            line.clear();
170            // Long records reserve once instead of copying a growing line repeatedly.
171            let buffer_capacity = reader.capacity();
172            let available = reader.fill_buf()?;
173            if available.len() >= buffer_capacity / 2 && !available.contains(&b'\n') {
174                line.reserve(large_line_capacity);
175            }
176            if reader.read_line(&mut line)? == 0 {
177                break;
178            }
179            needs_trailing_newline = !line.ends_with('\n');
180            if line.trim().is_empty() {
181                continue;
182            }
183            match serde_json::from_str::<SessionEntry>(&line) {
184                Ok(entry) => manager.insert_entry(entry),
185                Err(err) => {
186                    all_entries_valid = false;
187                    eprintln!("warning: skipping malformed session line: {err}");
188                }
189            }
190        }
191        manager.leaf_id = manager.entries.last().map(|e| e.id().to_string());
192        if needs_trailing_newline && all_entries_valid {
193            let mut file = std::fs::OpenOptions::new().append(true).open(path)?;
194            file.write_all(b"\n")?;
195        }
196        manager.reopen_append_file()?;
197        Ok(manager)
198    }
199
200    pub fn continue_recent(cwd: &Path, session_dir: Option<PathBuf>) -> Result<Self> {
201        let dir = session_dir.clone().unwrap_or_else(default_session_dir);
202        let listings = Self::list(cwd, &dir)?;
203        match listings.first() {
204            Some(listing) => Self::open(&listing.path),
205            None => Self::create(cwd, session_dir),
206        }
207    }
208
209    pub fn create_sibling(&self) -> Result<Self> {
210        if self.is_persisted() {
211            Self::create(&self.cwd, Some(self.session_dir.clone()))
212        } else {
213            let mut manager = Self::in_memory(&self.cwd);
214            manager.session_dir = self.session_dir.clone();
215            Ok(manager)
216        }
217    }
218
219    /// Create a child session in the same working directory and session store.
220    pub fn create_child(&self) -> Result<Self> {
221        let mut manager = self.create_sibling()?;
222        manager.header.parent_session = Some(
223            self.file
224                .as_ref()
225                .map(|path| path.display().to_string())
226                .unwrap_or_else(|| format!("session:{}", self.header.id)),
227        );
228        Ok(manager)
229    }
230
231    /// Fork: copy the source session's entries into a new file for `cwd`.
232    pub fn fork_from(source: &Path, cwd: &Path, session_dir: Option<PathBuf>) -> Result<Self> {
233        let origin = Self::open(source)?;
234        let mut manager = Self::create(cwd, session_dir)?;
235        manager.header.parent_session = Some(source.display().to_string());
236        for entry in &origin.entries {
237            manager.insert_entry(entry.clone());
238        }
239        manager.leaf_id = origin.leaf_id.clone();
240        manager.rewrite_file()?;
241        Ok(manager)
242    }
243
244    /// Copy one active branch into a new session file.
245    ///
246    /// `through` selects the last entry to inspect. When `include_through` is
247    /// false, the new session ends at that entry's parent. This lets an
248    /// interactive caller put a selected user message back in the editor.
249    pub fn fork_active_branch(&self, through: Option<&str>, include_through: bool) -> Result<Self> {
250        if let Some(entry_id) = through
251            && self.get_entry(entry_id).is_none()
252        {
253            anyhow::bail!("unknown entry id {entry_id}");
254        }
255
256        let mut branch = self.branch_entries(through);
257        if !include_through && through.is_some() {
258            branch.pop();
259        }
260
261        let mut manager = self.create_sibling()?;
262        manager.header.parent_session = self.file.as_ref().map(|path| path.display().to_string());
263        manager.entries.clear();
264        manager.index.clear();
265        manager.leaf_id = None;
266        for entry in branch {
267            let entry = entry.clone();
268            manager.leaf_id = Some(entry.id().to_string());
269            manager.insert_entry(entry);
270        }
271        manager.rewrite_file()?;
272        Ok(manager)
273    }
274
275    // ----- listing ------------------------------------------------------
276
277    pub fn list(cwd: &Path, session_dir: &Path) -> Result<Vec<SessionListing>> {
278        let dir = session_dir.join(cwd_slug(cwd));
279        let mut out = Self::list_dir(&dir)?;
280        out.sort_by_key(|listing| std::cmp::Reverse(listing.modified));
281        Ok(out)
282    }
283
284    pub fn list_all(session_dir: &Path) -> Result<Vec<SessionListing>> {
285        let mut out = Vec::new();
286        if let Ok(entries) = std::fs::read_dir(session_dir) {
287            for entry in entries.flatten() {
288                if entry.path().is_dir() {
289                    out.extend(Self::list_dir(&entry.path())?);
290                }
291            }
292        }
293        out.sort_by_key(|listing| std::cmp::Reverse(listing.modified));
294        Ok(out)
295    }
296
297    fn list_dir(dir: &Path) -> Result<Vec<SessionListing>> {
298        let mut out = Vec::new();
299        let Ok(entries) = std::fs::read_dir(dir) else {
300            return Ok(out);
301        };
302        for file in entries.flatten() {
303            let path = file.path();
304            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
305                continue;
306            }
307            if let Some(listing) = Self::peek(&path) {
308                out.push(listing);
309            }
310        }
311        Ok(out)
312    }
313
314    /// Reuse unchanged listing rows. JSONL remains authoritative.
315    fn peek(path: &Path) -> Option<SessionListing> {
316        let version = FileVersion::from_metadata(&std::fs::metadata(path).ok()?).ok()?;
317        let cache = LISTING_CACHE.get_or_init(|| Mutex::new(ListingCache::default()));
318        if cfg!(unix)
319            && let Ok(cache) = cache.lock()
320            && let Some((cached_version, listing)) = cache.rows.get(path)
321            && *cached_version == version
322        {
323            // Unix ctime also invalidates permission changes. Other platforms
324            // scan the file until they have an equally strong generation key.
325            return Some(listing.clone());
326        }
327        let mut reader = BufReader::with_capacity(64 * 1024, File::open(path).ok()?);
328        let mut line = String::new();
329        loop {
330            line.clear();
331            if reader.read_line(&mut line).ok()? == 0 {
332                return None;
333            }
334            if !line.trim().is_empty() {
335                break;
336            }
337        }
338        let header: SessionHeader = serde_json::from_str(&line).ok()?;
339        let mut name = None;
340        let mut first_message = None;
341        let mut entry_count = 0usize;
342        loop {
343            line.clear();
344            if reader.read_line(&mut line).ok()? == 0 {
345                break;
346            }
347            if line.trim().is_empty() {
348                continue;
349            }
350            entry_count += 1;
351            if name.is_none()
352                && line.contains("\"session_info\"")
353                && let Ok(v) = serde_json::from_str::<Value>(&line)
354                && v["type"] == "session_info"
355            {
356                name = v["name"].as_str().map(String::from);
357            }
358            if first_message.is_none()
359                && line.contains("\"message\"")
360                && let Ok(v) = serde_json::from_str::<Value>(&line)
361                && v["type"] == "message"
362                && v["message"]["role"] == "user"
363            {
364                let content = &v["message"]["content"];
365                let text = content.as_str().map(String::from).or_else(|| {
366                    content
367                        .as_array()
368                        .and_then(|a| a.iter().find(|b| b["type"] == "text"))
369                        .and_then(|b| b["text"].as_str().map(String::from))
370                });
371                first_message = text.map(|t| t.chars().take(120).collect());
372            }
373        }
374        if entry_count == 0 {
375            return None;
376        }
377        let listing = SessionListing {
378            path: path.to_path_buf(),
379            id: header.id,
380            name,
381            first_message,
382            cwd: header.cwd,
383            modified: version.modified,
384            entry_count,
385        };
386        // A concurrent append or path replacement must not certify a stale row.
387        if cfg!(unix)
388            && FileVersion::from_metadata(&reader.get_ref().metadata().ok()?).ok()? == version
389            && FileVersion::from_metadata(&std::fs::metadata(path).ok()?).ok()? == version
390            && let Ok(mut cache) = cache.lock()
391        {
392            if !cache.rows.contains_key(path) {
393                if cache.rows.len() == LISTING_CACHE_LIMIT
394                    && let Some(oldest) = cache.order.pop_front()
395                {
396                    cache.rows.remove(&oldest);
397                }
398                cache.order.push_back(path.to_path_buf());
399            }
400            cache
401                .rows
402                .insert(path.to_path_buf(), (version, listing.clone()));
403        }
404        Some(listing)
405    }
406
407    /// Find a session file by partial UUID across all projects.
408    pub fn find_by_id(session_dir: &Path, partial: &str) -> Result<Option<PathBuf>> {
409        for listing in Self::list_all(session_dir)? {
410            if listing.id.starts_with(partial) {
411                return Ok(Some(listing.path));
412            }
413        }
414        Ok(None)
415    }
416
417    // ----- appends ------------------------------------------------------
418
419    fn make_base(&self) -> EntryBase {
420        EntryBase {
421            id: new_entry_id(),
422            parent_id: self.leaf_id.clone(),
423            timestamp: iso_now(),
424        }
425    }
426
427    pub fn append_message(&mut self, message: AgentMessage) -> Result<String> {
428        self.append_entry(|base| SessionEntry::Message {
429            base,
430            message,
431            extra: Map::new(),
432        })
433    }
434
435    pub fn append_messages_with_session_info<I>(&mut self, messages: I, name: &str) -> Result<()>
436    where
437        I: IntoIterator<Item = AgentMessage>,
438    {
439        let mut parent_id = self.leaf_id.clone();
440        let mut entries = Vec::new();
441        for message in messages {
442            let entry = SessionEntry::Message {
443                base: EntryBase {
444                    id: new_entry_id(),
445                    parent_id: parent_id.clone(),
446                    timestamp: iso_now(),
447                },
448                message,
449                extra: Map::new(),
450            };
451            parent_id = Some(entry.id().to_string());
452            entries.push(entry);
453        }
454        entries.push(SessionEntry::SessionInfo {
455            base: EntryBase {
456                id: new_entry_id(),
457                parent_id,
458                timestamp: iso_now(),
459            },
460            name: Some(name.to_string()),
461            extra: Map::new(),
462        });
463        self.append_entries(entries)
464    }
465
466    fn append_entries(&mut self, entries: Vec<SessionEntry>) -> Result<()> {
467        let mut lines = String::new();
468        for entry in &entries {
469            lines.push_str(&serde_json::to_string(&entry)?);
470            lines.push('\n');
471        }
472        if !lines.is_empty() {
473            self.append_lines(&lines)?;
474        }
475        for entry in entries {
476            self.leaf_id = Some(entry.id().to_string());
477            self.insert_entry(entry);
478        }
479        Ok(())
480    }
481
482    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> Result<String> {
483        let (provider, model_id) = (provider.to_string(), model_id.to_string());
484        self.append_entry(|base| SessionEntry::ModelChange {
485            base,
486            provider,
487            model_id,
488            extra: Map::new(),
489        })
490    }
491
492    pub fn append_thinking_level_change(&mut self, level: ThinkingLevel) -> Result<String> {
493        self.append_entry(|base| SessionEntry::ThinkingLevelChange {
494            base,
495            thinking_level: level,
496            extra: Map::new(),
497        })
498    }
499
500    pub fn append_usage(
501        &mut self,
502        kind: &str,
503        provider: &str,
504        model: &str,
505        usage: Usage,
506        note: Option<String>,
507    ) -> Result<String> {
508        let (kind, provider, model) = (kind.to_string(), provider.to_string(), model.to_string());
509        self.append_entry(|base| SessionEntry::Usage {
510            base,
511            kind,
512            provider,
513            model,
514            usage,
515            note,
516            extra: Map::new(),
517        })
518    }
519
520    #[allow(clippy::too_many_arguments)]
521    pub fn append_compaction(
522        &mut self,
523        summary: String,
524        tokens_before: u64,
525        retained_tail: Vec<AgentMessage>,
526        usage: Option<Usage>,
527        details: Option<Value>,
528    ) -> Result<String> {
529        self.append_entry(|base| SessionEntry::Compaction {
530            base,
531            summary,
532            tokens_before,
533            first_kept_entry_id: None,
534            retained_tail: Some(retained_tail),
535            usage,
536            details,
537            extra: Map::new(),
538        })
539    }
540
541    pub fn append_session_info(&mut self, name: &str) -> Result<String> {
542        let name = name.to_string();
543        self.append_entry(|base| SessionEntry::SessionInfo {
544            base,
545            name: Some(name),
546            extra: Map::new(),
547        })
548    }
549
550    pub fn append_label(&mut self, target_id: &str, label: Option<String>) -> Result<String> {
551        let target_id = target_id.to_string();
552        self.append_entry(|base| SessionEntry::Label {
553            base,
554            target_id,
555            label,
556            extra: Map::new(),
557        })
558    }
559
560    pub fn append_custom(&mut self, custom_type: &str, data: Option<Value>) -> Result<String> {
561        let custom_type = custom_type.to_string();
562        self.append_entry(|base| SessionEntry::Custom {
563            base,
564            custom_type,
565            data,
566            extra: Map::new(),
567        })
568    }
569
570    fn append_entry(&mut self, make: impl FnOnce(EntryBase) -> SessionEntry) -> Result<String> {
571        let entry = make(self.make_base());
572        let id = entry.id().to_string();
573        self.append_entries(vec![entry])?;
574        Ok(id)
575    }
576
577    fn insert_entry(&mut self, entry: SessionEntry) {
578        self.index
579            .insert(entry.id().to_string(), self.entries.len());
580        self.entries.push(entry);
581        self.context_revision = self.context_revision.wrapping_add(1);
582    }
583
584    fn append_lines(&mut self, lines: &str) -> Result<()> {
585        if self.file.is_none() {
586            return Ok(());
587        }
588        if self.append_file.is_none() {
589            let path = self.file.as_ref().expect("a persisted session has a path");
590            if let Some(parent) = path.parent() {
591                std::fs::create_dir_all(parent)?;
592            }
593            let is_new = !path.exists();
594            let mut file = std::fs::OpenOptions::new()
595                .create(true)
596                .append(true)
597                .open(path)?;
598            if is_new {
599                writeln!(file, "{}", serde_json::to_string(&self.header)?)?;
600            }
601            self.append_file = Some(file);
602        }
603        let file = self
604            .append_file
605            .as_mut()
606            .expect("a persisted session has an append file");
607        file.write_all(lines.as_bytes())?;
608        file.flush()?;
609        file.sync_data()?;
610        Ok(())
611    }
612
613    fn reopen_append_file(&mut self) -> Result<()> {
614        self.append_file = match &self.file {
615            Some(path) => Some(std::fs::OpenOptions::new().append(true).open(path)?),
616            None => None,
617        };
618        Ok(())
619    }
620
621    fn assign_new_file(&mut self) -> Result<()> {
622        let dir = self.session_dir.join(cwd_slug(&self.cwd));
623        let stamp = chrono::Utc::now().format("%Y-%m-%d-%H-%M-%S");
624        let path = dir.join(format!("{stamp}_{}.jsonl", self.header.id));
625        self.file = Some(path);
626        Ok(())
627    }
628
629    /// Rewrite the whole file (header + entries). Used at creation and fork.
630    fn rewrite_file(&mut self) -> Result<()> {
631        let Some(path) = self.file.clone() else {
632            return Ok(());
633        };
634        if self.entries.is_empty() && !path.exists() {
635            return Ok(());
636        }
637        self.append_file = None;
638        let mut out = serde_json::to_string(&self.header)?;
639        out.push('\n');
640        for entry in &self.entries {
641            out.push_str(&serde_json::to_string(entry)?);
642            out.push('\n');
643        }
644        if let Some(parent) = path.parent() {
645            std::fs::create_dir_all(parent)?;
646        }
647        let parent = path
648            .parent()
649            .context("session file has no parent directory")?;
650        let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
651        temporary.write_all(out.as_bytes())?;
652        temporary.as_file().sync_all()?;
653        temporary.persist(&path).map_err(|error| error.error)?;
654        self.reopen_append_file()?;
655        Ok(())
656    }
657
658    // ----- tree ---------------------------------------------------------
659
660    pub fn get_entry(&self, id: &str) -> Option<&SessionEntry> {
661        self.index.get(id).map(|&i| &self.entries[i])
662    }
663
664    pub fn entries(&self) -> &[SessionEntry] {
665        &self.entries
666    }
667
668    pub fn leaf_id(&self) -> Option<&str> {
669        self.leaf_id.as_deref()
670    }
671
672    /// Monotonic value for cache invalidation after context or tree changes.
673    pub fn context_revision(&self) -> u64 {
674        self.context_revision
675    }
676
677    /// Move the leaf to an earlier entry (in-place branching).
678    pub fn branch(&mut self, entry_id: &str) -> Result<()> {
679        if self.get_entry(entry_id).is_none() {
680            anyhow::bail!("unknown entry id {entry_id}");
681        }
682        self.leaf_id = Some(entry_id.to_string());
683        self.context_revision = self.context_revision.wrapping_add(1);
684        Ok(())
685    }
686
687    /// Reset the leaf to before any entries.
688    pub fn reset_leaf(&mut self) {
689        self.leaf_id = None;
690        self.context_revision = self.context_revision.wrapping_add(1);
691    }
692
693    /// Branch to `entry_id` and record a summary of the abandoned path.
694    pub fn branch_with_summary(
695        &mut self,
696        entry_id: Option<&str>,
697        from_id: &str,
698        summary: String,
699        usage: Option<Usage>,
700        details: Option<Value>,
701    ) -> Result<String> {
702        if let Some(entry_id) = entry_id {
703            self.branch(entry_id)?;
704        } else {
705            self.reset_leaf();
706        }
707        let from_id = from_id.to_string();
708        self.append_entry(|base| SessionEntry::BranchSummary {
709            base,
710            from_id,
711            summary,
712            usage,
713            details,
714            extra: Map::new(),
715        })
716    }
717
718    pub fn children(&self, parent_id: Option<&str>) -> Vec<&SessionEntry> {
719        self.entries
720            .iter()
721            .filter(|e| e.parent_id() == parent_id)
722            .collect()
723    }
724
725    /// Walk leaf -> root. Returns entries in root-first order.
726    pub fn branch_entries(&self, from: Option<&str>) -> Vec<&SessionEntry> {
727        let mut out = Vec::new();
728        let mut cursor = from.or(self.leaf_id.as_deref()).map(String::from);
729        while let Some(id) = cursor {
730            let Some(entry) = self.get_entry(&id) else {
731                break;
732            };
733            out.push(entry);
734            cursor = entry.parent_id().map(String::from);
735        }
736        out.reverse();
737        out
738    }
739
740    /// LLM messages on one branch after an optional common ancestor.
741    pub fn branch_messages_after(&self, from: &str, ancestor: Option<&str>) -> Vec<AgentMessage> {
742        let path = self.branch_entries(Some(from));
743        let start = ancestor
744            .and_then(|ancestor| path.iter().position(|entry| entry.id() == ancestor))
745            .map_or(0, |position| position + 1);
746        let mut messages = Vec::new();
747        for entry in &path[start..] {
748            match entry {
749                SessionEntry::Message { message, .. } => messages.push(message.clone()),
750                SessionEntry::Compaction {
751                    summary,
752                    tokens_before,
753                    base,
754                    ..
755                } => messages.push(AgentMessage::CompactionSummary(CompactionSummaryMessage {
756                    summary: summary.clone(),
757                    tokens_before: *tokens_before,
758                    timestamp: chrono::DateTime::parse_from_rfc3339(&base.timestamp)
759                        .map(|time| time.timestamp_millis())
760                        .unwrap_or_else(|_| kiss_ai::now_ms()),
761                })),
762                SessionEntry::BranchSummary {
763                    summary,
764                    from_id,
765                    base,
766                    ..
767                } => messages.push(AgentMessage::BranchSummary(BranchSummaryMessage {
768                    summary: summary.clone(),
769                    from_id: from_id.clone(),
770                    timestamp: chrono::DateTime::parse_from_rfc3339(&base.timestamp)
771                        .map(|time| time.timestamp_millis())
772                        .unwrap_or_else(|_| kiss_ai::now_ms()),
773                })),
774                SessionEntry::CustomMessage {
775                    custom_type,
776                    content,
777                    display,
778                    details,
779                    base,
780                    ..
781                } => messages.push(AgentMessage::Custom(CustomMessage {
782                    custom_type: custom_type.clone(),
783                    content: content.clone(),
784                    display: *display,
785                    details: details.clone(),
786                    timestamp: chrono::DateTime::parse_from_rfc3339(&base.timestamp)
787                        .map(|time| time.timestamp_millis())
788                        .unwrap_or_else(|_| kiss_ai::now_ms()),
789                })),
790                _ => {}
791            }
792        }
793        messages
794    }
795
796    /// Latest session name on the active path.
797    pub fn session_name(&self) -> Option<String> {
798        self.branch_entries(None)
799            .iter()
800            .rev()
801            .find_map(|e| match e {
802                SessionEntry::SessionInfo { name, .. } => name.clone(),
803                _ => None,
804            })
805    }
806
807    pub fn label_of(&self, target: &str) -> Option<String> {
808        // Labels apply tree-wide. Last write wins.
809        self.entries
810            .iter()
811            .rev()
812            .find_map(|e| match e {
813                SessionEntry::Label {
814                    target_id, label, ..
815                } if target_id == target => Some(label.clone()),
816                _ => None,
817            })
818            .flatten()
819    }
820
821    // ----- context building --------------------------------------------
822
823    /// Active-branch entries with compaction applied: on a compaction with a
824    /// retained tail, the checkpoint replaces everything before it.
825    pub fn build_context_entries(&self) -> Vec<&SessionEntry> {
826        let path = self.branch_entries(None);
827        let compaction_pos = path
828            .iter()
829            .rposition(|e| matches!(e, SessionEntry::Compaction { .. }));
830        match compaction_pos {
831            Some(pos) => {
832                let SessionEntry::Compaction {
833                    retained_tail,
834                    first_kept_entry_id,
835                    ..
836                } = path[pos]
837                else {
838                    unreachable!()
839                };
840                let mut out: Vec<&SessionEntry> = Vec::new();
841                out.push(path[pos]);
842                if retained_tail.is_none() {
843                    // Legacy: include entries from firstKeptEntryId up to the
844                    // compaction entry.
845                    if let Some(first_kept) = first_kept_entry_id
846                        && let Some(start) = path.iter().position(|e| e.id() == first_kept)
847                    {
848                        for e in &path[start..pos] {
849                            out.push(e);
850                        }
851                    }
852                }
853                for e in &path[pos + 1..] {
854                    out.push(e);
855                }
856                out
857            }
858            None => path,
859        }
860    }
861
862    /// Saved model and thinking level on the full active branch.
863    pub fn session_settings(&self) -> (Option<(String, String)>, Option<ThinkingLevel>) {
864        // Model/thinking come from the full active path.
865        let path = self.branch_entries(None);
866        let mut model: Option<(String, String)> = None;
867        let mut thinking: Option<ThinkingLevel> = None;
868        for entry in &path {
869            match entry {
870                SessionEntry::ModelChange {
871                    provider, model_id, ..
872                } => {
873                    model = Some((provider.clone(), model_id.clone()));
874                }
875                SessionEntry::ThinkingLevelChange { thinking_level, .. } => {
876                    thinking = Some(*thinking_level)
877                }
878                _ => {}
879            }
880        }
881
882        (model, thinking)
883    }
884
885    /// Count active context messages without copying their content.
886    pub fn context_message_count(&self) -> usize {
887        self.build_context_entries()
888            .iter()
889            .map(|entry| match entry {
890                SessionEntry::Message { .. }
891                | SessionEntry::BranchSummary { .. }
892                | SessionEntry::CustomMessage { .. } => 1,
893                SessionEntry::Compaction {
894                    summary,
895                    retained_tail,
896                    ..
897                } => usize::from(!summary.is_empty()) + retained_tail.as_ref().map_or(0, Vec::len),
898                _ => 0,
899            })
900            .sum()
901    }
902
903    /// Messages, model and thinking level for the next model call.
904    pub fn build_session_context(&self) -> SessionContext {
905        let (model, thinking_level) = self.session_settings();
906        SessionContext {
907            messages: self.context_messages().map(Cow::into_owned).collect(),
908            model,
909            thinking_level,
910        }
911    }
912
913    pub(crate) fn context_messages(
914        &self,
915    ) -> impl DoubleEndedIterator<Item = Cow<'_, AgentMessage>> {
916        self.build_context_entries().into_iter().flat_map(|entry| {
917            let message = match entry {
918                SessionEntry::Message { message, .. } => Some(Cow::Borrowed(message)),
919                SessionEntry::Compaction {
920                    summary,
921                    tokens_before,
922                    base,
923                    ..
924                } if !summary.is_empty() => Some(Cow::Owned(AgentMessage::CompactionSummary(
925                    CompactionSummaryMessage {
926                        summary: summary.clone(),
927                        tokens_before: *tokens_before,
928                        timestamp: entry_timestamp(base),
929                    },
930                ))),
931                _ => context_message_from_entry(entry).map(Cow::Owned),
932            };
933            let tail = match entry {
934                SessionEntry::Compaction { retained_tail, .. } => {
935                    retained_tail.as_deref().unwrap_or(&[])
936                }
937                _ => &[],
938            };
939            message.into_iter().chain(tail.iter().map(Cow::Borrowed))
940        })
941    }
942
943    /// Restore provider-native OpenAI history from the latest compaction on
944    /// the active branch. Completed turns from another model are excluded.
945    pub fn build_openai_compaction_context(
946        &self,
947        model: &Model,
948    ) -> Option<OpenAICompactionContext> {
949        let path = self.branch_entries(None);
950        let compaction_pos = path
951            .iter()
952            .rposition(|entry| matches!(entry, SessionEntry::Compaction { .. }))?;
953        let SessionEntry::Compaction {
954            details: Some(details),
955            ..
956        } = path[compaction_pos]
957        else {
958            return None;
959        };
960        let remote = details.get("remoteCompaction")?.as_object()?;
961        if remote.get("version")?.as_u64()? != 2
962            || remote.get("provider")?.as_str()? != "openai-responses-compaction"
963            || remote.get("modelKey")?.as_str()?
964                != kiss_ai::api::openai_compaction::model_key(model)
965        {
966            return None;
967        }
968        let replacement_history = remote
969            .get("replacementHistory")?
970            .as_array()?
971            .iter()
972            .filter(|item| item.get("type").and_then(Value::as_str).is_some())
973            .cloned()
974            .collect::<Vec<_>>();
975        if replacement_history.is_empty()
976            || !replacement_history
977                .iter()
978                .any(|item| item["type"] == "compaction")
979        {
980            return None;
981        }
982
983        let mut messages = Vec::new();
984        let mut pending = Vec::new();
985        for entry in &path[compaction_pos + 1..] {
986            let Some(message) = context_message_from_entry(entry) else {
987                continue;
988            };
989            match &message {
990                AgentMessage::Assistant(assistant) => {
991                    if assistant.provider == model.provider && assistant.model == model.id {
992                        messages.append(&mut pending);
993                        messages.push(message);
994                    } else {
995                        pending.clear();
996                    }
997                }
998                _ => pending.push(message),
999            }
1000        }
1001        // Pending messages are the current unfinished turn. They have not yet
1002        // received an assistant completion from a possibly different model.
1003        messages.append(&mut pending);
1004        Some(OpenAICompactionContext {
1005            replacement_history,
1006            messages,
1007        })
1008    }
1009
1010    // ----- info ---------------------------------------------------------
1011
1012    pub fn header(&self) -> &SessionHeader {
1013        &self.header
1014    }
1015
1016    pub fn session_id(&self) -> &str {
1017        &self.header.id
1018    }
1019
1020    pub fn session_file(&self) -> Option<&Path> {
1021        self.file.as_deref()
1022    }
1023
1024    pub fn cwd(&self) -> &Path {
1025        &self.cwd
1026    }
1027
1028    pub fn session_dir(&self) -> &Path {
1029        &self.session_dir
1030    }
1031
1032    pub fn is_persisted(&self) -> bool {
1033        self.file.is_some()
1034    }
1035
1036    /// Sum every provider operation recorded in this append-only session.
1037    pub fn usage_totals(&self) -> Usage {
1038        let mut totals = Usage::default();
1039        for entry in &self.entries {
1040            match entry {
1041                SessionEntry::Message {
1042                    message: AgentMessage::Assistant(assistant),
1043                    ..
1044                } => totals.add(&assistant.usage),
1045                SessionEntry::Compaction {
1046                    usage: Some(usage), ..
1047                }
1048                | SessionEntry::BranchSummary {
1049                    usage: Some(usage), ..
1050                }
1051                | SessionEntry::Usage { usage, .. } => totals.add(usage),
1052                _ => {}
1053            }
1054        }
1055        totals
1056    }
1057
1058    /// Serialize the full append-only session file format.
1059    pub fn to_jsonl(&self) -> Result<String> {
1060        let mut output = serde_json::to_string(&self.header)?;
1061        output.push('\n');
1062        for entry in &self.entries {
1063            output.push_str(&serde_json::to_string(entry)?);
1064            output.push('\n');
1065        }
1066        Ok(output)
1067    }
1068}
1069
1070#[derive(Debug, Clone)]
1071pub struct SessionContext {
1072    pub messages: Vec<AgentMessage>,
1073    pub model: Option<(String, String)>,
1074    pub thinking_level: Option<ThinkingLevel>,
1075}
1076
1077#[derive(Debug, Clone, PartialEq)]
1078pub struct OpenAICompactionContext {
1079    pub replacement_history: Vec<Value>,
1080    pub messages: Vec<AgentMessage>,
1081}
1082
1083fn context_message_from_entry(entry: &SessionEntry) -> Option<AgentMessage> {
1084    match entry {
1085        SessionEntry::Message { message, .. } => Some(message.clone()),
1086        SessionEntry::BranchSummary {
1087            summary,
1088            from_id,
1089            base,
1090            ..
1091        } => Some(AgentMessage::BranchSummary(BranchSummaryMessage {
1092            summary: summary.clone(),
1093            from_id: from_id.clone(),
1094            timestamp: entry_timestamp(base),
1095        })),
1096        SessionEntry::CustomMessage {
1097            custom_type,
1098            content,
1099            display,
1100            details,
1101            base,
1102            ..
1103        } => Some(AgentMessage::Custom(CustomMessage {
1104            custom_type: custom_type.clone(),
1105            content: content.clone(),
1106            display: *display,
1107            details: details.clone(),
1108            timestamp: entry_timestamp(base),
1109        })),
1110        _ => None,
1111    }
1112}
1113
1114fn entry_timestamp(base: &EntryBase) -> i64 {
1115    chrono::DateTime::parse_from_rfc3339(&base.timestamp)
1116        .map(|time| time.timestamp_millis())
1117        .unwrap_or_else(|_| kiss_ai::now_ms())
1118}
1119
1120#[cfg(test)]
1121mod tests {
1122    use super::*;
1123    use kiss_ai::{AssistantMessage, ContentBlock, ModelCost, StopReason};
1124    use std::collections::BTreeMap;
1125
1126    fn manager() -> SessionManager {
1127        SessionManager::in_memory(Path::new("/tmp/project"))
1128    }
1129
1130    fn openai_model() -> Model {
1131        Model {
1132            id: "gpt-test".into(),
1133            name: "GPT test".into(),
1134            api: "openai-responses".into(),
1135            provider: "openai".into(),
1136            base_url: "https://api.openai.com/v1".into(),
1137            reasoning: true,
1138            input: vec!["text".into()],
1139            cost: ModelCost::default(),
1140            prompt_cache: None,
1141            context_window: 100_000,
1142            max_tokens: 1_000,
1143            compat: None,
1144            thinking_level_map: BTreeMap::new(),
1145            headers: BTreeMap::new(),
1146            sampling_params: Default::default(),
1147            sampling_params_by_thinking_level: Default::default(),
1148        }
1149    }
1150
1151    fn assistant(provider: &str, model: &str, text: &str) -> AgentMessage {
1152        let mut message = AssistantMessage::empty("openai-responses", provider, model);
1153        message.content.push(ContentBlock::text(text));
1154        message.stop_reason = StopReason::Stop;
1155        AgentMessage::Assistant(message)
1156    }
1157
1158    fn remote_details(model: &Model) -> Value {
1159        kiss_ai::api::openai_compaction::build_remote_compaction_details(
1160            model,
1161            &kiss_ai::api::openai_compaction::RemoteCompactionResult {
1162                replacement_history: vec![
1163                    serde_json::json!({
1164                        "type": "message",
1165                        "role": "user",
1166                        "content": [{"type": "input_text", "text": "old request"}]
1167                    }),
1168                    serde_json::json!({
1169                        "type": "compaction",
1170                        "encrypted_content": "opaque"
1171                    }),
1172                ],
1173                usage: None,
1174            },
1175        )
1176    }
1177
1178    #[test]
1179    fn linear_appends_build_context() {
1180        let mut m = manager();
1181        m.append_message(AgentMessage::user("one")).unwrap();
1182        m.append_message(AgentMessage::user("two")).unwrap();
1183        let ctx = m.build_session_context();
1184        assert_eq!(ctx.messages.len(), 2);
1185    }
1186
1187    #[test]
1188    fn usage_totals_include_saved_provider_operations() {
1189        let mut manager = manager();
1190        let mut answer = AssistantMessage::empty("openai-responses", "openai", "gpt-test");
1191        answer.usage = Usage {
1192            input: 20,
1193            cache_read: 80,
1194            cache_read_available: true,
1195            ..Default::default()
1196        };
1197        manager
1198            .append_message(AgentMessage::Assistant(answer))
1199            .unwrap();
1200        manager
1201            .append_compaction(
1202                "summary".into(),
1203                100,
1204                Vec::new(),
1205                Some(Usage {
1206                    input: 10,
1207                    ..Default::default()
1208                }),
1209                None,
1210            )
1211            .unwrap();
1212
1213        let totals = manager.usage_totals();
1214        assert_eq!(totals.input, 30);
1215        assert_eq!(totals.cache_read, 80);
1216        assert!(totals.cache_read_available);
1217    }
1218
1219    #[test]
1220    fn batched_messages_with_info_persist_as_one_branch() {
1221        let dir = tempfile::tempdir().unwrap();
1222        let cwd = dir.path().join("project");
1223        let mut m = SessionManager::create(&cwd, Some(dir.path().join("sessions"))).unwrap();
1224        m.append_messages_with_session_info(
1225            [AgentMessage::user("one"), assistant("test", "test", "two")],
1226            "imported",
1227        )
1228        .unwrap();
1229
1230        let reopened = SessionManager::open(m.session_file().unwrap()).unwrap();
1231        assert_eq!(reopened.entries().len(), 3);
1232        assert_eq!(reopened.branch_entries(None).len(), 3);
1233        assert_eq!(reopened.session_name().as_deref(), Some("imported"));
1234    }
1235
1236    #[test]
1237    fn branching_moves_leaf() {
1238        let mut m = manager();
1239        let a = m.append_message(AgentMessage::user("a")).unwrap();
1240        let _b = m.append_message(AgentMessage::user("b")).unwrap();
1241        m.branch(&a).unwrap();
1242        let c = m.append_message(AgentMessage::user("c")).unwrap();
1243        let path: Vec<String> = m
1244            .branch_entries(None)
1245            .iter()
1246            .map(|e| e.id().to_string())
1247            .collect();
1248        assert_eq!(path, vec![a, c]);
1249        assert_eq!(m.children(path.first().map(String::as_str)).len(), 2);
1250        assert_eq!(m.context_message_count(), 2);
1251    }
1252
1253    #[test]
1254    fn branch_summary_can_move_to_root() {
1255        let mut m = manager();
1256        let first = m.append_message(AgentMessage::user("first")).unwrap();
1257        let second = m.append_message(AgentMessage::user("second")).unwrap();
1258        let messages = m.branch_messages_after(&second, Some(&first));
1259        assert!(matches!(
1260            messages.as_slice(),
1261            [AgentMessage::User(user)] if user.content.as_text() == "second"
1262        ));
1263
1264        let summary = m
1265            .branch_with_summary(None, &second, "old work".into(), None, None)
1266            .unwrap();
1267        assert_eq!(m.leaf_id(), Some(summary.as_str()));
1268        assert_eq!(m.get_entry(&summary).unwrap().parent_id(), None);
1269        assert_eq!(m.context_message_count(), 1);
1270    }
1271
1272    #[test]
1273    fn compaction_with_retained_tail_replaces_history() {
1274        let mut m = manager();
1275        m.append_message(AgentMessage::user("old1")).unwrap();
1276        m.append_message(AgentMessage::user("old2")).unwrap();
1277        m.append_compaction(
1278            "summary of old".into(),
1279            5000,
1280            vec![AgentMessage::user("kept")],
1281            None,
1282            None,
1283        )
1284        .unwrap();
1285        m.append_message(AgentMessage::user("new")).unwrap();
1286        let ctx = m.build_session_context();
1287        let texts: Vec<String> = ctx
1288            .messages
1289            .iter()
1290            .map(|msg| match msg {
1291                AgentMessage::User(u) => u.content.as_text(),
1292                AgentMessage::CompactionSummary(c) => format!("summary:{}", c.summary),
1293                other => other.role().to_string(),
1294            })
1295            .collect();
1296        assert_eq!(texts, vec!["summary:summary of old", "kept", "new"]);
1297        assert_eq!(m.context_message_count(), 3);
1298    }
1299
1300    #[test]
1301    fn empty_summary_checkpoint_replays_only_verbatim_history() {
1302        let mut manager = manager();
1303        manager
1304            .append_message(AgentMessage::user("discarded"))
1305            .unwrap();
1306        let retained = vec![AgentMessage::user("verbatim")];
1307        manager
1308            .append_compaction(String::new(), 100, retained.clone(), None, None)
1309            .unwrap();
1310
1311        assert_eq!(manager.build_session_context().messages, retained);
1312        assert_eq!(manager.context_message_count(), 1);
1313    }
1314
1315    #[test]
1316    fn matching_openai_model_restores_remote_history_and_trailing_turns() {
1317        let model = openai_model();
1318        let mut manager = manager();
1319        manager.append_message(AgentMessage::user("old")).unwrap();
1320        manager
1321            .append_compaction(
1322                "portable summary".into(),
1323                2_000,
1324                vec![AgentMessage::user("local tail")],
1325                None,
1326                Some(remote_details(&model)),
1327            )
1328            .unwrap();
1329        manager
1330            .append_message(AgentMessage::user("after compaction"))
1331            .unwrap();
1332        manager
1333            .append_message(assistant("openai", "gpt-test", "continued"))
1334            .unwrap();
1335
1336        let restored = manager.build_openai_compaction_context(&model).unwrap();
1337        assert_eq!(restored.replacement_history.len(), 2);
1338        assert_eq!(restored.replacement_history[1]["type"], "compaction");
1339        assert_eq!(restored.messages.len(), 2);
1340        assert!(matches!(
1341            &restored.messages[0],
1342            AgentMessage::User(user) if user.content.as_text() == "after compaction"
1343        ));
1344    }
1345
1346    #[test]
1347    fn remote_history_drops_other_model_turns_but_keeps_current_prompt() {
1348        let model = openai_model();
1349        let mut manager = manager();
1350        manager
1351            .append_compaction(
1352                "portable summary".into(),
1353                2_000,
1354                vec![],
1355                None,
1356                Some(remote_details(&model)),
1357            )
1358            .unwrap();
1359        manager
1360            .append_message(AgentMessage::user("question for Claude"))
1361            .unwrap();
1362        manager
1363            .append_message(assistant("anthropic", "claude-test", "Claude answer"))
1364            .unwrap();
1365        manager
1366            .append_message(AgentMessage::user("back to OpenAI"))
1367            .unwrap();
1368
1369        let restored = manager.build_openai_compaction_context(&model).unwrap();
1370        assert_eq!(restored.messages.len(), 1);
1371        assert!(matches!(
1372            &restored.messages[0],
1373            AgentMessage::User(user) if user.content.as_text() == "back to OpenAI"
1374        ));
1375
1376        let mut other_model = model.clone();
1377        other_model.id = "gpt-other".into();
1378        assert!(
1379            manager
1380                .build_openai_compaction_context(&other_model)
1381                .is_none()
1382        );
1383    }
1384
1385    #[test]
1386    fn persist_and_reopen_roundtrip() {
1387        let dir = tempfile::tempdir().unwrap();
1388        let cwd = dir.path().join("proj");
1389        std::fs::create_dir_all(&cwd).unwrap();
1390        let mut m = SessionManager::create(&cwd, Some(dir.path().join("sessions"))).unwrap();
1391        m.append_message(AgentMessage::user("persisted")).unwrap();
1392        m.append_session_info("my task").unwrap();
1393        let path = m.session_file().unwrap().to_path_buf();
1394
1395        let reopened = SessionManager::open(&path).unwrap();
1396        assert_eq!(reopened.entries().len(), 2);
1397        assert_eq!(reopened.session_name().as_deref(), Some("my task"));
1398        let listings = SessionManager::list(&cwd, &dir.path().join("sessions")).unwrap();
1399        assert_eq!(listings.len(), 1);
1400        assert_eq!(listings[0].name.as_deref(), Some("my task"));
1401    }
1402
1403    #[test]
1404    fn empty_session_is_lazy_and_header_only_files_are_hidden() {
1405        let dir = tempfile::tempdir().unwrap();
1406        let cwd = dir.path().join("project");
1407        let sessions = dir.path().join("sessions");
1408        let mut manager = SessionManager::create(&cwd, Some(sessions.clone())).unwrap();
1409        let path = manager.session_file().unwrap().to_path_buf();
1410        assert!(!path.exists());
1411
1412        let legacy = path.with_file_name("legacy.jsonl");
1413        std::fs::create_dir_all(legacy.parent().unwrap()).unwrap();
1414        std::fs::write(
1415            &legacy,
1416            format!("{}\n", serde_json::to_string(manager.header()).unwrap()),
1417        )
1418        .unwrap();
1419        assert!(SessionManager::list(&cwd, &sessions).unwrap().is_empty());
1420
1421        manager
1422            .append_message(AgentMessage::user("first message"))
1423            .unwrap();
1424        assert!(path.exists());
1425        assert_eq!(SessionManager::list(&cwd, &sessions).unwrap().len(), 1);
1426    }
1427
1428    #[test]
1429    fn child_session_records_its_parent() {
1430        let parent = manager();
1431        let child = parent.create_child().unwrap();
1432        assert_eq!(
1433            child.header().parent_session.as_deref(),
1434            Some(format!("session:{}", parent.session_id()).as_str())
1435        );
1436
1437        let dir = tempfile::tempdir().unwrap();
1438        let cwd = dir.path().join("project");
1439        std::fs::create_dir_all(&cwd).unwrap();
1440        let parent = SessionManager::create(&cwd, Some(dir.path().join("sessions"))).unwrap();
1441        let parent_file = parent.session_file().unwrap().display().to_string();
1442        let mut child = parent.create_child().unwrap();
1443        assert!(!child.session_file().unwrap().exists());
1444        child
1445            .append_message(AgentMessage::user("child work"))
1446            .unwrap();
1447        let reopened = SessionManager::open(child.session_file().unwrap()).unwrap();
1448        assert_eq!(
1449            reopened.header().parent_session.as_deref(),
1450            Some(parent_file.as_str())
1451        );
1452    }
1453
1454    #[test]
1455    fn scalar_count_preserves_legacy_tail_and_hidden_custom_messages() {
1456        let mut m = manager();
1457        m.append_message(AgentMessage::user("discarded")).unwrap();
1458        let kept = m.append_message(AgentMessage::user("kept")).unwrap();
1459        m.append_entry(|base| SessionEntry::Compaction {
1460            base,
1461            summary: "legacy summary".into(),
1462            tokens_before: 100,
1463            first_kept_entry_id: Some(kept),
1464            retained_tail: None,
1465            usage: None,
1466            details: None,
1467            extra: Map::new(),
1468        })
1469        .unwrap();
1470        m.append_custom("extension-state", None).unwrap();
1471        m.append_entry(|base| SessionEntry::CustomMessage {
1472            base,
1473            custom_type: "hidden".into(),
1474            content: serde_json::from_value(serde_json::json!("")).unwrap(),
1475            display: false,
1476            details: None,
1477            extra: Map::new(),
1478        })
1479        .unwrap();
1480        assert_eq!(m.context_message_count(), 3);
1481        let context = m.build_session_context();
1482        assert_eq!(context.messages.len(), 3);
1483        assert!(
1484            matches!(&context.messages[1], AgentMessage::User(user) if user.content.as_text() == "kept")
1485        );
1486        assert!(
1487            matches!(&context.messages[0], AgentMessage::CompactionSummary(summary) if summary.summary == "legacy summary")
1488        );
1489        assert!(matches!(&context.messages[2], AgentMessage::Custom(message) if !message.display));
1490    }
1491
1492    #[test]
1493    fn model_and_thinking_tracked() {
1494        let mut m = manager();
1495        m.append_model_change("anthropic", "claude-sonnet-4-5")
1496            .unwrap();
1497        m.append_thinking_level_change(ThinkingLevel::High).unwrap();
1498        let ctx = m.build_session_context();
1499        assert_eq!(
1500            ctx.model,
1501            Some(("anthropic".into(), "claude-sonnet-4-5".into()))
1502        );
1503        assert_eq!(ctx.thinking_level, Some(ThinkingLevel::High));
1504        assert!(ctx.messages.is_empty());
1505        m.append_compaction(String::new(), 0, Vec::new(), None, None)
1506            .unwrap();
1507        assert_eq!(
1508            m.session_settings(),
1509            (
1510                Some(("anthropic".into(), "claude-sonnet-4-5".into())),
1511                Some(ThinkingLevel::High)
1512            )
1513        );
1514        assert_eq!(m.context_message_count(), 0);
1515    }
1516
1517    #[test]
1518    fn active_branch_fork_excludes_selected_user_message_for_editing() {
1519        let dir = tempfile::tempdir().unwrap();
1520        let cwd = dir.path().join("project");
1521        std::fs::create_dir_all(&cwd).unwrap();
1522        let mut source = SessionManager::create(&cwd, Some(dir.path().join("sessions"))).unwrap();
1523        let first = source.append_message(AgentMessage::user("first")).unwrap();
1524        let selected = source
1525            .append_message(AgentMessage::user("selected"))
1526            .unwrap();
1527        source
1528            .append_message(AgentMessage::user("abandoned"))
1529            .unwrap();
1530
1531        let fork = source.fork_active_branch(Some(&selected), false).unwrap();
1532        assert_eq!(fork.leaf_id(), Some(first.as_str()));
1533        assert_eq!(fork.entries().len(), 1);
1534        assert_ne!(fork.session_id(), source.session_id());
1535        assert_eq!(
1536            fork.header().parent_session.as_deref(),
1537            source
1538                .session_file()
1539                .map(|path| path.to_string_lossy())
1540                .as_deref()
1541        );
1542    }
1543
1544    #[test]
1545    fn jsonl_serialization_reopens() {
1546        let dir = tempfile::tempdir().unwrap();
1547        let target = dir.path().join("session.jsonl");
1548        let mut source = manager();
1549        source
1550            .append_message(AgentMessage::user("portable"))
1551            .unwrap();
1552        std::fs::write(&target, source.to_jsonl().unwrap()).unwrap();
1553        let reopened = SessionManager::open(&target).unwrap();
1554        assert_eq!(reopened.entries().len(), 1);
1555    }
1556
1557    #[test]
1558    fn in_memory_session_restores_external_entries() {
1559        let mut original = SessionManager::in_memory(Path::new("/work"));
1560        original
1561            .append_message(AgentMessage::user("restored"))
1562            .unwrap();
1563        let restored =
1564            SessionManager::in_memory_with_entries(Path::new("/work"), original.entries().to_vec());
1565        assert_eq!(
1566            restored.build_session_context().messages,
1567            original.build_session_context().messages
1568        );
1569        assert_eq!(restored.leaf_id(), original.leaf_id());
1570        assert!(!restored.is_persisted());
1571    }
1572
1573    #[test]
1574    fn listing_cache_tracks_append_replacement_and_deletion() {
1575        let dir = tempfile::tempdir().unwrap();
1576        let cwd = dir.path().join("project");
1577        let sessions = dir.path().join("sessions");
1578        let mut manager = SessionManager::create(&cwd, Some(sessions.clone())).unwrap();
1579        manager.append_message(AgentMessage::user("first")).unwrap();
1580        let path = manager.session_file().unwrap().to_path_buf();
1581        for _ in 0..2 {
1582            let rows = SessionManager::list(&cwd, &sessions).unwrap();
1583            assert_eq!(rows[0].first_message.as_deref(), Some("first"));
1584            assert_eq!(rows[0].entry_count, 1);
1585        }
1586        manager.append_session_info("new name").unwrap();
1587        let rows = SessionManager::list(&cwd, &sessions).unwrap();
1588        assert_eq!(rows[0].name.as_deref(), Some("new name"));
1589        assert_eq!(rows[0].entry_count, 2);
1590        drop(manager);
1591        let mut replacement = SessionManager::in_memory(&cwd);
1592        replacement
1593            .append_message(AgentMessage::user("replacement"))
1594            .unwrap();
1595        let mut temporary = tempfile::NamedTempFile::new_in(path.parent().unwrap()).unwrap();
1596        temporary
1597            .write_all(replacement.to_jsonl().unwrap().as_bytes())
1598            .unwrap();
1599        temporary.persist(&path).unwrap();
1600        let rows = SessionManager::list(&cwd, &sessions).unwrap();
1601        assert_eq!(rows[0].first_message.as_deref(), Some("replacement"));
1602        assert_eq!(rows[0].name, None);
1603        assert_eq!(rows[0].entry_count, 1);
1604        std::fs::remove_file(path).unwrap();
1605        assert!(SessionManager::list(&cwd, &sessions).unwrap().is_empty());
1606    }
1607
1608    #[test]
1609    fn open_repairs_missing_final_newline_before_append() {
1610        let dir = tempfile::tempdir().unwrap();
1611        let cwd = dir.path().join("project");
1612        std::fs::create_dir_all(&cwd).unwrap();
1613        let mut source = SessionManager::create(&cwd, Some(dir.path().join("sessions"))).unwrap();
1614        source
1615            .append_message(AgentMessage::user("before repair"))
1616            .unwrap();
1617        let path = source.session_file().unwrap().to_path_buf();
1618        drop(source);
1619
1620        let mut text = std::fs::read_to_string(&path).unwrap();
1621        assert_eq!(text.pop(), Some('\n'));
1622        std::fs::write(&path, text).unwrap();
1623
1624        let mut reopened = SessionManager::open(&path).unwrap();
1625        reopened
1626            .append_message(AgentMessage::user("after repair"))
1627            .unwrap();
1628        drop(reopened);
1629
1630        let text = std::fs::read_to_string(&path).unwrap();
1631        assert!(text.ends_with('\n'));
1632        assert_eq!(text.lines().count(), 3);
1633        for line in text.lines() {
1634            let _: Value = serde_json::from_str(line).unwrap();
1635        }
1636        assert_eq!(SessionManager::open(&path).unwrap().entries().len(), 2);
1637    }
1638
1639    #[test]
1640    fn sibling_of_in_memory_session_stays_in_memory() {
1641        let source = manager();
1642        let sibling = source.create_sibling().unwrap();
1643        assert!(!sibling.is_persisted());
1644    }
1645
1646    #[test]
1647    #[ignore = "release-mode performance benchmark"]
1648    fn benchmark_performance_session_open_many_records() {
1649        let dir = tempfile::tempdir().unwrap();
1650        let path = dir.path().join("many-records.jsonl");
1651        let mut manager = SessionManager::in_memory(dir.path());
1652        manager
1653            .append_message(AgentMessage::user("x".repeat(10 * 1024)))
1654            .unwrap();
1655        let fixture = manager.to_jsonl().unwrap();
1656        let mut lines = fixture.lines();
1657        let header = lines.next().unwrap();
1658        let mut entry: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
1659        let mut file = std::io::BufWriter::new(File::create(&path).unwrap());
1660        writeln!(file, "{header}").unwrap();
1661        for index in 0..1024 {
1662            entry["id"] = serde_json::json!(format!("entry-{index}"));
1663            serde_json::to_writer(&mut file, &entry).unwrap();
1664            writeln!(file).unwrap();
1665        }
1666        file.flush().unwrap();
1667        kiss_bench::measure(
1668            "session_open_1024_records",
1669            11,
1670            1,
1671            "10mib_1024_records",
1672            || {
1673                let manager = SessionManager::open(&path).unwrap();
1674                assert_eq!(manager.entries().len(), 1024);
1675                manager.entries().len()
1676            },
1677        );
1678    }
1679
1680    #[test]
1681    #[ignore = "release-mode performance benchmark"]
1682    fn benchmark_performance_session_open_and_list() {
1683        let dir = tempfile::tempdir().unwrap();
1684        let cwd = dir.path().join("project");
1685        let sessions = dir.path().join("sessions");
1686        let mut manager = SessionManager::create(&cwd, Some(sessions.clone())).unwrap();
1687        let text = "x".repeat(10 * 1024 * 1024);
1688        manager.append_message(AgentMessage::user(text)).unwrap();
1689        let path = manager.session_file().unwrap().to_path_buf();
1690        drop(manager);
1691        kiss_bench::measure("session_open_10mib", 11, 1, "10mib_user_message", || {
1692            SessionManager::open(&path).unwrap().entries().len()
1693        });
1694        for index in 0..1000 {
1695            let mut manager = SessionManager::create(&cwd, Some(sessions.clone())).unwrap();
1696            manager
1697                .append_message(AgentMessage::user(format!("session {index}")))
1698                .unwrap();
1699            manager
1700                .append_session_info(&format!("name {index}"))
1701                .unwrap();
1702        }
1703        kiss_bench::measure(
1704            "session_list_1001",
1705            11,
1706            1,
1707            "1000_small_and_one_10mib",
1708            || SessionManager::list(&cwd, &sessions).unwrap().len(),
1709        );
1710        kiss_bench::measure_pair(
1711            ("session_list_uncached_1001", "session_list_cached_1001"),
1712            11,
1713            1,
1714            ("same_1001_files", "same_1001_files"),
1715            || {
1716                let mut cache = LISTING_CACHE.get().unwrap().lock().unwrap();
1717                cache.rows.clear();
1718                cache.order.clear();
1719                drop(cache);
1720                SessionManager::list(&cwd, &sessions).unwrap().len()
1721            },
1722            || SessionManager::list(&cwd, &sessions).unwrap().len(),
1723        );
1724    }
1725
1726    #[test]
1727    #[ignore = "release-mode performance benchmark"]
1728    fn benchmark_performance_session_append() {
1729        let dir = tempfile::tempdir().unwrap();
1730        let cwd = dir.path().join("project");
1731        std::fs::create_dir_all(&cwd).unwrap();
1732        let session_dir = dir.path().join("sessions");
1733        kiss_bench::measure(
1734            "session_append_500",
1735            11,
1736            1,
1737            "500_persisted_user_messages",
1738            || {
1739                let mut manager = SessionManager::create(&cwd, Some(session_dir.clone())).unwrap();
1740                for index in 0..500 {
1741                    manager
1742                        .append_message(AgentMessage::user(format!(
1743                            "message {index}: deterministic session append benchmark"
1744                        )))
1745                        .unwrap();
1746                }
1747                manager.entries().len()
1748            },
1749        );
1750    }
1751}