1use 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 save_error: Option<String>,
65 save_error_reported: bool,
67 session_dir: PathBuf,
68 cwd: PathBuf,
69}
70
71pub fn cwd_slug(cwd: &Path) -> String {
73 let s = cwd.display().to_string().replace(['/', '\\', ':'], "-");
74 format!("-{s}-")
75}
76
77pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 pub fn context_revision(&self) -> u64 {
772 self.context_revision
773 }
774
775 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 pub fn reset_leaf(&mut self) {
787 self.leaf_id = None;
788 self.context_revision = self.context_revision.wrapping_add(1);
789 }
790
791 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 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 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 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 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 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 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 pub fn session_settings(&self) -> (Option<(String, String)>, Option<ThinkingLevel>) {
962 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 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 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 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 messages.append(&mut pending);
1102 Some(OpenAICompactionContext {
1103 replacement_history,
1104 messages,
1105 })
1106 }
1107
1108 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 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 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 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 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 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 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}