1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use imp_llm::{truncate_chars_with_suffix, AssistantMessage, Message, Model, ToolResultMessage};
5use serde::{Deserialize, Serialize};
6
7use crate::agent::AgentEvent;
8use crate::error::Result;
9use crate::usage::{
10 canonical_usage_record_for_assistant_turn_with_model_meta, usage_record_entry,
11 usage_records_from_session, SessionUsageRecord, UsageRecordV1, USAGE_CUSTOM_TYPE,
12};
13
14pub const CHECKPOINT_CUSTOM_TYPE: &str = "checkpoint-record";
15pub const CHECKPOINT_RECORD_VERSION: u32 = 1;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct SessionCheckpointRecord {
19 pub version: u32,
20 pub checkpoint_id: String,
21 pub created_at: u64,
22 pub label: Option<String>,
23 pub files: Vec<String>,
24}
25
26const SESSION_META_VERSION: u32 = 1;
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
30#[serde(tag = "type")]
31pub enum SessionEntry {
32 #[serde(rename = "header")]
33 Header {
34 version: u32,
35 created_at: u64,
36 cwd: String,
37 },
38 #[serde(rename = "message")]
39 Message {
40 id: String,
41 parent_id: Option<String>,
42 message: Message,
43 },
44 #[serde(rename = "compaction")]
45 Compaction {
46 id: String,
47 parent_id: Option<String>,
48 summary: String,
49 first_kept_id: String,
50 #[serde(default)]
51 tokens_before: u32,
52 #[serde(default)]
53 tokens_after: u32,
54 },
55 #[serde(rename = "custom")]
56 Custom {
57 id: String,
58 parent_id: Option<String>,
59 custom_type: String,
60 data: serde_json::Value,
61 },
62 #[serde(rename = "label")]
63 Label { entry_id: String, label: String },
64 #[serde(rename = "session-meta")]
65 SessionMeta {
66 version: u32,
67 name: Option<String>,
68 summary: Option<String>,
69 },
70}
71
72impl SessionEntry {
73 pub fn id(&self) -> Option<&str> {
75 match self {
76 SessionEntry::Header { .. }
77 | SessionEntry::Label { .. }
78 | SessionEntry::SessionMeta { .. } => None,
79 SessionEntry::Message { id, .. }
80 | SessionEntry::Compaction { id, .. }
81 | SessionEntry::Custom { id, .. } => Some(id),
82 }
83 }
84
85 pub fn parent_id(&self) -> Option<&str> {
87 match self {
88 SessionEntry::Header { .. }
89 | SessionEntry::Label { .. }
90 | SessionEntry::SessionMeta { .. } => None,
91 SessionEntry::Message { parent_id, .. }
92 | SessionEntry::Compaction { parent_id, .. }
93 | SessionEntry::Custom { parent_id, .. } => parent_id.as_deref(),
94 }
95 }
96}
97
98#[derive(Debug, Clone)]
100pub struct TreeNode {
101 pub entry: SessionEntry,
102 pub children: Vec<TreeNode>,
103}
104
105#[derive(Debug, Clone)]
107pub struct SessionInfo {
108 pub id: String,
109 pub path: PathBuf,
110 pub cwd: String,
111 pub created_at: u64,
112 pub updated_at: u64,
113 pub message_count: usize,
114 pub first_message: Option<String>,
115 pub name: Option<String>,
116 pub summary: Option<String>,
117}
118
119pub fn checkpoint_record_entry(
120 entry_id: impl Into<String>,
121 record: SessionCheckpointRecord,
122) -> Result<SessionEntry> {
123 Ok(SessionEntry::Custom {
124 id: entry_id.into(),
125 parent_id: None,
126 custom_type: CHECKPOINT_CUSTOM_TYPE.to_string(),
127 data: serde_json::to_value(record)?,
128 })
129}
130
131impl SessionInfo {
132 pub fn title(&self, max_chars: usize) -> Option<String> {
134 if let Some(name) = self
135 .name
136 .as_deref()
137 .filter(|name| !name.trim().is_empty())
138 .map(|name| truncate_chars_with_suffix(name.trim(), max_chars, "…"))
139 {
140 return Some(name);
141 }
142
143 preferred_title_candidate(
144 self.first_message.as_deref(),
145 self.summary.as_deref(),
146 max_chars,
147 )
148 }
149}
150
151pub struct SessionManager {
159 entries: Vec<SessionEntry>,
160 path: Option<PathBuf>,
161 leaf_id: Option<String>,
162 session_name: Option<String>,
163 session_summary: Option<String>,
164}
165
166impl SessionManager {
167 pub fn new(cwd: &Path, session_dir: &Path) -> Result<Self> {
169 let session_id = uuid::Uuid::new_v4().to_string();
170 let path = session_dir.join(format!("{session_id}.jsonl"));
171 let header = SessionEntry::Header {
172 version: 1,
173 created_at: imp_llm::now(),
174 cwd: cwd.to_string_lossy().to_string(),
175 };
176
177 {
179 use std::io::Write;
180 if let Some(parent) = path.parent() {
181 std::fs::create_dir_all(parent)?;
182 }
183 let mut file = std::fs::File::create(&path)?;
184 let line = serde_json::to_string(&header)?;
185 writeln!(file, "{line}")?;
186 }
187
188 Ok(Self {
189 entries: vec![header],
190 path: Some(path),
191 leaf_id: None,
192 session_name: None,
193 session_summary: None,
194 })
195 }
196
197 pub fn open(path: &Path) -> Result<Self> {
199 let content = std::fs::read_to_string(path)?;
200 let mut entries = Vec::new();
201 let mut last_id = None;
202
203 let mut session_name = None;
204 let mut session_summary = None;
205
206 for (_line_num, line) in content.lines().enumerate() {
207 if line.trim().is_empty() {
208 continue;
209 }
210 match serde_json::from_str::<SessionEntry>(line) {
211 Ok(entry) => {
212 if let Some(id) = entry.id() {
213 last_id = Some(id.to_string());
214 }
215 if let SessionEntry::SessionMeta { name, summary, .. } = &entry {
216 session_name = name.clone();
217 session_summary = summary.clone();
218 }
219 entries.push(entry);
220 }
221 Err(_e) => {
222 }
225 }
226 }
227
228 Ok(Self {
229 entries,
230 path: Some(path.to_path_buf()),
231 leaf_id: last_id,
232 session_name,
233 session_summary,
234 })
235 }
236
237 pub fn in_memory() -> Self {
239 Self {
240 entries: Vec::new(),
241 path: None,
242 leaf_id: None,
243 session_name: None,
244 session_summary: None,
245 }
246 }
247
248 pub fn continue_recent(cwd: &Path, session_dir: &Path) -> Result<Option<Self>> {
250 if !session_dir.exists() {
251 return Ok(None);
252 }
253
254 let cwd_str = cwd.to_string_lossy().to_string();
255 let mut best: Option<(std::time::SystemTime, PathBuf)> = None;
256
257 for dir_entry in std::fs::read_dir(session_dir)? {
258 let dir_entry = dir_entry?;
259 let path = dir_entry.path();
260 if path.extension().is_none_or(|e| e != "jsonl") {
261 continue;
262 }
263 let modified = dir_entry
265 .metadata()?
266 .modified()
267 .unwrap_or(std::time::UNIX_EPOCH);
268
269 if best.as_ref().is_none_or(|(t, _)| modified > *t) {
271 if let Ok(first_line) = read_first_line(&path) {
273 if let Ok(SessionEntry::Header { cwd, .. }) =
274 serde_json::from_str::<SessionEntry>(&first_line).as_ref()
275 {
276 if *cwd == cwd_str {
277 best = Some((modified, path));
278 }
279 }
280 }
281 }
282 }
283
284 match best {
285 Some((_, path)) => Ok(Some(Self::open(&path)?)),
286 None => Ok(None),
287 }
288 }
289
290 pub fn name(&self) -> Option<&str> {
292 self.session_name.as_deref()
293 }
294
295 pub fn summary(&self) -> Option<&str> {
297 self.session_summary.as_deref()
298 }
299
300 pub fn set_name(&mut self, name: &str) {
302 self.session_name = Some(name.to_string());
303 let _ = self.persist_session_meta();
304 }
305
306 pub fn set_summary(&mut self, summary: impl Into<String>) {
308 let summary = summary.into();
309 self.session_summary = Some(summary);
310 let _ = self.persist_session_meta();
311 }
312
313 pub fn clear_summary(&mut self) {
315 self.session_summary = None;
316 let _ = self.persist_session_meta();
317 }
318
319 pub fn title(&self, max_chars: usize) -> Option<String> {
321 if let Some(name) = self
322 .name()
323 .filter(|name| !name.trim().is_empty())
324 .map(|name| truncate_chars_with_suffix(name.trim(), max_chars, "…"))
325 {
326 return Some(name);
327 }
328
329 let first_prompt = self.entries.iter().find_map(|entry| match entry {
330 SessionEntry::Message { message, .. } => extract_text(message),
331 _ => None,
332 });
333 let summary = self
334 .summary()
335 .filter(|summary| !summary.trim().is_empty())
336 .map(str::to_string)
337 .or_else(|| derive_session_summary(&self.entries));
338
339 preferred_title_candidate(first_prompt.as_deref(), summary.as_deref(), max_chars)
340 }
341
342 fn persist_session_meta(&mut self) -> Result<()> {
343 self.append(SessionEntry::SessionMeta {
344 version: SESSION_META_VERSION,
345 name: self.session_name.clone(),
346 summary: self.session_summary.clone(),
347 })
348 }
349
350 fn refresh_derived_summary(&mut self) {
351 let derived = derive_session_summary(&self.entries);
352 if derived != self.session_summary {
353 self.session_summary = derived;
354 let _ = self.persist_session_meta();
355 }
356 }
357
358 pub fn append(&mut self, mut entry: SessionEntry) -> Result<()> {
361 match &mut entry {
363 SessionEntry::Message { parent_id, .. }
364 | SessionEntry::Compaction { parent_id, .. }
365 | SessionEntry::Custom { parent_id, .. } => {
366 *parent_id = self.leaf_id.clone();
367 }
368 SessionEntry::Header { .. }
369 | SessionEntry::Label { .. }
370 | SessionEntry::SessionMeta { .. } => {}
371 }
372
373 if let Some(id) = entry.id() {
375 self.leaf_id = Some(id.to_string());
376 }
377
378 if let Some(ref path) = self.path {
380 use std::io::Write;
381 if let Some(parent) = path.parent() {
382 std::fs::create_dir_all(parent)?;
383 }
384 let mut file = std::fs::OpenOptions::new()
385 .create(true)
386 .append(true)
387 .open(path)?;
388 let line = serde_json::to_string(&entry)?;
389 writeln!(file, "{line}")?;
390 }
391
392 self.entries.push(entry);
393 Ok(())
394 }
395
396 pub fn append_assistant_turn(
398 &mut self,
399 model: &Model,
400 turn_index: u32,
401 message: AssistantMessage,
402 ) -> Result<(String, Option<String>)> {
403 self.append_assistant_turn_with_model_meta(&model.meta, turn_index, message)
404 }
405
406 pub fn append_assistant_turn_with_model_meta(
408 &mut self,
409 model_meta: &imp_llm::model::ModelMeta,
410 turn_index: u32,
411 message: AssistantMessage,
412 ) -> Result<(String, Option<String>)> {
413 let assistant_message_id = uuid::Uuid::new_v4().to_string();
414 self.append(SessionEntry::Message {
415 id: assistant_message_id.clone(),
416 parent_id: None,
417 message: Message::Assistant(message.clone()),
418 })?;
419
420 let usage_entry_id = self.append_canonical_usage_for_assistant_turn_with_model_meta(
421 model_meta,
422 &assistant_message_id,
423 turn_index,
424 &message,
425 )?;
426
427 self.refresh_derived_summary();
428
429 Ok((assistant_message_id, usage_entry_id))
430 }
431
432 pub fn append_tool_result_message(&mut self, result: ToolResultMessage) -> Result<String> {
434 let entry_id = uuid::Uuid::new_v4().to_string();
435 self.append(SessionEntry::Message {
436 id: entry_id.clone(),
437 parent_id: None,
438 message: Message::ToolResult(result),
439 })?;
440 Ok(entry_id)
441 }
442
443 pub fn persist_agent_event_entries(
448 &mut self,
449 model: &Model,
450 event: &AgentEvent,
451 ) -> Result<Vec<&'static str>> {
452 self.persist_agent_event_entries_with_model_meta(&model.meta, event)
453 }
454
455 pub fn persist_agent_event_entries_with_model_meta(
460 &mut self,
461 model_meta: &imp_llm::model::ModelMeta,
462 event: &AgentEvent,
463 ) -> Result<Vec<&'static str>> {
464 let mut persisted = Vec::new();
465
466 match event {
467 AgentEvent::ToolExecutionEnd { result, .. } => {
468 self.append_tool_result_message(result.clone())?;
469 persisted.push("tool result");
470 }
471 AgentEvent::TurnEnd { index, message, .. } => {
472 let (_assistant_id, usage_entry_id) = self.append_assistant_turn_with_model_meta(
473 model_meta,
474 *index,
475 message.clone(),
476 )?;
477 persisted.push("assistant message");
478 if usage_entry_id.is_some() {
479 persisted.push("canonical usage");
480 }
481 }
482 _ => {}
483 }
484
485 Ok(persisted)
486 }
487
488 pub fn append_canonical_usage_for_assistant_turn(
494 &mut self,
495 model: &Model,
496 assistant_message_id: &str,
497 turn_index: u32,
498 message: &AssistantMessage,
499 ) -> Result<Option<String>> {
500 self.append_canonical_usage_for_assistant_turn_with_model_meta(
501 &model.meta,
502 assistant_message_id,
503 turn_index,
504 message,
505 )
506 }
507
508 pub fn append_canonical_usage_for_assistant_turn_with_model_meta(
514 &mut self,
515 model_meta: &imp_llm::model::ModelMeta,
516 assistant_message_id: &str,
517 turn_index: u32,
518 message: &AssistantMessage,
519 ) -> Result<Option<String>> {
520 let Some(record) = canonical_usage_record_for_assistant_turn_with_model_meta(
521 self,
522 model_meta,
523 assistant_message_id,
524 turn_index,
525 message,
526 ) else {
527 return Ok(None);
528 };
529
530 let entry_id = uuid::Uuid::new_v4().to_string();
531 let entry = usage_record_entry(entry_id.clone(), record)?;
532 self.append(entry)?;
533 Ok(Some(entry_id))
534 }
535
536 pub fn usage_records(&self) -> Vec<SessionUsageRecord> {
538 usage_records_from_session(self)
539 }
540
541 pub fn append_checkpoint_record(&mut self, record: SessionCheckpointRecord) -> Result<String> {
542 let entry_id = uuid::Uuid::new_v4().to_string();
543 let entry = checkpoint_record_entry(entry_id.clone(), record)?;
544 self.append(entry)?;
545 Ok(entry_id)
546 }
547
548 pub fn checkpoint_records(&self) -> Vec<SessionCheckpointRecord> {
549 self.entries
550 .iter()
551 .filter_map(|entry| {
552 let SessionEntry::Custom {
553 custom_type, data, ..
554 } = entry
555 else {
556 return None;
557 };
558
559 if custom_type != CHECKPOINT_CUSTOM_TYPE {
560 return None;
561 }
562
563 serde_json::from_value::<SessionCheckpointRecord>(data.clone()).ok()
564 })
565 .collect()
566 }
567
568 pub fn find_checkpoint_record(&self, needle: &str) -> Option<SessionCheckpointRecord> {
569 self.checkpoint_records().into_iter().find(|record| {
570 record.checkpoint_id == needle || record.label.as_deref() == Some(needle)
571 })
572 }
573
574 pub fn restore_checkpoint(
575 &self,
576 checkpoint_state: &crate::tools::CheckpointState,
577 needle: &str,
578 ) -> Result<Vec<PathBuf>> {
579 let Some(record) = self.find_checkpoint_record(needle) else {
580 return Ok(Vec::new());
581 };
582 checkpoint_state
583 .restore_checkpoint(&record.checkpoint_id)
584 .map_err(Into::into)
585 }
586
587 pub fn has_canonical_usage_request_id(&self, request_id: &str) -> bool {
589 self.entries.iter().any(|entry| {
590 let SessionEntry::Custom {
591 custom_type, data, ..
592 } = entry
593 else {
594 return false;
595 };
596
597 if custom_type != USAGE_CUSTOM_TYPE {
598 return false;
599 }
600
601 UsageRecordV1::from_custom_data(data.clone())
602 .map(|record| record.request_id == request_id)
603 .unwrap_or(false)
604 })
605 }
606
607 pub fn has_canonical_usage_for_assistant_message(&self, assistant_message_id: &str) -> bool {
609 self.entries.iter().any(|entry| {
610 let SessionEntry::Custom {
611 custom_type, data, ..
612 } = entry
613 else {
614 return false;
615 };
616
617 if custom_type != USAGE_CUSTOM_TYPE {
618 return false;
619 }
620
621 UsageRecordV1::from_custom_data(data.clone())
622 .ok()
623 .and_then(|record| record.assistant_message_id)
624 .as_deref()
625 == Some(assistant_message_id)
626 })
627 }
628
629 pub fn get_branch(&self) -> Vec<&SessionEntry> {
636 let Some(ref leaf) = self.leaf_id else {
637 return self
639 .entries
640 .iter()
641 .filter(|e| matches!(e, SessionEntry::Header { .. }))
642 .collect();
643 };
644
645 let id_map: HashMap<&str, usize> = self
647 .entries
648 .iter()
649 .enumerate()
650 .filter_map(|(i, e)| e.id().map(|id| (id, i)))
651 .collect();
652
653 let mut branch = Vec::new();
655 let mut current = Some(leaf.as_str());
656
657 while let Some(id) = current {
658 if let Some(&idx) = id_map.get(id) {
659 let entry = &self.entries[idx];
660 branch.push(entry);
661 current = entry.parent_id();
662 } else {
663 break;
664 }
665 }
666
667 for entry in &self.entries {
669 if matches!(entry, SessionEntry::Header { .. }) {
670 branch.push(entry);
671 break;
672 }
673 }
674
675 branch.reverse();
676 branch
677 }
678
679 pub fn get_messages(&self) -> Vec<&Message> {
685 self.get_branch()
686 .into_iter()
687 .filter_map(|e| match e {
688 SessionEntry::Message { message, .. } => Some(message),
689 _ => None,
690 })
691 .collect()
692 }
693
694 pub fn latest_compaction(&self) -> Option<&SessionEntry> {
696 self.get_branch()
697 .into_iter()
698 .rev()
699 .find(|entry| matches!(entry, SessionEntry::Compaction { .. }))
700 }
701
702 pub fn get_active_messages(&self) -> Vec<Message> {
715 let branch = self.get_branch();
716 let latest_compaction = branch.iter().enumerate().rev().find_map(|(idx, entry)| {
717 let SessionEntry::Compaction {
718 summary,
719 first_kept_id,
720 ..
721 } = entry
722 else {
723 return None;
724 };
725 Some((idx, summary.as_str(), first_kept_id.as_str()))
726 });
727
728 let Some((_compaction_idx, summary, first_kept_id)) = latest_compaction else {
729 return branch
730 .into_iter()
731 .filter_map(|entry| match entry {
732 SessionEntry::Message { message, .. } => Some(message.clone()),
733 _ => None,
734 })
735 .collect();
736 };
737
738 let mut active = Vec::new();
739 let summary_text = summary.trim();
740 if !summary_text.is_empty() {
741 active.push(Message::user(summary_text.to_string()));
742 }
743
744 let mut keep = false;
745 for entry in branch {
746 if entry.id() == Some(first_kept_id) {
747 keep = true;
748 }
749 if !keep {
750 continue;
751 }
752 if let SessionEntry::Message { message, .. } = entry {
753 active.push(message.clone());
754 }
755 }
756
757 active
758 }
759
760 pub fn active_message_count(&self) -> usize {
765 self.get_active_messages().len()
766 }
767
768 pub fn get_tree(&self) -> Vec<TreeNode> {
770 let mut children_map: HashMap<&str, Vec<usize>> = HashMap::new();
772 let mut roots: Vec<usize> = Vec::new();
773
774 for (i, entry) in self.entries.iter().enumerate() {
775 match entry.parent_id() {
776 Some(pid) => {
777 children_map.entry(pid).or_default().push(i);
778 }
779 None => {
780 roots.push(i);
781 }
782 }
783 }
784
785 roots
786 .into_iter()
787 .map(|i| self.build_subtree(i, &children_map))
788 .collect()
789 }
790
791 fn build_subtree(&self, idx: usize, children_map: &HashMap<&str, Vec<usize>>) -> TreeNode {
792 let entry = &self.entries[idx];
793 let children = entry
794 .id()
795 .and_then(|id| children_map.get(id))
796 .map(|child_indices| {
797 child_indices
798 .iter()
799 .map(|&ci| self.build_subtree(ci, children_map))
800 .collect()
801 })
802 .unwrap_or_default();
803
804 TreeNode {
805 entry: entry.clone(),
806 children,
807 }
808 }
809
810 pub fn navigate(&mut self, target_id: &str) -> Result<()> {
812 let exists = self.entries.iter().any(|e| e.id() == Some(target_id));
813 if !exists {
814 return Err(crate::error::Error::Session(format!(
815 "entry not found: {target_id}"
816 )));
817 }
818 self.leaf_id = Some(target_id.to_string());
819 Ok(())
820 }
821
822 pub fn fork(&self, entry_id: &str, new_path: &Path) -> Result<SessionManager> {
825 let id_map: HashMap<&str, usize> = self
827 .entries
828 .iter()
829 .enumerate()
830 .filter_map(|(i, e)| e.id().map(|id| (id, i)))
831 .collect();
832
833 let mut branch_indices = Vec::new();
834 let mut current = Some(entry_id);
835
836 while let Some(id) = current {
837 if let Some(&idx) = id_map.get(id) {
838 branch_indices.push(idx);
839 current = self.entries[idx].parent_id();
840 } else {
841 break;
842 }
843 }
844
845 branch_indices.reverse();
846
847 let mut forked_entries = Vec::new();
849 for entry in &self.entries {
850 if matches!(entry, SessionEntry::Header { .. }) {
851 forked_entries.push(entry.clone());
852 break;
853 }
854 }
855 for idx in &branch_indices {
856 forked_entries.push(self.entries[*idx].clone());
857 }
858
859 let branch_ids: std::collections::HashSet<String> = forked_entries
861 .iter()
862 .filter_map(|e| e.id().map(String::from))
863 .collect();
864 let labels: Vec<SessionEntry> = self
865 .entries
866 .iter()
867 .filter(|e| {
868 matches!(e, SessionEntry::Label { entry_id, .. } if branch_ids.contains(entry_id.as_str()))
869 })
870 .cloned()
871 .collect();
872 forked_entries.extend(labels);
873
874 let meta_entries: Vec<SessionEntry> = self
876 .entries
877 .iter()
878 .filter(|e| matches!(e, SessionEntry::SessionMeta { .. }))
879 .cloned()
880 .collect();
881 forked_entries.extend(meta_entries);
882
883 if let Some(parent) = new_path.parent() {
885 std::fs::create_dir_all(parent)?;
886 }
887
888 {
889 use std::io::Write;
890 let mut file = std::fs::File::create(new_path)?;
891 for entry in &forked_entries {
892 let line = serde_json::to_string(entry)?;
893 writeln!(file, "{line}")?;
894 }
895 }
896
897 let leaf_id = forked_entries
898 .iter()
899 .rev()
900 .find_map(|e| e.id())
901 .map(String::from);
902
903 Ok(SessionManager {
904 entries: forked_entries,
905 path: Some(new_path.to_path_buf()),
906 leaf_id,
907 session_name: self.session_name.clone(),
908 session_summary: self.session_summary.clone(),
909 })
910 }
911
912 pub fn entries(&self) -> &[SessionEntry] {
914 &self.entries
915 }
916
917 pub fn path(&self) -> Option<&Path> {
919 self.path.as_deref()
920 }
921
922 pub fn leaf_id(&self) -> Option<&str> {
924 self.leaf_id.as_deref()
925 }
926
927 pub fn session_id(&self) -> Option<String> {
929 self.path
930 .as_ref()
931 .and_then(|path| path.file_stem())
932 .map(|stem| stem.to_string_lossy().to_string())
933 }
934
935 pub fn list(session_dir: &Path) -> Result<Vec<SessionInfo>> {
937 let mut sessions = Vec::new();
938 if !session_dir.exists() {
939 return Ok(sessions);
940 }
941
942 for dir_entry in std::fs::read_dir(session_dir)? {
943 let dir_entry = dir_entry?;
944 let path = dir_entry.path();
945 if path.extension().is_none_or(|e| e != "jsonl") {
946 continue;
947 }
948
949 let updated_at = dir_entry
950 .metadata()
951 .ok()
952 .and_then(|m| m.modified().ok())
953 .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
954 .map(|d| d.as_secs())
955 .unwrap_or(0);
956
957 if let Ok(session) = Self::open(&path) {
958 let cwd = session
959 .entries
960 .iter()
961 .find_map(|e| match e {
962 SessionEntry::Header { cwd, .. } => Some(cwd.clone()),
963 _ => None,
964 })
965 .unwrap_or_default();
966
967 let created_at = session
968 .entries
969 .iter()
970 .find_map(|e| match e {
971 SessionEntry::Header { created_at, .. } => Some(*created_at),
972 _ => None,
973 })
974 .unwrap_or(0);
975
976 let message_count = session
977 .entries
978 .iter()
979 .filter(|e| matches!(e, SessionEntry::Message { .. }))
980 .count();
981
982 let first_message = session.entries.iter().find_map(|e| match e {
983 SessionEntry::Message { message, .. } => extract_text(message),
984 _ => None,
985 });
986
987 if message_count == 0 {
989 continue;
990 }
991
992 let name = session.name().map(str::to_string);
993 let summary = session
994 .summary()
995 .map(str::to_string)
996 .or_else(|| derive_session_summary(&session.entries));
997
998 sessions.push(SessionInfo {
999 id: path
1000 .file_stem()
1001 .map(|s| s.to_string_lossy().to_string())
1002 .unwrap_or_default(),
1003 path,
1004 cwd,
1005 created_at,
1006 updated_at,
1007 message_count,
1008 first_message,
1009 name,
1010 summary,
1011 });
1012 }
1013 }
1014
1015 sessions.sort_by(|a, b| {
1016 b.updated_at
1017 .cmp(&a.updated_at)
1018 .then_with(|| b.created_at.cmp(&a.created_at))
1019 });
1020 Ok(sessions)
1021 }
1022}
1023
1024pub fn sanitize_messages(messages: &mut Vec<Message>) {
1031 use std::collections::HashSet;
1032
1033 let result_ids: HashSet<String> = messages
1035 .iter()
1036 .filter_map(|m| match m {
1037 Message::ToolResult(tr) => Some(tr.tool_call_id.clone()),
1038 _ => None,
1039 })
1040 .collect();
1041
1042 for msg in messages.iter_mut() {
1044 if let Message::Assistant(assistant) = msg {
1045 assistant.content.retain(|block| match block {
1046 imp_llm::ContentBlock::ToolCall { id, .. } => result_ids.contains(id),
1047 _ => true,
1048 });
1049 }
1050 }
1051
1052 messages.retain(|msg| match msg {
1054 Message::Assistant(a) => !a.content.is_empty(),
1055 _ => true,
1056 });
1057
1058 let remaining_call_ids: HashSet<String> = messages
1060 .iter()
1061 .filter_map(|m| match m {
1062 Message::Assistant(a) => Some(a.content.iter().filter_map(|b| match b {
1063 imp_llm::ContentBlock::ToolCall { id, .. } => Some(id.clone()),
1064 _ => None,
1065 })),
1066 _ => None,
1067 })
1068 .flatten()
1069 .collect();
1070 messages.retain(|msg| match msg {
1071 Message::ToolResult(tr) => remaining_call_ids.contains(&tr.tool_call_id),
1072 _ => true,
1073 });
1074
1075 reorder_tool_results(messages);
1079}
1080
1081fn reorder_tool_results(messages: &mut Vec<Message>) {
1084 use std::collections::HashMap;
1085
1086 let mut call_to_assistant: HashMap<String, usize> = HashMap::new();
1088 for (i, msg) in messages.iter().enumerate() {
1089 if let Message::Assistant(a) = msg {
1090 for block in &a.content {
1091 if let imp_llm::ContentBlock::ToolCall { id, .. } = block {
1092 call_to_assistant.insert(id.clone(), i);
1093 }
1094 }
1095 }
1096 }
1097
1098 let mut deferred: Vec<(usize, Message)> = Vec::new(); let mut i = 0;
1101 while i < messages.len() {
1102 if let Message::ToolResult(tr) = &messages[i] {
1103 if let Some(&assistant_idx) = call_to_assistant.get(&tr.tool_call_id) {
1104 if i < assistant_idx {
1105 let msg = messages.remove(i);
1107 deferred.push((assistant_idx, msg));
1108 for v in call_to_assistant.values_mut() {
1110 if *v > i {
1111 *v -= 1;
1112 }
1113 }
1114 for d in &mut deferred {
1115 if d.0 > i {
1116 d.0 -= 1;
1117 }
1118 }
1119 continue; }
1121 }
1122 }
1123 i += 1;
1124 }
1125
1126 deferred.sort_by(|a, b| b.0.cmp(&a.0));
1129 for (target_idx, msg) in deferred {
1130 let insert_at = (target_idx + 1).min(messages.len());
1131 messages.insert(insert_at, msg);
1132 }
1133}
1134
1135fn extract_text(message: &Message) -> Option<String> {
1137 let blocks = match message {
1138 Message::User(u) => &u.content,
1139 Message::Assistant(a) => &a.content,
1140 Message::ToolResult(t) => &t.content,
1141 };
1142 blocks.iter().find_map(|b| match b {
1143 imp_llm::ContentBlock::Text { text } => Some(text.clone()),
1144 _ => None,
1145 })
1146}
1147
1148fn derive_session_summary(entries: &[SessionEntry]) -> Option<String> {
1149 let mut parts = Vec::new();
1150
1151 for entry in entries.iter().rev() {
1152 match entry {
1153 SessionEntry::SessionMeta {
1154 summary: Some(summary),
1155 ..
1156 } if !summary.trim().is_empty() => {
1157 return Some(truncate_chars_with_suffix(summary.trim(), 120, "…"));
1158 }
1159 SessionEntry::Compaction { summary, .. } => {
1161 let trimmed = cleanup_summary_text(summary);
1162 if !trimmed.is_empty() {
1163 parts.push(trimmed);
1164 }
1165 }
1166 SessionEntry::Message { message, .. } => {
1167 if let Message::Assistant(_) = message {
1168 if let Some(text) = extract_text(message) {
1169 let trimmed = cleanup_summary_text(&text);
1170 if !trimmed.is_empty() {
1171 parts.push(trimmed);
1172 }
1173 }
1174 }
1175 }
1176 _ => {}
1177 }
1178
1179 if parts.len() >= 3 {
1180 break;
1181 }
1182 }
1183
1184 if parts.is_empty() {
1185 return None;
1186 }
1187
1188 let joined = parts.into_iter().rev().collect::<Vec<_>>().join(" ");
1189 let collapsed = joined.split_whitespace().collect::<Vec<_>>().join(" ");
1190 if collapsed.is_empty() {
1191 None
1192 } else {
1193 Some(truncate_chars_with_suffix(&collapsed, 120, "…"))
1194 }
1195}
1196
1197fn cleanup_summary_text(text: &str) -> String {
1198 let mut collapsed = text
1199 .split_whitespace()
1200 .collect::<Vec<_>>()
1201 .join(" ")
1202 .trim()
1203 .to_string();
1204
1205 for prefix in [
1206 "summary:",
1207 "session summary:",
1208 "assistant summary:",
1209 "in summary,",
1210 "to summarize,",
1211 ] {
1212 if collapsed.to_ascii_lowercase().starts_with(prefix) {
1213 collapsed = collapsed[prefix.len()..].trim().to_string();
1214 break;
1215 }
1216 }
1217
1218 collapsed
1219}
1220
1221fn preferred_title_candidate(
1222 first_prompt: Option<&str>,
1223 summary: Option<&str>,
1224 max_chars: usize,
1225) -> Option<String> {
1226 let first_prompt = first_prompt
1227 .map(cleanup_summary_text)
1228 .filter(|text| !text.is_empty());
1229 let summary = summary
1230 .map(cleanup_summary_text)
1231 .filter(|text| !text.is_empty());
1232
1233 match (first_prompt.as_deref(), summary.as_deref()) {
1234 (Some(prompt), Some(summary)) => {
1235 let prompt_title = literal_topic_title(prompt, max_chars);
1236 let summary_title = literal_topic_title(summary, max_chars);
1237 choose_better_title(prompt_title, summary_title, max_chars)
1238 }
1239 (Some(prompt), None) => literal_topic_title(prompt, max_chars),
1240 (None, Some(summary)) => literal_topic_title(summary, max_chars),
1241 (None, None) => None,
1242 }
1243}
1244
1245fn choose_better_title(
1246 prompt_title: Option<String>,
1247 summary_title: Option<String>,
1248 max_chars: usize,
1249) -> Option<String> {
1250 match (prompt_title, summary_title) {
1251 (Some(prompt), Some(summary)) => {
1252 if is_generic_title(&prompt) && !is_generic_title(&summary) {
1253 Some(summary)
1254 } else if !is_generic_title(&prompt) && is_generic_title(&summary) {
1255 Some(prompt)
1256 } else if topic_word_count(&summary) > topic_word_count(&prompt) {
1257 Some(summary)
1258 } else {
1259 Some(truncate_chars_with_suffix(&prompt, max_chars, "…"))
1260 }
1261 }
1262 (Some(prompt), None) => Some(prompt),
1263 (None, Some(summary)) => Some(summary),
1264 (None, None) => None,
1265 }
1266}
1267
1268fn topic_word_count(title: &str) -> usize {
1269 title
1270 .split_whitespace()
1271 .filter(|word| word.len() >= 4)
1272 .count()
1273}
1274
1275fn literal_topic_title(text: &str, max_chars: usize) -> Option<String> {
1276 let cleaned = cleanup_summary_text(text);
1277 if cleaned.is_empty() {
1278 return None;
1279 }
1280
1281 let literal = concise_topic_phrase(&cleaned, max_chars);
1282 if !literal.trim().is_empty() && !is_generic_title(&literal) {
1283 return Some(literal);
1284 }
1285
1286 let heuristic = summarize_session_title(&cleaned, max_chars);
1287 if !heuristic.trim().is_empty() && !is_generic_title(&heuristic) {
1288 return Some(heuristic);
1289 }
1290
1291 Some(truncate_chars_with_suffix(cleaned.trim(), max_chars, "…"))
1292}
1293
1294fn is_generic_title(title: &str) -> bool {
1295 let lower = title.trim().to_ascii_lowercase();
1296 if lower.is_empty() {
1297 return true;
1298 }
1299
1300 let generic_words = [
1301 "yes", "yeah", "yep", "ok", "okay", "sure", "think", "some", "pretty", "good", "great",
1302 "nice", "maybe", "just", "really", "thing", "stuff",
1303 ];
1304
1305 let words: Vec<&str> = lower.split_whitespace().collect();
1306 if words.len() <= 2 && words.iter().all(|w| generic_words.contains(w)) {
1307 return true;
1308 }
1309
1310 words.iter().filter(|w| generic_words.contains(w)).count() >= words.len().saturating_sub(1)
1311}
1312
1313fn concise_topic_phrase(text: &str, max_chars: usize) -> String {
1314 let collapsed = text
1315 .split_whitespace()
1316 .collect::<Vec<_>>()
1317 .join(" ")
1318 .trim()
1319 .to_string();
1320
1321 let mut phrase = collapsed
1322 .split_terminator(['.', '!', '?', ';', ':'])
1323 .find_map(|part| {
1324 let trimmed = part.trim();
1325 if trimmed.split_whitespace().count() >= 3 {
1326 Some(trimmed.to_string())
1327 } else {
1328 None
1329 }
1330 })
1331 .unwrap_or(collapsed);
1332
1333 let leading_phrases = [
1334 "we should ",
1335 "let's ",
1336 "i want to ",
1337 "i'd like to ",
1338 "can we ",
1339 "can you ",
1340 "could we ",
1341 "could you ",
1342 "would you ",
1343 "please ",
1344 "help me ",
1345 "yes ",
1346 "yeah ",
1347 "ok ",
1348 "okay ",
1349 "sure ",
1350 "i think ",
1351 "think ",
1352 ];
1353
1354 let lower = phrase.to_ascii_lowercase();
1355 for prefix in leading_phrases {
1356 if let Some(stripped) = lower.strip_prefix(prefix) {
1357 phrase = stripped.trim().to_string();
1358 break;
1359 }
1360 }
1361
1362 let stopwords = [
1363 "a",
1364 "an",
1365 "and",
1366 "are",
1367 "as",
1368 "at",
1369 "be",
1370 "but",
1371 "by",
1372 "for",
1373 "from",
1374 "how",
1375 "i",
1376 "if",
1377 "in",
1378 "into",
1379 "is",
1380 "it",
1381 "its",
1382 "me",
1383 "my",
1384 "of",
1385 "on",
1386 "or",
1387 "please",
1388 "so",
1389 "that",
1390 "the",
1391 "their",
1392 "them",
1393 "there",
1394 "these",
1395 "they",
1396 "this",
1397 "to",
1398 "up",
1399 "we",
1400 "what",
1401 "when",
1402 "where",
1403 "which",
1404 "while",
1405 "with",
1406 "would",
1407 "can",
1408 "could",
1409 "should",
1410 "work",
1411 "working",
1412 "improving",
1413 "improve",
1414 "usability",
1415 "currently",
1416 "displayed",
1417 "shown",
1418 "information",
1419 "some",
1420 "pretty",
1421 "really",
1422 "just",
1423 "think",
1424 "yes",
1425 "yeah",
1426 "okay",
1427 "ok",
1428 "sure",
1429 ];
1430
1431 let normalized = phrase
1432 .replace("/resume", "resume")
1433 .replace("chat summaries", "chat_summaries")
1434 .replace("top bar", "top_bar")
1435 .replace("session picker", "session_picker")
1436 .replace("oauth login", "oauth_login")
1437 .replace("provider refresh", "provider_refresh");
1438
1439 let mut tokens = Vec::new();
1440 for raw in normalized.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
1441 if raw.is_empty() {
1442 continue;
1443 }
1444 let lower = raw.to_ascii_lowercase();
1445 if stopwords.contains(&lower.as_str()) {
1446 continue;
1447 }
1448 if tokens.iter().any(|existing: &String| existing == &lower) {
1449 continue;
1450 }
1451 tokens.push(lower);
1452 }
1453
1454 if tokens.is_empty() {
1455 let words: Vec<&str> = phrase.split_whitespace().collect();
1456 let take = words.len().min(4);
1457 return truncate_chars_with_suffix(&words[..take].join(" "), max_chars, "…");
1458 }
1459
1460 let mut out = tokens
1461 .into_iter()
1462 .take(5)
1463 .map(|token| match token.as_str() {
1464 "chat_summaries" => "chat summaries".to_string(),
1465 "top_bar" => "top bar".to_string(),
1466 "session_picker" => "session picker".to_string(),
1467 "oauth_login" => "oauth login".to_string(),
1468 "provider_refresh" => "provider refresh".to_string(),
1469 _ => token,
1470 })
1471 .collect::<Vec<_>>();
1472
1473 if out.len() > 4 {
1474 out.truncate(4);
1475 }
1476
1477 let mut out = out.join(" ");
1478
1479 out = out.replace("resume chat summaries", "resume + summaries");
1480 truncate_chars_with_suffix(out.trim(), max_chars, "…")
1481}
1482
1483fn summarize_session_title(text: &str, max_chars: usize) -> String {
1484 let collapsed = text
1485 .lines()
1486 .map(str::trim)
1487 .filter(|line| !line.is_empty())
1488 .collect::<Vec<_>>()
1489 .join(" ");
1490 let mut normalized = collapsed.to_ascii_lowercase();
1491
1492 for prefix in [
1493 "can we ",
1494 "could we ",
1495 "can you ",
1496 "could you ",
1497 "would you ",
1498 "please ",
1499 "please can you ",
1500 "please could you ",
1501 "help me ",
1502 "i want to ",
1503 "i'd like to ",
1504 "let's ",
1505 ] {
1506 if let Some(stripped) = normalized.strip_prefix(prefix) {
1507 normalized = stripped.to_string();
1508 break;
1509 }
1510 }
1511
1512 for (phrase, token) in [
1513 ("top bar", "top_bar"),
1514 ("prompt box", "prompt_box"),
1515 ("thinking level", "thinking_level"),
1516 ("model name", "model_name"),
1517 ("session name", "session_name"),
1518 ("chat title", "chat_title"),
1519 ("chat name", "chat_name"),
1520 ("session id", "session_id"),
1521 ("context window", "context_window"),
1522 ] {
1523 normalized = normalized.replace(phrase, token);
1524 }
1525
1526 let mentions_top_bar_layout = normalized.contains("top_bar")
1527 && (normalized.contains("display")
1528 || normalized.contains("displayed")
1529 || normalized.contains("shown")
1530 || normalized.contains("information"));
1531
1532 let verbs = [
1533 "fix",
1534 "adjust",
1535 "update",
1536 "change",
1537 "move",
1538 "rename",
1539 "remove",
1540 "add",
1541 "show",
1542 "hide",
1543 "improve",
1544 "refactor",
1545 "debug",
1546 "investigate",
1547 "implement",
1548 "summarize",
1549 ];
1550
1551 let stopwords = [
1552 "a",
1553 "an",
1554 "and",
1555 "are",
1556 "as",
1557 "at",
1558 "be",
1559 "but",
1560 "by",
1561 "for",
1562 "from",
1563 "get",
1564 "have",
1565 "how",
1566 "i",
1567 "if",
1568 "in",
1569 "instead",
1570 "into",
1571 "is",
1572 "it",
1573 "its",
1574 "me",
1575 "my",
1576 "now",
1577 "of",
1578 "on",
1579 "or",
1580 "please",
1581 "right",
1582 "so",
1583 "string",
1584 "that",
1585 "the",
1586 "their",
1587 "them",
1588 "then",
1589 "there",
1590 "these",
1591 "they",
1592 "this",
1593 "to",
1594 "up",
1595 "we",
1596 "what",
1597 "when",
1598 "where",
1599 "which",
1600 "while",
1601 "with",
1602 "would",
1603 "listed",
1604 "resume",
1605 "prompt",
1606 "first",
1607 "summarized",
1608 "summarize",
1609 "information",
1610 "display",
1611 "displayed",
1612 "shown",
1613 "currently",
1614 ];
1615
1616 let mut verb: Option<String> = None;
1617 let mut nouns: Vec<String> = Vec::new();
1618
1619 for raw in normalized.split(|c: char| !c.is_ascii_alphanumeric() && c != '_') {
1620 if raw.is_empty() {
1621 continue;
1622 }
1623 if verb.is_none() && verbs.contains(&raw) {
1624 verb = Some(raw.to_string());
1625 continue;
1626 }
1627 if stopwords.contains(&raw) {
1628 continue;
1629 }
1630 if nouns.iter().any(|existing| existing == raw) {
1631 continue;
1632 }
1633 nouns.push(raw.to_string());
1634 }
1635
1636 let mut parts = Vec::new();
1637 if let Some(verb) = verb {
1638 parts.push(verb);
1639 }
1640
1641 for noun in nouns {
1642 if parts.len() >= 4 {
1643 break;
1644 }
1645 parts.push(noun.clone());
1646 if noun == "top_bar" && mentions_top_bar_layout && parts.len() < 4 {
1647 parts.push("layout".to_string());
1648 }
1649 }
1650
1651 if parts.is_empty() {
1652 parts.push(collapsed.trim().to_string());
1653 }
1654
1655 let summary = parts
1656 .into_iter()
1657 .map(|part| match part.as_str() {
1658 "top_bar" => "top bar".to_string(),
1659 "prompt_box" => "prompt box".to_string(),
1660 "thinking_level" => "thinking level".to_string(),
1661 "model_name" => "model name".to_string(),
1662 "session_name" => "session name".to_string(),
1663 "chat_title" => "chat title".to_string(),
1664 "chat_name" => "chat name".to_string(),
1665 "session_id" => "session id".to_string(),
1666 "context_window" => "context window".to_string(),
1667 _ => part,
1668 })
1669 .collect::<Vec<_>>()
1670 .join(" ");
1671
1672 truncate_chars_with_suffix(summary.trim(), max_chars, "…")
1673}
1674
1675fn read_first_line(path: &Path) -> Result<String> {
1677 use std::io::BufRead;
1678 let file = std::fs::File::open(path)?;
1679 let reader = std::io::BufReader::new(file);
1680 for line in reader.lines() {
1681 let line = line?;
1682 if !line.trim().is_empty() {
1683 return Ok(line);
1684 }
1685 }
1686 Err(crate::error::Error::Session("empty file".into()))
1687}
1688
1689#[cfg(test)]
1690mod tests {
1691 use super::*;
1692 use async_trait::async_trait;
1693 use futures::stream;
1694 use imp_llm::{
1695 auth::{ApiKey, AuthStore},
1696 model::{Capabilities, ModelMeta, ModelPricing},
1697 provider::{Context, Provider, RequestOptions},
1698 AssistantMessage, ContentBlock, Message, StopReason, StreamEvent,
1699 };
1700 use tempfile::TempDir;
1701
1702 struct NoopProvider {
1703 models: Vec<ModelMeta>,
1704 }
1705
1706 #[async_trait]
1707 impl Provider for NoopProvider {
1708 fn stream(
1709 &self,
1710 _model: &Model,
1711 _context: Context,
1712 _options: RequestOptions,
1713 _api_key: &str,
1714 ) -> std::pin::Pin<Box<dyn futures_core::Stream<Item = imp_llm::Result<StreamEvent>> + Send>>
1715 {
1716 Box::pin(stream::empty())
1717 }
1718
1719 async fn resolve_auth(&self, _auth: &AuthStore) -> imp_llm::Result<ApiKey> {
1720 Ok(String::new())
1721 }
1722
1723 fn id(&self) -> &str {
1724 "noop"
1725 }
1726
1727 fn models(&self) -> &[ModelMeta] {
1728 &self.models
1729 }
1730 }
1731
1732 fn make_msg_entry(id: &str, text: &str) -> SessionEntry {
1733 SessionEntry::Message {
1734 id: id.to_string(),
1735 parent_id: None, message: Message::user(text),
1737 }
1738 }
1739
1740 #[test]
1741 fn summarized_title_compacts_request_into_short_label() {
1742 let title = summarize_session_title(
1743 "can we adjust the information that is displayed in the top bar",
1744 48,
1745 );
1746 assert_eq!(title, "adjust top bar layout");
1747 }
1748
1749 #[test]
1750 fn literal_topic_title_prefers_subject_words_over_compaction() {
1751 let title = literal_topic_title(
1752 "can we work on improving the usability of /resume and the chat summaries?",
1753 64,
1754 )
1755 .unwrap();
1756
1757 assert!(title.contains("resume") || title.contains("summaries"));
1758 assert!(title.split_whitespace().count() <= 5);
1759 }
1760
1761 #[test]
1762 fn generic_summary_title_falls_back_to_more_descriptive_phrase() {
1763 let title = literal_topic_title(
1764 "yes think some pretty significant issues with oauth login persistence and provider refresh",
1765 64,
1766 )
1767 .unwrap();
1768
1769 assert!(title.contains("oauth") || title.contains("login"));
1770 assert!(title.split_whitespace().count() <= 5);
1771 assert_ne!(title, "yes think some pretty");
1772 }
1773
1774 #[test]
1775 fn session_titles_can_be_derived_from_summary_text() {
1776 let info = SessionInfo {
1777 id: "abc".into(),
1778 path: PathBuf::from("/tmp/abc.jsonl"),
1779 cwd: "/tmp/project".into(),
1780 created_at: 0,
1781 updated_at: 0,
1782 message_count: 1,
1783 first_message: Some("help me with oauth login issues".into()),
1784 name: None,
1785 summary: Some(
1786 "Investigated OAuth login failures and refreshed provider auth flow".into(),
1787 ),
1788 };
1789
1790 let title = info.title(48).unwrap();
1791 assert!(!title.is_empty());
1792 assert!(title.contains("oauth") || title.contains("login") || title.contains("provider"));
1793 assert!(title.split_whitespace().count() <= 5);
1794 }
1795
1796 #[test]
1797 fn session_compaction_active_messages_replace_prefix_with_summary() {
1798 let mut mgr = SessionManager::in_memory();
1799
1800 mgr.append(make_msg_entry("u1", "first request")).unwrap();
1801 mgr.append(SessionEntry::Message {
1802 id: "a1".into(),
1803 parent_id: None,
1804 message: Message::Assistant(AssistantMessage {
1805 content: vec![ContentBlock::Text {
1806 text: "initial answer".into(),
1807 }],
1808 usage: None,
1809 stop_reason: StopReason::EndTurn,
1810 timestamp: 1,
1811 }),
1812 })
1813 .unwrap();
1814 mgr.append(make_msg_entry("u2", "latest request")).unwrap();
1815 mgr.append(SessionEntry::Compaction {
1816 id: "c1".into(),
1817 parent_id: None,
1818 summary: "Compaction summary of earlier work".into(),
1819 first_kept_id: "u2".into(),
1820 tokens_before: 100,
1821 tokens_after: 40,
1822 })
1823 .unwrap();
1824 mgr.append(SessionEntry::Message {
1825 id: "a2".into(),
1826 parent_id: None,
1827 message: Message::Assistant(AssistantMessage {
1828 content: vec![ContentBlock::Text {
1829 text: "follow-up answer".into(),
1830 }],
1831 usage: None,
1832 stop_reason: StopReason::EndTurn,
1833 timestamp: 2,
1834 }),
1835 })
1836 .unwrap();
1837
1838 let raw = mgr.get_messages();
1839 assert_eq!(raw.len(), 4);
1840
1841 let active = mgr.get_active_messages();
1842 assert_eq!(active.len(), 3);
1843 match &active[0] {
1844 Message::User(user) => match user.content.as_slice() {
1845 [ContentBlock::Text { text }] => {
1846 assert_eq!(text, "Compaction summary of earlier work")
1847 }
1848 other => panic!("unexpected summary content: {other:?}"),
1849 },
1850 other => panic!("unexpected active message: {other:?}"),
1851 }
1852 match &active[1] {
1853 Message::User(user) => match user.content.as_slice() {
1854 [ContentBlock::Text { text }] => assert_eq!(text, "latest request"),
1855 other => panic!("unexpected kept user content: {other:?}"),
1856 },
1857 other => panic!("unexpected kept message: {other:?}"),
1858 }
1859 }
1860
1861 #[test]
1862 fn session_compaction_active_messages_fall_back_to_raw_when_first_kept_missing() {
1863 let mut mgr = SessionManager::in_memory();
1864 mgr.append(make_msg_entry("u1", "hello")).unwrap();
1865 mgr.append(SessionEntry::Compaction {
1866 id: "c1".into(),
1867 parent_id: None,
1868 summary: "summary only".into(),
1869 first_kept_id: "missing".into(),
1870 tokens_before: 10,
1871 tokens_after: 3,
1872 })
1873 .unwrap();
1874
1875 let active = mgr.get_active_messages();
1876 assert_eq!(active.len(), 1);
1877 match &active[0] {
1878 Message::User(user) => match user.content.as_slice() {
1879 [ContentBlock::Text { text }] => assert_eq!(text, "summary only"),
1880 other => panic!("unexpected summary-only content: {other:?}"),
1881 },
1882 other => panic!("unexpected active message: {other:?}"),
1883 }
1884 }
1885
1886 #[test]
1887 fn session_compaction_fork_preserves_compacted_branch_semantics() {
1888 let tmp = TempDir::new().unwrap();
1889 let fork_path = tmp.path().join("forked.jsonl");
1890
1891 let mut mgr = SessionManager::in_memory();
1892 mgr.append(make_msg_entry("u1", "older")).unwrap();
1893 mgr.append(make_msg_entry("u2", "newer")).unwrap();
1894 mgr.append(SessionEntry::Compaction {
1895 id: "c1".into(),
1896 parent_id: None,
1897 summary: "summary older".into(),
1898 first_kept_id: "u2".into(),
1899 tokens_before: 20,
1900 tokens_after: 8,
1901 })
1902 .unwrap();
1903 mgr.append(SessionEntry::Message {
1904 id: "a2".into(),
1905 parent_id: None,
1906 message: Message::Assistant(AssistantMessage {
1907 content: vec![ContentBlock::Text {
1908 text: "done".into(),
1909 }],
1910 usage: None,
1911 stop_reason: StopReason::EndTurn,
1912 timestamp: 3,
1913 }),
1914 })
1915 .unwrap();
1916
1917 let forked = mgr.fork("a2", &fork_path).unwrap();
1918 let active = forked.get_active_messages();
1919 assert_eq!(active.len(), 3);
1920 match &active[0] {
1921 Message::User(user) => match user.content.as_slice() {
1922 [ContentBlock::Text { text }] => assert_eq!(text, "summary older"),
1923 other => panic!("unexpected summary content: {other:?}"),
1924 },
1925 other => panic!("unexpected active message: {other:?}"),
1926 }
1927 }
1928
1929 #[test]
1930 fn session_create_append_reopen() {
1931 let tmp = TempDir::new().unwrap();
1932 let session_dir = tmp.path().join("sessions");
1933 let cwd = tmp.path().join("project");
1934
1935 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
1936 mgr.append(make_msg_entry("m1", "hello")).unwrap();
1937 mgr.append(make_msg_entry("m2", "world")).unwrap();
1938 mgr.append(make_msg_entry("m3", "!")).unwrap();
1939
1940 let path = mgr.path().unwrap().to_path_buf();
1941 assert!(path.exists());
1942
1943 let reopened = SessionManager::open(&path).unwrap();
1945 let original_msgs = mgr.get_messages();
1946 let reopened_msgs = reopened.get_messages();
1947 assert_eq!(original_msgs.len(), reopened_msgs.len());
1948 assert_eq!(reopened_msgs.len(), 3);
1949
1950 let entries = reopened.entries();
1952 for entry in entries {
1953 if let SessionEntry::Message { id, parent_id, .. } = entry {
1954 match id.as_str() {
1955 "m1" => assert_eq!(*parent_id, None),
1956 "m2" => assert_eq!(parent_id.as_deref(), Some("m1")),
1957 "m3" => assert_eq!(parent_id.as_deref(), Some("m2")),
1958 _ => {}
1959 }
1960 }
1961 }
1962 }
1963
1964 #[test]
1965 fn session_branch() {
1966 let mut mgr = SessionManager::in_memory();
1967 for i in 1..=5 {
1969 mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
1970 .unwrap();
1971 }
1972 assert_eq!(mgr.get_messages().len(), 5);
1973 assert_eq!(mgr.leaf_id(), Some("m5"));
1974
1975 mgr.navigate("m3").unwrap();
1977 assert_eq!(mgr.leaf_id(), Some("m3"));
1978
1979 mgr.append(make_msg_entry("b1", "branch 1")).unwrap();
1981 mgr.append(make_msg_entry("b2", "branch 2")).unwrap();
1982
1983 let branch = mgr.get_branch();
1985 let branch_ids: Vec<Option<&str>> = branch.iter().map(|e| e.id()).collect();
1986 assert_eq!(
1987 branch_ids,
1988 vec![Some("m1"), Some("m2"), Some("m3"), Some("b1"), Some("b2")]
1989 );
1990 assert_eq!(mgr.get_messages().len(), 5);
1991
1992 mgr.navigate("m5").unwrap();
1994 let main_branch = mgr.get_branch();
1995 let main_ids: Vec<Option<&str>> = main_branch.iter().map(|e| e.id()).collect();
1996 assert_eq!(
1997 main_ids,
1998 vec![Some("m1"), Some("m2"), Some("m3"), Some("m4"), Some("m5")]
1999 );
2000 }
2001
2002 #[test]
2003 fn session_fork() {
2004 let tmp = TempDir::new().unwrap();
2005 let session_dir = tmp.path().join("sessions");
2006 let cwd = tmp.path().join("project");
2007
2008 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2009 for i in 1..=5 {
2010 mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
2011 .unwrap();
2012 }
2013
2014 let fork_path = session_dir.join("forked.jsonl");
2015 let forked = mgr.fork("m3", &fork_path).unwrap();
2016
2017 assert_eq!(forked.get_messages().len(), 3);
2019 assert_eq!(forked.leaf_id(), Some("m3"));
2020 assert!(fork_path.exists());
2021
2022 let reopened = SessionManager::open(&fork_path).unwrap();
2024 assert_eq!(reopened.get_messages().len(), 3);
2025 }
2026
2027 #[test]
2028 fn session_list() {
2029 let tmp = TempDir::new().unwrap();
2030 let session_dir = tmp.path().join("sessions");
2031 let cwd = tmp.path().join("project");
2032
2033 let mut s1 = SessionManager::new(&cwd, &session_dir).unwrap();
2035 s1.append(make_msg_entry("a1", "first session")).unwrap();
2036 s1.set_name("First");
2037
2038 let mut s2 = SessionManager::new(&cwd, &session_dir).unwrap();
2039 s2.append(make_msg_entry("b1", "second session")).unwrap();
2040 s2.append(make_msg_entry("b2", "more stuff")).unwrap();
2041 s2.set_summary("Second session summary");
2042
2043 let sessions = SessionManager::list(&session_dir).unwrap();
2044 assert_eq!(sessions.len(), 2);
2045
2046 for s in &sessions {
2048 assert_eq!(s.cwd, cwd.to_string_lossy().to_string());
2049 }
2050
2051 let mut counts: Vec<usize> = sessions.iter().map(|s| s.message_count).collect();
2053 counts.sort();
2054 assert_eq!(counts, vec![1, 2]);
2055
2056 for s in &sessions {
2058 assert!(s.first_message.is_some());
2059 }
2060
2061 assert!(sessions.iter().any(|s| s.name.as_deref() == Some("First")));
2062 assert!(sessions
2063 .iter()
2064 .any(|s| s.summary.as_deref() == Some("Second session summary")));
2065 }
2066
2067 #[test]
2068 fn session_continue_recent() {
2069 let tmp = TempDir::new().unwrap();
2070 let session_dir = tmp.path().join("sessions");
2071 let cwd_a = tmp.path().join("project-a");
2072 let cwd_b = tmp.path().join("project-b");
2073
2074 let mut s1 = SessionManager::new(&cwd_a, &session_dir).unwrap();
2076 s1.append(make_msg_entry("a1", "hello from a")).unwrap();
2077
2078 let mut s2 = SessionManager::new(&cwd_b, &session_dir).unwrap();
2080 s2.append(make_msg_entry("b1", "hello from b")).unwrap();
2081
2082 let continued = SessionManager::continue_recent(&cwd_a, &session_dir)
2084 .unwrap()
2085 .expect("should find a session");
2086 assert_eq!(continued.get_messages().len(), 1);
2087
2088 let none =
2090 SessionManager::continue_recent(Path::new("/nonexistent"), &session_dir).unwrap();
2091 assert!(none.is_none());
2092 }
2093
2094 #[test]
2095 fn session_name_and_summary_persist_across_reopen() {
2096 let tmp = TempDir::new().unwrap();
2097 let session_dir = tmp.path().join("sessions");
2098 let cwd = tmp.path().join("project");
2099
2100 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2101 mgr.append(make_msg_entry("m1", "hello world")).unwrap();
2102 mgr.set_name("Debug auth");
2103 mgr.set_summary("Investigating OAuth login failures");
2104
2105 let path = mgr.path().unwrap().to_path_buf();
2106 let reopened = SessionManager::open(&path).unwrap();
2107 assert_eq!(reopened.name(), Some("Debug auth"));
2108 assert_eq!(
2109 reopened.summary(),
2110 Some("Investigating OAuth login failures")
2111 );
2112 }
2113
2114 #[test]
2115 fn session_in_memory() {
2116 let mut mgr = SessionManager::in_memory();
2117 assert!(mgr.path().is_none());
2118
2119 mgr.append(make_msg_entry("m1", "hello")).unwrap();
2120 mgr.append(make_msg_entry("m2", "world")).unwrap();
2121
2122 assert_eq!(mgr.get_messages().len(), 2);
2123 assert_eq!(mgr.entries().len(), 2);
2124 }
2125
2126 #[test]
2127 fn session_malformed_jsonl() {
2128 let tmp = TempDir::new().unwrap();
2129 let path = tmp.path().join("bad.jsonl");
2130
2131 let content = format!(
2133 "{}\n\
2134 NOT VALID JSON\n\
2135 {}\n\
2136 {{\"type\":\"unknown_variant\",\"foo\":1}}\n\
2137 {}\n",
2138 serde_json::to_string(&SessionEntry::Header {
2139 version: 1,
2140 created_at: 1000,
2141 cwd: "/tmp".into(),
2142 })
2143 .unwrap(),
2144 serde_json::to_string(&SessionEntry::Message {
2145 id: "m1".into(),
2146 parent_id: None,
2147 message: Message::user("hello"),
2148 })
2149 .unwrap(),
2150 serde_json::to_string(&SessionEntry::Message {
2151 id: "m2".into(),
2152 parent_id: Some("m1".into()),
2153 message: Message::user("world"),
2154 })
2155 .unwrap(),
2156 );
2157 std::fs::write(&path, content).unwrap();
2158
2159 let mgr = SessionManager::open(&path).unwrap();
2161 assert_eq!(mgr.entries().len(), 3);
2163 assert_eq!(mgr.get_messages().len(), 2);
2164 }
2165
2166 #[test]
2167 fn session_get_tree() {
2168 let mut mgr = SessionManager::in_memory();
2169 for i in 1..=3 {
2170 mgr.append(make_msg_entry(&format!("m{i}"), &format!("msg {i}")))
2171 .unwrap();
2172 }
2173 mgr.navigate("m2").unwrap();
2175 mgr.append(make_msg_entry("b1", "branch")).unwrap();
2176
2177 let tree = mgr.get_tree();
2178 assert_eq!(tree.len(), 1);
2180 assert_eq!(tree[0].entry.id(), Some("m1"));
2181
2182 assert_eq!(tree[0].children.len(), 1);
2184 let m2_node = &tree[0].children[0];
2185 assert_eq!(m2_node.entry.id(), Some("m2"));
2186
2187 assert_eq!(m2_node.children.len(), 2);
2189 let child_ids: Vec<Option<&str>> = m2_node.children.iter().map(|n| n.entry.id()).collect();
2190 assert!(child_ids.contains(&Some("m3")));
2191 assert!(child_ids.contains(&Some("b1")));
2192 }
2193
2194 #[test]
2195 fn append_assistant_turn_persists_canonical_usage_once() {
2196 let tmp = TempDir::new().unwrap();
2197 let session_dir = tmp.path().join("sessions");
2198 let cwd = tmp.path().join("project");
2199 let model = Model {
2200 meta: imp_llm::ModelMeta {
2201 id: "test-model".into(),
2202 provider: "test-provider".into(),
2203 name: "Test Model".into(),
2204 context_window: 8192,
2205 max_output_tokens: 2048,
2206 pricing: ModelPricing {
2207 input_per_mtok: 1.0,
2208 output_per_mtok: 2.0,
2209 cache_read_per_mtok: 0.5,
2210 cache_write_per_mtok: 1.0,
2211 },
2212 capabilities: Capabilities {
2213 reasoning: false,
2214 images: false,
2215 tool_use: true,
2216 },
2217 },
2218 provider: std::sync::Arc::new(NoopProvider { models: Vec::new() }),
2219 };
2220
2221 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2222 let message = AssistantMessage {
2223 content: vec![imp_llm::ContentBlock::Text {
2224 text: "done".into(),
2225 }],
2226 usage: Some(imp_llm::Usage {
2227 input_tokens: 100,
2228 output_tokens: 25,
2229 cache_read_tokens: 10,
2230 cache_write_tokens: 5,
2231 }),
2232 stop_reason: imp_llm::StopReason::EndTurn,
2233 timestamp: 123,
2234 };
2235
2236 let (_assistant_id, usage_id) = mgr
2237 .append_assistant_turn(&model, 3, message.clone())
2238 .unwrap();
2239 assert!(usage_id.is_some());
2240
2241 let (_assistant_id_2, usage_id_2) = mgr
2242 .append_assistant_turn(
2243 &model,
2244 4,
2245 AssistantMessage {
2246 usage: None,
2247 ..message
2248 },
2249 )
2250 .unwrap();
2251 assert!(usage_id_2.is_none());
2252
2253 let usage_records = mgr.usage_records();
2254 assert_eq!(usage_records.len(), 1);
2255 assert_eq!(usage_records[0].turn_index, Some(3));
2256 assert_eq!(usage_records[0].provider.as_deref(), Some("test-provider"));
2257 assert_eq!(usage_records[0].model.as_deref(), Some("test-model"));
2258 }
2259
2260 #[test]
2261 fn append_checkpoint_record_round_trips_and_lookup_works() {
2262 let tmp = tempfile::tempdir().unwrap();
2263 let cwd = tmp.path().join("project");
2264 let session_dir = tmp.path().join("sessions");
2265 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2266
2267 let record = SessionCheckpointRecord {
2268 version: CHECKPOINT_RECORD_VERSION,
2269 checkpoint_id: "cp-1".into(),
2270 created_at: 123,
2271 label: Some("before edits".into()),
2272 files: vec!["src/main.rs".into(), "src/lib.rs".into()],
2273 };
2274 mgr.append_checkpoint_record(record.clone()).unwrap();
2275
2276 assert_eq!(mgr.checkpoint_records(), vec![record.clone()]);
2277 assert_eq!(
2278 mgr.find_checkpoint_record("cp-1").unwrap().label.as_deref(),
2279 Some("before edits")
2280 );
2281 assert_eq!(
2282 mgr.find_checkpoint_record("before edits")
2283 .unwrap()
2284 .checkpoint_id,
2285 "cp-1"
2286 );
2287 }
2288
2289 #[test]
2290 fn restore_checkpoint_uses_checkpoint_state() {
2291 let tmp = tempfile::tempdir().unwrap();
2292 let cwd = tmp.path().join("project");
2293 let session_dir = tmp.path().join("sessions");
2294 std::fs::create_dir_all(&cwd).unwrap();
2295 let file = cwd.join("main.rs");
2296 std::fs::write(&file, "original").unwrap();
2297
2298 let checkpoint_state = crate::tools::CheckpointState::new();
2299 let checkpoint = checkpoint_state
2300 .snapshot_paths(std::slice::from_ref(&file), Some("before edits".into()))
2301 .unwrap()
2302 .unwrap();
2303 std::fs::write(&file, "modified").unwrap();
2304
2305 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
2306 mgr.append_checkpoint_record(SessionCheckpointRecord {
2307 version: CHECKPOINT_RECORD_VERSION,
2308 checkpoint_id: checkpoint.id.clone(),
2309 created_at: checkpoint.created_at,
2310 label: checkpoint.label.clone(),
2311 files: checkpoint
2312 .files
2313 .iter()
2314 .map(|path| path.to_string_lossy().to_string())
2315 .collect(),
2316 })
2317 .unwrap();
2318
2319 let restored = mgr
2320 .restore_checkpoint(&checkpoint_state, "before edits")
2321 .unwrap();
2322 assert_eq!(restored, vec![file.clone()]);
2323 assert_eq!(std::fs::read_to_string(&file).unwrap(), "original");
2324 }
2325
2326 #[test]
2327 fn session_navigate_invalid() {
2328 let mut mgr = SessionManager::in_memory();
2329 mgr.append(make_msg_entry("m1", "hello")).unwrap();
2330
2331 let result = mgr.navigate("nonexistent");
2332 assert!(result.is_err());
2333 }
2334}