Skip to main content

imp_core/
session.rs

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/// A single entry in the session JSONL file.
33#[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    /// Get the id of this entry, if it has one (Header and Label don't).
78    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    /// Get the parent_id of this entry, if it has one.
90    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/// A node in the session tree.
103#[derive(Debug, Clone)]
104pub struct TreeNode {
105    pub entry: SessionEntry,
106    pub children: Vec<TreeNode>,
107}
108
109/// Summary of a session for listing.
110#[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    /// A short, single-line chat title derived from persisted session metadata or message history.
149    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/// Manages a single session's entries and persistence.
168///
169/// Raw persisted entries are always retained in `entries`. Active model-visible
170/// history may differ from the raw branch when a `SessionEntry::Compaction`
171/// exists on the current branch. In that case, callers should prefer
172/// `get_active_messages()` over `get_messages()` when assembling context for an
173/// LLM request.
174#[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    /// Create a new session. Writes the header to disk immediately.
185    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        // Write header to disk immediately
195        {
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    /// Open an existing session file, skipping malformed lines.
215    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                    // Keep session loading side-effect free for embedded callers like the TUI.
240                    // Malformed lines are skipped so resume/continue can still recover usable history.
241                }
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    /// In-memory session (no persistence).
255    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    /// In-memory session seeded with a linear message history.
266    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    /// Find the most recently modified session for a given cwd.
279    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            // Check modification time first (cheap) before parsing
294            let modified = dir_entry
295                .metadata()?
296                .modified()
297                .unwrap_or(std::time::UNIX_EPOCH);
298
299            // Only parse if this could be newer than our current best
300            if best.as_ref().is_none_or(|(t, _)| modified > *t) {
301                // Read just the first line to check cwd without parsing the whole file
302                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    /// Get the session name.
321    pub fn name(&self) -> Option<&str> {
322        self.session_name.as_deref()
323    }
324
325    /// Get the session summary.
326    pub fn summary(&self) -> Option<&str> {
327        self.session_summary.as_deref()
328    }
329
330    /// Set the session name.
331    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    /// Set the session summary.
337    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    /// Clear the session summary.
344    pub fn clear_summary(&mut self) {
345        self.session_summary = None;
346        let _ = self.persist_session_meta();
347    }
348
349    /// A short, single-line chat title derived from persisted session metadata or message history.
350    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    /// Append an entry. Sets parent_id to current leaf_id, updates leaf_id,
389    /// and writes to file if persisted.
390    pub fn append(&mut self, mut entry: SessionEntry) -> Result<()> {
391        // Set parent_id on entries that support it
392        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        // Update leaf_id
404        if let Some(id) = entry.id() {
405            self.leaf_id = Some(id.to_string());
406        }
407
408        // Write to file
409        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    /// Append an assistant turn and, when available, its canonical usage record.
427    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    /// Append an assistant turn and, when available, its canonical usage record.
437    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    /// Append a tool result message and return the persisted entry id.
463    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    /// Append a recovery checkpoint custom entry and return the persisted entry id.
474    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    /// Persist the session entries implied by an agent event.
482    ///
483    /// Returns a short description of what was written so callers can surface
484    /// best-effort persistence diagnostics without owning the persistence logic.
485    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    /// Persist the session entries implied by an agent event.
494    ///
495    /// Returns a short description of what was written so callers can surface
496    /// best-effort persistence diagnostics without owning the persistence logic.
497    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    /// Append a canonical usage entry for an assistant turn, if the turn reports usage
531    /// and no equivalent canonical record already exists.
532    ///
533    /// This is best-effort metadata persistence: callers should treat errors as
534    /// non-fatal to the main agent flow.
535    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    /// Append a canonical usage entry for an assistant turn, if the turn reports usage
551    /// and no equivalent canonical record already exists.
552    ///
553    /// This is best-effort metadata persistence: callers should treat errors as
554    /// non-fatal to the main agent flow.
555    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    /// Read canonical usage rows attached to this session.
579    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    /// Check whether a canonical usage record already exists for the given request id.
630    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    /// Check whether a canonical usage record already exists for the given assistant turn.
650    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    /// Walk parent_ids from leaf_id to root, return raw entries in chronological order.
672    ///
673    /// This is the durable branch as persisted on disk. It may include
674    /// `SessionEntry::Compaction` markers plus raw pre-compaction messages.
675    /// Callers building model-visible context should prefer
676    /// `get_active_messages()`.
677    pub fn get_branch(&self) -> Vec<&SessionEntry> {
678        let Some(ref leaf) = self.leaf_id else {
679            // No messages yet — return just the header if present
680            return self
681                .entries
682                .iter()
683                .filter(|e| matches!(e, SessionEntry::Header { .. }))
684                .collect();
685        };
686
687        // Build id -> entry index for fast lookups
688        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        // Walk from leaf to root
696        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        // Include the header
710        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    /// Get raw message entries for the current branch.
722    ///
723    /// This reflects the durable branch exactly and intentionally ignores
724    /// compaction semantics. For model-visible history after a compaction,
725    /// prefer `get_active_messages()`.
726    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    /// Return the latest compaction entry on the active branch, if any.
737    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    /// Build the model-visible message history for the active branch.
745    ///
746    /// Compaction semantics are branch-local and replacement-based:
747    /// - if there is no compaction entry on the branch, this returns the raw
748    ///   branch messages;
749    /// - if a compaction entry exists, all raw messages before that boundary are
750    ///   replaced by a synthetic user summary message derived from the latest
751    ///   compaction entry, followed by the raw messages from `first_kept_id`
752    ///   onward that are still on the active branch.
753    ///
754    /// Raw persisted entries remain intact on disk and are still available via
755    /// `get_branch()` / `get_messages()`.
756    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    /// Get the active model-visible branch entries.
803    ///
804    /// This is a convenience wrapper over `get_active_messages()` for callers
805    /// that still want borrowed-like iteration semantics at the message layer.
806    pub fn active_message_count(&self) -> usize {
807        self.get_active_messages().len()
808    }
809
810    /// Build the full tree structure from all entries.
811    pub fn get_tree(&self) -> Vec<TreeNode> {
812        // Separate roots (entries with no parent_id that have an id) and children
813        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    /// Change the current position in the tree to a different entry.
853    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    /// Create a new session file containing only entries up to (and including) the
865    /// given entry_id, following its branch from root.
866    pub fn fork(&self, entry_id: &str, new_path: &Path) -> Result<SessionManager> {
867        // Build the branch to this entry
868        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        // Collect header + branch entries
890        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        // Also include any Label entries that reference entries in our branch
902        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        // Also include session metadata so names/summaries survive forks.
917        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        // Write to new file
926        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    /// Return all persisted recovery checkpoints in session order.
955    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    /// Build a recovery ledger from persisted recovery checkpoint entries.
974    pub fn recovery_ledger(&self) -> crate::agent::RecoveryLedger {
975        crate::agent::RecoveryLedger::from_checkpoints(self.recovery_checkpoints())
976    }
977
978    /// Get all entries.
979    pub fn entries(&self) -> &[SessionEntry] {
980        &self.entries
981    }
982
983    /// Get the session file path.
984    pub fn path(&self) -> Option<&Path> {
985        self.path.as_deref()
986    }
987
988    /// Get the current leaf id.
989    pub fn leaf_id(&self) -> Option<&str> {
990        self.leaf_id.as_deref()
991    }
992
993    /// Set the current leaf id for an in-memory session.
994    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    /// Get the stable session id derived from the persisted file name, if any.
1021    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    /// List available sessions in a directory.
1029    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
1120/// Sanitize a message history for API submission.
1121///
1122/// Strips unpaired tool_call blocks (assistant tool_use without matching tool_result)
1123/// and orphaned tool_result messages (tool_result without matching tool_use).
1124/// This handles both old sessions (before tool_result persistence) and corrupted
1125/// sessions where tool calls were partially recorded.
1126pub fn sanitize_messages(messages: &mut Vec<Message>) {
1127    use std::collections::HashSet;
1128
1129    // Collect tool_result IDs to find which tool_calls have results
1130    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    // Strip unpaired tool_call blocks from assistant messages
1139    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    // Remove empty assistant messages left after stripping
1149    messages.retain(|msg| match msg {
1150        Message::Assistant(a) => !a.content.is_empty(),
1151        _ => true,
1152    });
1153
1154    // Strip orphaned tool_results whose tool_call no longer exists
1155    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: ensure each tool_result follows the assistant message that
1172    // contains its tool_call. Session persistence can write tool_results
1173    // before the assistant message (ToolExecutionEnd fires before TurnEnd).
1174    reorder_tool_results(messages);
1175}
1176
1177/// Move tool_result messages so they immediately follow the assistant
1178/// message containing the matching tool_call.
1179fn reorder_tool_results(messages: &mut Vec<Message>) {
1180    use std::collections::HashMap;
1181
1182    // Build map: tool_call_id → index of the assistant message that has it
1183    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    // Separate tool_results that are out of order
1195    let mut deferred: Vec<(usize, Message)> = Vec::new(); // (target_after_idx, msg)
1196    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                    // tool_result appears before its assistant — pull it out
1202                    let msg = messages.remove(i);
1203                    deferred.push((assistant_idx, msg));
1204                    // Adjust assistant indices after removal
1205                    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; // don't increment i
1216                }
1217            }
1218        }
1219        i += 1;
1220    }
1221
1222    // Re-insert deferred tool_results after their assistant messages
1223    // Sort by target index descending so insertions don't shift earlier targets
1224    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
1231/// Extract the first text content from a message.
1232fn 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            // Session summaries are stored in compact session-meta entries.
1256            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
1771/// Read just the first non-empty line of a file.
1772fn 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, // append() will set this
1856            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        // Reopen and verify messages match
2064        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        // Verify parent chain: m1 has no parent, m2's parent is m1, m3's parent is m2
2071        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        // Append 5 messages (m1..m5)
2088        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        // Navigate back to m3
2096        mgr.navigate("m3").unwrap();
2097        assert_eq!(mgr.leaf_id(), Some("m3"));
2098
2099        // Append 2 new messages on the branch
2100        mgr.append(make_msg_entry("b1", "branch 1")).unwrap();
2101        mgr.append(make_msg_entry("b2", "branch 2")).unwrap();
2102
2103        // get_branch should return: header-less chain of m1, m2, m3, b1, b2
2104        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        // Navigate back to m5 to verify original branch still works
2113        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        // Forked session should have header + m1, m2, m3
2138        assert_eq!(forked.get_messages().len(), 3);
2139        assert_eq!(forked.leaf_id(), Some("m3"));
2140        assert!(fork_path.exists());
2141
2142        // Reopen the forked file and verify
2143        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        // Create two sessions
2154        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        // Both should have the right cwd
2167        for s in &sessions {
2168            assert_eq!(s.cwd, cwd.to_string_lossy().to_string());
2169        }
2170
2171        // One has 1 message, the other has 2
2172        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        // first_message should be set
2177        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        // Create a session for cwd_a
2212        let mut s1 = SessionManager::new(&cwd_a, &session_dir).unwrap();
2213        s1.append(make_msg_entry("a1", "hello from a")).unwrap();
2214
2215        // Create a session for cwd_b
2216        let mut s2 = SessionManager::new(&cwd_b, &session_dir).unwrap();
2217        s2.append(make_msg_entry("b1", "hello from b")).unwrap();
2218
2219        // continue_recent for cwd_a should find s1
2220        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        // continue_recent for a non-existent cwd returns None
2226        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        // Write a file with a mix of valid and invalid lines
2269        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        // Should succeed, skipping the bad lines
2297        let mgr = SessionManager::open(&path).unwrap();
2298        // Header + 2 valid messages (bad lines skipped)
2299        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        // Branch from m2
2311        mgr.navigate("m2").unwrap();
2312        mgr.append(make_msg_entry("b1", "branch")).unwrap();
2313
2314        let tree = mgr.get_tree();
2315        // Root should be m1 (no parent)
2316        assert_eq!(tree.len(), 1);
2317        assert_eq!(tree[0].entry.id(), Some("m1"));
2318
2319        // m1 -> m2
2320        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        // m2 has two children: m3 and b1
2325        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}