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