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