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