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