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