Skip to main content

oxi/store/
session.rs

1//! Session management for the coding agent.
2//!
3//! Manages conversation sessions as append-only trees stored in JSONL files.
4//! Each session entry has an id and parent_id forming a tree structure.
5
6use anyhow::{Context, Result};
7use chrono::{DateTime, Utc};
8use parking_lot::RwLock;
9use serde::{Deserialize, Serialize};
10use std::collections::{HashMap, HashSet};
11use std::fs::{self, File};
12use std::io::{BufRead, BufReader, Write};
13use std::path::{Path, PathBuf};
14use uuid::Uuid;
15
16// ============================================================================
17// Atomic Write Helper
18// ============================================================================
19
20use super::fs_util::atomic_write;
21
22/// Type alias for entry IDs (for backward compatibility)
23pub type EntryId = Uuid;
24
25/// Current session version for migrations
26pub const CURRENT_SESSION_VERSION: i32 = 3;
27
28// ============================================================================
29// Backward Compatibility Layer
30// ============================================================================
31
32/// Session metadata stored separately from entries (backward compatibility)
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SessionMeta {
35    /// Unique session identifier.
36    pub id: Uuid,
37    /// ID of the parent session this was branched from.
38    pub parent_id: Option<Uuid>,
39    /// ID of the root session in the branch chain.
40    pub root_id: Option<Uuid>,
41    /// Entry ID where this session was branched.
42    pub branch_point: Option<Uuid>,
43    /// Creation timestamp in milliseconds since epoch.
44    pub created_at: i64,
45    /// Last update timestamp in milliseconds since epoch.
46    pub updated_at: i64,
47    /// Optional human-readable session name.
48    pub name: Option<String>,
49}
50
51impl SessionMeta {
52    /// New.
53    pub fn new(id: Uuid) -> Self {
54        let now = Utc::now().timestamp_millis();
55        Self {
56            id,
57            parent_id: None,
58            root_id: None,
59            branch_point: None,
60            created_at: now,
61            updated_at: now,
62            name: None,
63        }
64    }
65
66    /// Branched from.
67    pub fn branched_from(parent_id: Uuid, root_id: Option<Uuid>, branch_point: Uuid) -> Self {
68        let now = Utc::now().timestamp_millis();
69        Self {
70            id: Uuid::new_v4(),
71            parent_id: Some(parent_id),
72            root_id: root_id.or(Some(parent_id)),
73            branch_point: Some(branch_point),
74            created_at: now,
75            updated_at: now,
76            name: None,
77        }
78    }
79}
80
81/// Information about where a session branched from
82#[derive(Debug, Clone)]
83pub struct BranchInfo {
84    /// The session id.
85    pub session_id: Uuid,
86    /// The parent session id.
87    pub parent_session_id: Option<Uuid>,
88    /// The root session id.
89    pub root_session_id: Option<Uuid>,
90    /// The branch point entry id.
91    pub branch_point_entry_id: Option<Uuid>,
92    /// The parent session name.
93    pub parent_session_name: Option<String>,
94}
95
96// ============================================================================
97// Session Header
98// ============================================================================
99
100/// Session header stored as the first line in JSONL files
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SessionHeader {
103    /// The entry type.
104    #[serde(rename = "type")]
105    pub entry_type: String,
106    /// The version.
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub version: Option<i32>,
109    /// The id.
110    pub id: String,
111    /// The timestamp.
112    pub timestamp: String,
113    /// The cwd.
114    pub cwd: String,
115    /// The parent session.
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub parent_session: Option<String>,
118}
119
120impl SessionHeader {
121    /// New.
122    pub fn new(id: String, cwd: String, parent_session: Option<String>) -> Self {
123        Self {
124            entry_type: "session".to_string(),
125            version: Some(CURRENT_SESSION_VERSION),
126            id,
127            timestamp: Utc::now().to_rfc3339(),
128            cwd,
129            parent_session,
130        }
131    }
132}
133
134// ============================================================================
135// Content Types
136// ============================================================================
137
138/// Content can be string or array of content blocks
139#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(untagged)]
141pub enum ContentValue {
142    /// String.
143    String(String),
144    /// Blocks.
145    Blocks(Vec<ContentBlock>),
146}
147
148impl ContentValue {
149    /// As str.
150    pub fn as_str(&self) -> &str {
151        match self {
152            ContentValue::String(s) => s,
153            ContentValue::Blocks(blocks) => {
154                // For blocks, return first text block or empty
155                for block in blocks {
156                    if let ContentBlock::Text { text } = block {
157                        return text;
158                    }
159                }
160                ""
161            }
162        }
163    }
164}
165
166impl From<String> for ContentValue {
167    fn from(s: String) -> Self {
168        ContentValue::String(s)
169    }
170}
171
172impl From<&str> for ContentValue {
173    fn from(s: &str) -> Self {
174        ContentValue::String(s.to_string())
175    }
176}
177
178/// Content block for text or image content
179#[derive(Debug, Clone, Serialize, Deserialize)]
180#[serde(tag = "type")]
181pub enum ContentBlock {
182    /// Plain text content block.
183    #[serde(rename = "text")]
184    Text {
185        /// The text content.
186        text: String,
187    },
188    /// Image content block.
189    #[serde(rename = "image")]
190    Image {
191        /// Base64-encoded image data.
192        data: String,
193        /// MIME type of the image.
194        media_type: Option<String>,
195    },
196}
197
198// ============================================================================
199// Agent Message Types
200// ============================================================================
201
202/// Agent message roles
203#[derive(Debug, Clone, Serialize, Deserialize)]
204#[serde(tag = "role")]
205pub enum AgentMessage {
206    /// User.
207    #[serde(rename = "user")]
208    User {
209        /// The content.
210        content: ContentValue,
211    },
212    /// Assistant.
213    #[serde(rename = "assistant")]
214    Assistant {
215        /// The content.
216        content: Vec<AssistantContentBlock>,
217        /// The provider.
218        #[serde(skip_serializing_if = "Option::is_none")]
219        provider: Option<String>,
220        /// The model id.
221        #[serde(skip_serializing_if = "Option::is_none")]
222        model_id: Option<String>,
223        /// The usage.
224        #[serde(skip_serializing_if = "Option::is_none")]
225        usage: Option<Usage>,
226        /// The stop reason.
227        #[serde(rename = "stopReason", skip_serializing_if = "Option::is_none")]
228        stop_reason: Option<String>,
229    },
230    /// Tool Result.
231    #[serde(rename = "toolResult")]
232    ToolResult {
233        /// The content.
234        content: ContentValue,
235        /// The tool call id.
236        #[serde(rename = "toolCallId")]
237        tool_call_id: String,
238    },
239    /// System.
240    #[serde(rename = "system")]
241    System {
242        /// The content.
243        content: ContentValue,
244    },
245    /// Bash Execution.
246    #[serde(rename = "bashExecution")]
247    BashExecution {
248        /// The command.
249        command: String,
250        /// The output.
251        output: String,
252        /// The exit code.
253        #[serde(rename = "exitCode")]
254        exit_code: Option<i32>,
255        /// The cancelled.
256        cancelled: bool,
257        /// The truncated.
258        truncated: bool,
259        /// The full output path.
260        #[serde(rename = "fullOutputPath", skip_serializing_if = "Option::is_none")]
261        full_output_path: Option<String>,
262        /// The exclude from context.
263        #[serde(rename = "excludeFromContext", skip_serializing_if = "Option::is_none")]
264        exclude_from_context: Option<bool>,
265        /// The timestamp.
266        timestamp: i64,
267    },
268    /// Custom.
269    #[serde(rename = "custom")]
270    Custom {
271        /// The custom type.
272        #[serde(rename = "customType")]
273        custom_type: String,
274        /// The content.
275        content: ContentValue,
276        /// The display.
277        display: bool,
278        /// The details.
279        #[serde(skip_serializing_if = "Option::is_none")]
280        details: Option<serde_json::Value>,
281        /// The timestamp.
282        timestamp: i64,
283    },
284    /// Branch Summary.
285    #[serde(rename = "branchSummary")]
286    BranchSummary {
287        /// The summary.
288        summary: String,
289        /// The from id.
290        #[serde(rename = "fromId")]
291        from_id: String,
292        /// The timestamp.
293        timestamp: i64,
294    },
295    /// Compaction Summary.
296    #[serde(rename = "compactionSummary")]
297    CompactionSummary {
298        /// The summary.
299        summary: String,
300        /// The tokens before.
301        #[serde(rename = "tokensBefore")]
302        tokens_before: i64,
303        /// The timestamp.
304        timestamp: i64,
305    },
306}
307
308impl AgentMessage {
309    /// Get the content of the message as a string
310    pub fn content(&self) -> String {
311        match self {
312            AgentMessage::User { content } => content.as_str().to_string(),
313            AgentMessage::Assistant { content, .. } => {
314                let estimated_len = content
315                    .iter()
316                    .map(|b| match b {
317                        AssistantContentBlock::Text { text: t } => t.len(),
318                        _ => 0,
319                    })
320                    .sum::<usize>();
321                let mut text = String::with_capacity(estimated_len.max(256));
322                for block in content {
323                    if let AssistantContentBlock::Text { text: t } = block {
324                        text.push_str(t)
325                    }
326                }
327                text
328            }
329            AgentMessage::ToolResult { content, .. } => content.as_str().to_string(),
330            AgentMessage::System { content } => content.as_str().to_string(),
331            AgentMessage::BashExecution { output, .. } => output.clone(),
332            AgentMessage::Custom { content, .. } => content.as_str().to_string(),
333            AgentMessage::BranchSummary { summary, .. } => summary.clone(),
334            AgentMessage::CompactionSummary { summary, .. } => summary.clone(),
335        }
336    }
337
338    /// Check if this is a user message
339    pub fn is_user(&self) -> bool {
340        matches!(self, AgentMessage::User { .. })
341    }
342
343    /// Check if this is an assistant message
344    pub fn is_assistant(&self) -> bool {
345        matches!(self, AgentMessage::Assistant { .. })
346    }
347}
348
349/// Content block for assistant messages
350#[derive(Debug, Clone, Serialize, Deserialize)]
351#[serde(tag = "type")]
352pub enum AssistantContentBlock {
353    /// Plain text content block.
354    #[serde(rename = "text")]
355    Text {
356        /// The text content.
357        text: String,
358    },
359    /// Extended thinking content block.
360    #[serde(rename = "thinking")]
361    Thinking {
362        /// The thinking content.
363        thinking: String,
364    },
365    /// Tool Call.
366    #[serde(rename = "toolCall")]
367    ToolCall {
368        /// The id.
369        id: String,
370        /// The name.
371        name: String,
372        /// The arguments.
373        arguments: serde_json::Value,
374    },
375    /// Tool Plan.
376    #[serde(rename = "toolPlan")]
377    ToolPlan {
378        /// The content.
379        content: String,
380        /// The tool call id.
381        #[serde(rename = "toolCallId")]
382        tool_call_id: String,
383    },
384    /// Image result content block.
385    #[serde(rename = "image")]
386    ImageResult {
387        /// Base64-encoded image data.
388        data: String,
389        /// MIME type of the image.
390        media_type: String,
391    },
392    /// Refusal content block.
393    #[serde(rename = "refusal")]
394    Refusal {
395        /// The refusal reason.
396        content: String,
397    },
398}
399
400/// Usage statistics from an assistant message
401#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct Usage {
403    /// The input.
404    #[serde(rename = "inputTokens", skip_serializing_if = "Option::is_none")]
405    pub input: Option<i64>,
406    /// The output.
407    #[serde(rename = "outputTokens", skip_serializing_if = "Option::is_none")]
408    pub output: Option<i64>,
409    /// The cache read.
410    #[serde(rename = "cacheReadTokens", skip_serializing_if = "Option::is_none")]
411    pub cache_read: Option<i64>,
412    /// The cache write.
413    #[serde(rename = "cacheWriteTokens", skip_serializing_if = "Option::is_none")]
414    pub cache_write: Option<i64>,
415    /// The total tokens.
416    #[serde(rename = "totalTokens", skip_serializing_if = "Option::is_none")]
417    pub total_tokens: Option<i64>,
418}
419
420// ============================================================================
421// Session Entry Types
422// ============================================================================
423
424/// Base fields for all session entries (internal use)
425#[derive(Debug, Clone, Serialize, Deserialize)]
426pub struct SessionEntryBase {
427    /// The entry type.
428    #[serde(rename = "type")]
429    pub entry_type: String,
430    /// The id.
431    pub id: String,
432    /// The parent id.
433    #[serde(rename = "parentId")]
434    pub parent_id: Option<String>,
435    /// The timestamp.
436    pub timestamp: String,
437}
438
439/// Message entry with AgentMessage content
440#[derive(Debug, Clone, Serialize, Deserialize)]
441pub struct SessionMessageEntry {
442    /// The base.
443    #[serde(flatten)]
444    pub base: SessionEntryBase,
445    /// The message.
446    pub message: AgentMessage,
447}
448
449/// Thinking level change entry
450#[derive(Debug, Clone, Serialize, Deserialize)]
451pub struct ThinkingLevelChangeEntry {
452    /// The base.
453    #[serde(flatten)]
454    pub base: SessionEntryBase,
455    /// The thinking level.
456    #[serde(rename = "thinkingLevel")]
457    pub thinking_level: String,
458}
459
460/// Model change entry
461#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct ModelChangeEntry {
463    /// The base.
464    #[serde(flatten)]
465    pub base: SessionEntryBase,
466    /// The provider.
467    pub provider: String,
468    /// The model id.
469    #[serde(rename = "modelId")]
470    pub model_id: String,
471}
472
473/// Compaction entry for context window management
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct CompactionEntry {
476    /// The base.
477    #[serde(flatten)]
478    pub base: SessionEntryBase,
479    /// The summary.
480    pub summary: String,
481    /// The first kept entry id.
482    #[serde(rename = "firstKeptEntryId")]
483    pub first_kept_entry_id: String,
484    /// The tokens before.
485    #[serde(rename = "tokensBefore")]
486    pub tokens_before: i64,
487    /// The details.
488    #[serde(skip_serializing_if = "Option::is_none")]
489    pub details: Option<serde_json::Value>,
490    /// The from hook.
491    #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
492    pub from_hook: Option<bool>,
493}
494
495/// Branch summary entry for abandoned branches
496#[derive(Debug, Clone, Serialize, Deserialize)]
497pub struct BranchSummaryEntry {
498    /// The base.
499    #[serde(flatten)]
500    pub base: SessionEntryBase,
501    /// The from id.
502    #[serde(rename = "fromId")]
503    pub from_id: String,
504    /// The summary.
505    pub summary: String,
506    /// The details.
507    #[serde(skip_serializing_if = "Option::is_none")]
508    pub details: Option<serde_json::Value>,
509    /// The from hook.
510    #[serde(rename = "fromHook", skip_serializing_if = "Option::is_none")]
511    pub from_hook: Option<bool>,
512}
513
514/// Custom entry for extensions to store extension-specific data
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct CustomEntry {
517    /// The base.
518    #[serde(flatten)]
519    pub base: SessionEntryBase,
520    /// The custom type.
521    #[serde(rename = "customType")]
522    pub custom_type: String,
523    /// The data.
524    #[serde(skip_serializing_if = "Option::is_none")]
525    pub data: Option<serde_json::Value>,
526}
527
528/// Label entry for user-defined bookmarks/markers on entries
529#[derive(Debug, Clone, Serialize, Deserialize)]
530pub struct LabelEntry {
531    /// The base.
532    #[serde(flatten)]
533    pub base: SessionEntryBase,
534    /// The target id.
535    #[serde(rename = "targetId")]
536    pub target_id: String,
537    /// The label.
538    pub label: Option<String>,
539}
540
541/// Session metadata entry (e.g., user-defined display name)
542#[derive(Debug, Clone, Serialize, Deserialize)]
543pub struct SessionInfoEntry {
544    /// The base.
545    #[serde(flatten)]
546    pub base: SessionEntryBase,
547    /// The name.
548    pub name: Option<String>,
549}
550
551/// Custom message entry for extensions to inject messages into LLM context
552#[derive(Debug, Clone, Serialize, Deserialize)]
553pub struct CustomMessageEntry {
554    /// The base.
555    #[serde(flatten)]
556    pub base: SessionEntryBase,
557    /// The custom type.
558    #[serde(rename = "customType")]
559    pub custom_type: String,
560    /// The content.
561    pub content: ContentValue,
562    /// The details.
563    #[serde(skip_serializing_if = "Option::is_none")]
564    pub details: Option<serde_json::Value>,
565    /// The display.
566    pub display: bool,
567}
568
569/// All possible session entries (internal enum)
570#[derive(Debug, Clone, Serialize, Deserialize)]
571#[serde(untagged)]
572pub enum SessionEntryEnum {
573    /// Message.
574    Message(SessionMessageEntry),
575    /// Thinking Level Change.
576    ThinkingLevelChange(ThinkingLevelChangeEntry),
577    /// Model Change.
578    ModelChange(ModelChangeEntry),
579    /// Compaction.
580    Compaction(CompactionEntry),
581    /// Branch Summary.
582    BranchSummary(BranchSummaryEntry),
583    /// Custom.
584    Custom(CustomEntry),
585    /// Label.
586    Label(LabelEntry),
587    /// Session Info.
588    SessionInfo(SessionInfoEntry),
589    /// Custom Message.
590    CustomMessage(CustomMessageEntry),
591}
592
593/// Session entry - a simple struct for backward compatibility
594/// This wraps the internal enum representation
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct SessionEntry {
597    /// The id.
598    pub id: String,
599    /// The parent id.
600    pub parent_id: Option<String>,
601    /// The timestamp.
602    pub timestamp: i64,
603    /// The message.
604    pub message: AgentMessage,
605}
606
607impl SessionEntry {
608    /// Create a new session entry
609    pub fn new(message: AgentMessage) -> Self {
610        Self {
611            id: Uuid::new_v4().to_string(),
612            parent_id: None,
613            timestamp: Utc::now().timestamp_millis(),
614            message,
615        }
616    }
617
618    /// Create a simple message entry with a role string and content
619    pub fn simple_message(role: &str, content: &str) -> Self {
620        use crate::store::session::ContentValue;
621        let message = match role {
622            "user" => AgentMessage::User {
623                content: ContentValue::String(content.to_string()),
624            },
625            "assistant" => AgentMessage::Assistant {
626                content: vec![AssistantContentBlock::Text {
627                    text: content.to_string(),
628                }],
629                provider: None,
630                model_id: None,
631                usage: None,
632                stop_reason: None,
633            },
634            "system" => AgentMessage::System {
635                content: ContentValue::String(content.to_string()),
636            },
637            _ => AgentMessage::System {
638                content: ContentValue::String(content.to_string()),
639            },
640        };
641        Self::new(message)
642    }
643
644    /// Create a branched entry with a parent reference
645    pub fn branched(message: AgentMessage, parent_id: &str) -> Self {
646        Self {
647            id: Uuid::new_v4().to_string(),
648            parent_id: Some(parent_id.to_string()),
649            timestamp: Utc::now().timestamp_millis(),
650            message,
651        }
652    }
653
654    /// Get the message content as a string
655    pub fn content(&self) -> String {
656        self.message.content()
657    }
658}
659
660/// Raw file entry (includes header and internal enum)
661#[derive(Debug, Clone, Serialize, Deserialize)]
662#[serde(untagged)]
663pub enum FileEntry {
664    /// Header.
665    Header(SessionHeader),
666    /// Entry.
667    Entry(SessionEntryEnum),
668}
669
670// ============================================================================
671// Session Context
672// ============================================================================
673
674/// Context built from session entries for the LLM
675#[derive(Debug, Clone)]
676pub struct SessionContext {
677    /// The messages.
678    pub messages: Vec<AgentMessage>,
679    /// The thinking level.
680    pub thinking_level: String,
681    /// The model.
682    pub model: Option<ModelInfo>,
683}
684
685/// Model information
686#[derive(Debug, Clone)]
687pub struct ModelInfo {
688    /// The provider.
689    pub provider: String,
690    /// The model id.
691    pub model_id: String,
692}
693
694// ============================================================================
695// Session Info
696// ============================================================================
697
698/// Session metadata for listing
699#[derive(Debug, Clone)]
700pub struct SessionInfo {
701    /// The path.
702    pub path: String,
703    /// The id.
704    pub id: String,
705    /// The cwd.
706    pub cwd: String,
707    /// The name.
708    pub name: Option<String>,
709    /// The parent session path.
710    pub parent_session_path: Option<String>,
711    /// The created.
712    pub created: DateTime<Utc>,
713    /// The modified.
714    pub modified: DateTime<Utc>,
715    /// The message count.
716    pub message_count: i64,
717    /// The first message.
718    pub first_message: String,
719    /// The all messages text.
720    pub all_messages_text: String,
721}
722
723// ============================================================================
724// Session Tree Node
725// ============================================================================
726
727/// Tree node for get_tree()
728#[derive(Debug, Clone)]
729pub struct SessionTreeNode {
730    /// The entry.
731    pub entry: SessionEntry,
732    /// The children.
733    pub children: Vec<SessionTreeNode>,
734    /// The label.
735    pub label: Option<String>,
736    /// The label timestamp.
737    pub label_timestamp: Option<String>,
738}
739
740// ============================================================================
741// ID Generation
742// ============================================================================
743
744fn generate_id(by_id: &HashSet<String>) -> String {
745    for _ in 0..100 {
746        let id = Uuid::new_v4().to_string()[..8].to_string();
747        if !by_id.contains(&id) {
748            return id;
749        }
750    }
751    // Fallback to full UUID if somehow we have collisions
752    Uuid::new_v4().to_string()
753}
754
755// ============================================================================
756// Version Migration
757// ============================================================================
758
759/// Migrate v1 to v2: add id/parent_id tree structure
760fn migrate_v1_to_v2(entries: &mut [FileEntry]) {
761    let mut ids = HashSet::new();
762    let mut prev_id: Option<String> = None;
763
764    for entry in entries.iter_mut() {
765        match entry {
766            FileEntry::Header(header) => {
767                header.version = Some(2);
768            }
769            FileEntry::Entry(entry) => {
770                let id = match entry {
771                    SessionEntryEnum::Message(e) => {
772                        e.base.id = generate_id(&ids);
773                        e.base.parent_id = prev_id.clone();
774                        e.base.entry_type = "message".to_string();
775                        prev_id = Some(e.base.id.clone());
776                        e.base.id.clone()
777                    }
778                    SessionEntryEnum::ThinkingLevelChange(e) => {
779                        e.base.id = generate_id(&ids);
780                        e.base.parent_id = prev_id.clone();
781                        e.base.entry_type = "thinking_level_change".to_string();
782                        prev_id = Some(e.base.id.clone());
783                        e.base.id.clone()
784                    }
785                    SessionEntryEnum::ModelChange(e) => {
786                        e.base.id = generate_id(&ids);
787                        e.base.parent_id = prev_id.clone();
788                        e.base.entry_type = "model_change".to_string();
789                        prev_id = Some(e.base.id.clone());
790                        e.base.id.clone()
791                    }
792                    SessionEntryEnum::Compaction(e) => {
793                        e.base.id = generate_id(&ids);
794                        e.base.parent_id = prev_id.clone();
795                        e.base.entry_type = "compaction".to_string();
796                        prev_id = Some(e.base.id.clone());
797                        e.base.id.clone()
798                    }
799                    SessionEntryEnum::BranchSummary(e) => {
800                        e.base.id = generate_id(&ids);
801                        e.base.parent_id = prev_id.clone();
802                        e.base.entry_type = "branch_summary".to_string();
803                        prev_id = Some(e.base.id.clone());
804                        e.base.id.clone()
805                    }
806                    SessionEntryEnum::Custom(e) => {
807                        e.base.id = generate_id(&ids);
808                        e.base.parent_id = prev_id.clone();
809                        e.base.entry_type = "custom".to_string();
810                        prev_id = Some(e.base.id.clone());
811                        e.base.id.clone()
812                    }
813                    SessionEntryEnum::Label(e) => {
814                        e.base.id = generate_id(&ids);
815                        e.base.parent_id = prev_id.clone();
816                        e.base.entry_type = "label".to_string();
817                        prev_id = Some(e.base.id.clone());
818                        e.base.id.clone()
819                    }
820                    SessionEntryEnum::SessionInfo(e) => {
821                        e.base.id = generate_id(&ids);
822                        e.base.parent_id = prev_id.clone();
823                        e.base.entry_type = "session_info".to_string();
824                        prev_id = Some(e.base.id.clone());
825                        e.base.id.clone()
826                    }
827                    SessionEntryEnum::CustomMessage(e) => {
828                        e.base.id = generate_id(&ids);
829                        e.base.parent_id = prev_id.clone();
830                        e.base.entry_type = "custom_message".to_string();
831                        prev_id = Some(e.base.id.clone());
832                        e.base.id.clone()
833                    }
834                };
835                ids.insert(id);
836            }
837        }
838    }
839}
840
841/// Migrate v2 to v3: rename hookMessage role to custom
842fn migrate_v2_to_v3(entries: &mut [FileEntry]) {
843    for entry in entries.iter_mut() {
844        match entry {
845            FileEntry::Header(header) => {
846                header.version = Some(3);
847            }
848            FileEntry::Entry(_) => {
849                // v2 to v3 migration handled elsewhere
850            }
851        }
852    }
853}
854
855/// Run all necessary migrations to bring entries to current version
856fn migrate_to_current_version(entries: &mut [FileEntry]) -> bool {
857    let header = entries.iter().find_map(|e| match e {
858        FileEntry::Header(h) => Some(h),
859        _ => None,
860    });
861    let version = header.and_then(|h| h.version).unwrap_or(1);
862
863    if version >= CURRENT_SESSION_VERSION {
864        return false;
865    }
866
867    if version < 2 {
868        migrate_v1_to_v2(entries);
869    }
870    if version < 3 {
871        migrate_v2_to_v3(entries);
872    }
873
874    true
875}
876
877// ============================================================================
878// Session Manager
879// ============================================================================
880
881/// Manages conversation sessions as append-only trees stored in JSONL files.
882///
883/// SessionManager handles session persistence, branching, and tree traversal.
884/// Each session is stored as a JSONL file where each line is a session entry.
885/// Entries form a tree structure allowing for session branching and history.
886pub struct SessionManager {
887    session_id: String,
888    session_file: Option<String>,
889    session_dir: String,
890    cwd: String,
891    persist: bool,
892    flushed: bool,
893    /// Tracks how many agent messages have been persisted so far,
894    /// so that `persist_session()` only appends new messages.
895    persisted_count: RwLock<usize>,
896    file_entries: RwLock<Vec<FileEntry>>,
897    by_id: RwLock<HashMap<String, SessionEntry>>,
898    labels_by_id: RwLock<HashMap<String, String>>,
899    label_timestamps_by_id: RwLock<HashMap<String, String>>,
900    leaf_id: RwLock<Option<String>>,
901}
902
903// Manual Clone implementation — only copies internal pointers, not file handles
904impl Clone for SessionManager {
905    fn clone(&self) -> Self {
906        Self {
907            session_id: self.session_id.clone(),
908            session_file: self.session_file.clone(),
909            session_dir: self.session_dir.clone(),
910            cwd: self.cwd.clone(),
911            persist: self.persist,
912            flushed: self.flushed,
913            persisted_count: RwLock::new(*self.persisted_count.read()),
914            file_entries: RwLock::new(self.file_entries.read().clone()),
915            by_id: RwLock::new(self.by_id.read().clone()),
916            labels_by_id: RwLock::new(self.labels_by_id.read().clone()),
917            label_timestamps_by_id: RwLock::new(self.label_timestamps_by_id.read().clone()),
918            leaf_id: RwLock::new(self.leaf_id.read().clone()),
919        }
920    }
921}
922
923impl SessionManager {
924    /// Create a new session and persist it to disk.
925    pub fn create(cwd: &str, session_dir: Option<&str>) -> Self {
926        let dir = session_dir
927            .map(|s| s.to_string())
928            .unwrap_or_else(|| get_default_session_dir(cwd));
929
930        let mut manager = Self::new_internal(cwd, &dir, None, true);
931        manager.persist = true;
932        manager
933    }
934
935    /// Open an existing session from a file path.
936    pub fn open(path: &str, session_dir: Option<&str>, cwd_override: Option<&str>) -> Self {
937        let entries = load_entries_from_file(path);
938        let header = entries.iter().find_map(|e| match e {
939            FileEntry::Header(h) => Some(h),
940            _ => None,
941        });
942        let cwd = cwd_override
943            .map(|s| s.to_string())
944            .or_else(|| header.as_ref().map(|h| h.cwd.clone()))
945            .unwrap_or_else(|| {
946                std::env::current_dir()
947                    .unwrap_or_else(|_| PathBuf::from("."))
948                    .to_string_lossy()
949                    .to_string()
950            });
951        let dir = session_dir.map(|s| s.to_string()).unwrap_or_else(|| {
952            Path::new(path)
953                .parent()
954                .map(|p| p.to_string_lossy().to_string())
955                .unwrap_or_else(|| ".".to_string())
956        });
957
958        let mut manager = Self::new_internal(&cwd, &dir, Some(path), true);
959        manager.persist = true;
960        manager
961    }
962
963    /// Continue the most recent session, or create a new one if none exists.
964    pub fn continue_recent(cwd: &str, session_dir: Option<&str>) -> Self {
965        let dir = session_dir
966            .map(|s| s.to_string())
967            .unwrap_or_else(|| get_default_session_dir(cwd));
968
969        if let Some(most_recent) = find_most_recent_session(&dir) {
970            return Self::open(&most_recent, None, None);
971        }
972        Self::create(cwd, None)
973    }
974
975    /// Create an in-memory session without file persistence.
976    pub fn in_memory(cwd: &str) -> Self {
977        let cwd = cwd.to_string();
978        Self::new_internal(&cwd, "", None, false)
979    }
980
981    fn new_internal(
982        cwd: &str,
983        session_dir: &str,
984        session_file: Option<&str>,
985        persist: bool,
986    ) -> Self {
987        let cwd = cwd.to_string();
988        let session_dir = session_dir.to_string();
989
990        if persist && !session_dir.is_empty() && !Path::new(&session_dir).exists() {
991            let _ = fs::create_dir_all(&session_dir);
992        }
993
994        let mut manager = Self {
995            session_id: Uuid::new_v4().to_string(),
996            session_file: session_file.map(|s| s.to_string()),
997            session_dir,
998            cwd,
999            persist,
1000            flushed: false,
1001            persisted_count: RwLock::new(0),
1002            file_entries: RwLock::new(Vec::new()),
1003            by_id: RwLock::new(HashMap::new()),
1004            labels_by_id: RwLock::new(HashMap::new()),
1005            label_timestamps_by_id: RwLock::new(HashMap::new()),
1006            leaf_id: RwLock::new(None),
1007        };
1008
1009        if let Some(file) = session_file {
1010            manager.set_session_file(file);
1011        } else {
1012            manager.new_session(None);
1013        }
1014
1015        manager
1016    }
1017
1018    /// Switch to a different session file
1019    pub fn set_session_file(&mut self, session_file: &str) {
1020        let path = Path::new(session_file)
1021            .canonicalize()
1022            .unwrap_or_else(|_| PathBuf::from(session_file));
1023        let path_str = path.to_string_lossy().to_string();
1024        self.session_file = Some(path_str.clone());
1025
1026        if path.exists() {
1027            let mut entries = load_entries_from_file(&path_str);
1028
1029            // If file was empty or corrupted (no valid header), truncate and start fresh
1030            if entries.is_empty() {
1031                let explicit_path = self.session_file.take();
1032                self.new_session(None);
1033                self.session_file = explicit_path;
1034                self._rewrite_file();
1035                self.flushed = true;
1036                return;
1037            }
1038
1039            let header = entries.iter().find_map(|e| match e {
1040                FileEntry::Header(h) => Some(h),
1041                _ => None,
1042            });
1043            self.session_id = header
1044                .map(|h| h.id.clone())
1045                .unwrap_or_else(|| Uuid::new_v4().to_string());
1046
1047            if migrate_to_current_version(&mut entries) {
1048                self._rewrite_file();
1049            }
1050
1051            *self.file_entries.write() = entries;
1052            self._build_index();
1053            self.flushed = true;
1054        } else {
1055            let explicit_path = self.session_file.take();
1056            self.new_session(None);
1057            self.session_file = explicit_path;
1058        }
1059    }
1060
1061    /// Create a new session with optional ID and parent
1062    pub fn new_session(&mut self, options: Option<NewSessionOptions>) {
1063        self.session_id = options
1064            .as_ref()
1065            .and_then(|o| o.id.clone())
1066            .unwrap_or_else(|| Uuid::new_v4().to_string());
1067        let timestamp = Utc::now().to_rfc3339();
1068        let header = SessionHeader::new(
1069            self.session_id.clone(),
1070            self.cwd.clone(),
1071            options.and_then(|o| o.parent_session),
1072        );
1073
1074        self.file_entries = RwLock::new(vec![FileEntry::Header(header)]);
1075        self.by_id.write().clear();
1076        self.labels_by_id.write().clear();
1077        self.label_timestamps_by_id.write().clear();
1078        *self.leaf_id.write() = None;
1079        *self.persisted_count.write() = 0;
1080        self.flushed = false;
1081
1082        if self.persist {
1083            let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1084            let short_id = &self.session_id[..8];
1085            self.session_file = Some(format!(
1086                "{}/{}_{}.jsonl",
1087                self.session_dir, file_timestamp, short_id
1088            ));
1089        }
1090    }
1091
1092    fn _build_index(&mut self) {
1093        let mut by_id = self.by_id.write();
1094        let mut labels = self.labels_by_id.write();
1095        let mut label_timestamps = self.label_timestamps_by_id.write();
1096        let mut leaf_id = self.leaf_id.write();
1097
1098        by_id.clear();
1099        labels.clear();
1100        label_timestamps.clear();
1101        *leaf_id = None;
1102
1103        for entry in self.file_entries.read().iter() {
1104            if let FileEntry::Entry(e) = entry {
1105                // Convert internal enum to simple SessionEntry struct
1106                if let Some(session_entry) = convert_to_session_entry(e) {
1107                    by_id.insert(session_entry.id.clone(), session_entry.clone());
1108                    *leaf_id = Some(session_entry.id.clone());
1109                }
1110
1111                // Handle labels
1112                if let SessionEntryEnum::Label(l) = e {
1113                    if let Some(ref label) = l.label {
1114                        labels.insert(l.target_id.clone(), label.clone());
1115                        label_timestamps.insert(l.target_id.clone(), l.base.timestamp.clone());
1116                    } else {
1117                        labels.remove(&l.target_id);
1118                        label_timestamps.remove(&l.target_id);
1119                    }
1120                }
1121            }
1122        }
1123    }
1124
1125    fn _rewrite_file(&self) {
1126        if !self.persist || self.session_file.is_none() {
1127            return;
1128        }
1129
1130        let file = match self.session_file.as_ref() {
1131            Some(f) => f,
1132            None => return,
1133        };
1134
1135        let content: String = self
1136            .file_entries
1137            .read()
1138            .iter()
1139            .map(|e| serde_json::to_string(e).unwrap_or_default())
1140            .collect::<Vec<_>>()
1141            .join("\n")
1142            + "\n";
1143
1144        if let Err(e) = atomic_write(Path::new(file), &content) {
1145            tracing::warn!("Failed to rewrite session file {}: {}", file, e);
1146        }
1147    }
1148
1149    /// Check if session is persisted to disk
1150    pub fn is_persisted(&self) -> bool {
1151        self.persist
1152    }
1153
1154    /// Validate a session ID format.
1155    ///
1156    /// Checks that the session_id conforms to the expected UUID format.
1157    /// Returns `true` if valid.
1158    pub fn validate_session_id(id: &str) -> bool {
1159        Uuid::parse_str(id).is_ok()
1160    }
1161
1162    /// Returns `true` if this session is in read-only mode.
1163    ///
1164    /// A session is read-only when:
1165    /// - It was opened without write permissions
1166    /// - Its underlying file is set to read-only on the filesystem
1167    ///
1168    /// Read-only sessions reject any append/branch operations.
1169    pub fn is_readonly(&self) -> bool {
1170        if !self.persist {
1171            // In-memory sessions start mutable, but can be marked readonly
1172            return false;
1173        }
1174        if let Some(ref file) = self.session_file {
1175            let path = Path::new(file);
1176            if path.exists()
1177                && let Ok(metadata) = fs::metadata(path)
1178            {
1179                #[cfg(unix)]
1180                {
1181                    use std::os::unix::fs::PermissionsExt;
1182                    let perm = metadata.permissions().mode();
1183                    // 0o200 = write bit for owner removed
1184                    return perm & 0o200 == 0;
1185                }
1186                #[cfg(not(unix))]
1187                {
1188                    let _ = metadata;
1189                    return false;
1190                }
1191            }
1192        }
1193        false
1194    }
1195
1196    /// Check if appending to this session is allowed.
1197    ///
1198    /// Combination of `!is_readonly()` + in-memory or writable backing file.
1199    pub fn can_append(&self) -> bool {
1200        !self.is_readonly() && self.persist
1201    }
1202
1203    /// Get the number of agent messages that have already been persisted.
1204    pub fn persisted_count(&self) -> usize {
1205        *self.persisted_count.read()
1206    }
1207
1208    /// Set the number of agent messages that have been persisted.
1209    pub fn set_persisted_count(&self, count: usize) {
1210        *self.persisted_count.write() = count;
1211    }
1212
1213    /// Get working directory
1214    pub fn get_cwd(&self) -> String {
1215        self.cwd.clone()
1216    }
1217
1218    /// Get session directory
1219    pub fn get_session_dir(&self) -> String {
1220        self.session_dir.clone()
1221    }
1222
1223    /// Get session ID
1224    pub fn get_session_id(&self) -> String {
1225        self.session_id.clone()
1226    }
1227
1228    /// Get session file path
1229    pub fn get_session_file(&self) -> Option<String> {
1230        self.session_file.clone()
1231    }
1232
1233    /// Remove the session file from disk if the session has no real conversation
1234    /// (i.e., no user message was ever persisted).
1235    /// Called before switching to a new session or quitting.
1236    pub fn cleanup_if_empty(&self) {
1237        if !self.persist {
1238            return;
1239        }
1240        let Some(file) = &self.session_file else {
1241            return;
1242        };
1243
1244        let has_user = self.file_entries.read().iter().any(|e| {
1245            matches!(
1246                e,
1247                FileEntry::Entry(SessionEntryEnum::Message(m)) if m.message.is_user()
1248            )
1249        });
1250
1251        if !has_user {
1252            let path = Path::new(file);
1253            if path.exists() {
1254                if let Err(e) = fs::remove_file(path) {
1255                    tracing::warn!("Failed to remove empty session file {}: {}", file, e);
1256                } else {
1257                    tracing::debug!("Removed empty session file: {}", file);
1258                }
1259            }
1260        }
1261    }
1262
1263    fn _persist(&mut self, entry: &SessionEntry) {
1264        if !self.persist {
1265            return;
1266        }
1267        let Some(file) = &self.session_file else {
1268            return;
1269        };
1270
1271        // pi deferred-flush pattern: only write to disk once we have at
1272        // least one assistant message. Before that, keep entries in memory
1273        // and set flushed = false so the full buffer is written when the
1274        // first assistant arrives.
1275        let has_assistant = self.file_entries.read().iter().any(|e| {
1276            matches!(
1277                e,
1278                FileEntry::Entry(SessionEntryEnum::Message(m))
1279                    if m.message.is_assistant()
1280            )
1281        });
1282
1283        if !has_assistant {
1284            // Keep in memory, don't write yet.
1285            // When the first assistant arrives, all accumulated entries
1286            // (header + user + this entry) will be flushed at once.
1287            self.flushed = false;
1288            return;
1289        }
1290
1291        let mut handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1292            Ok(h) => h,
1293            Err(e) => {
1294                tracing::warn!("Failed to open session file for append {}: {}", file, e);
1295                return;
1296            }
1297        };
1298
1299        if !self.flushed {
1300            for e in self.file_entries.read().iter() {
1301                if let Ok(line) = serde_json::to_string(e) {
1302                    let _ = writeln!(&mut handle, "{}", line);
1303                } else {
1304                    tracing::warn!("Failed to serialize session entry, skipping");
1305                }
1306            }
1307            self.flushed = true;
1308        } else {
1309            // Convert SessionEntry back to FileEntry for writing
1310            let file_entry = convert_from_session_entry(entry);
1311            if let Ok(line) = serde_json::to_string(&file_entry) {
1312                let _ = writeln!(&mut handle, "{}", line);
1313            } else {
1314                tracing::warn!("Failed to serialize incremental session entry, skipping");
1315            }
1316        }
1317    }
1318
1319    // LOCK ORDERING CONVENTION (must be followed to prevent deadlock):
1320    // 1. file_entries  2. by_id  3. labels_by_id  4. label_timestamps_by_id  5. leaf_id
1321    // Always acquire locks in this order. Never acquire an earlier lock after a later one.
1322    fn _append_entry(&mut self, entry: SessionEntry) {
1323        let file_entry = convert_from_session_entry(&entry);
1324        self.file_entries.write().push(FileEntry::Entry(file_entry));
1325        self.by_id.write().insert(entry.id.clone(), entry.clone());
1326        *self.leaf_id.write() = Some(entry.id.clone());
1327        self._persist(&entry);
1328    }
1329
1330    /// Append a message as child of current leaf
1331    pub fn append_message(&mut self, message: AgentMessage) -> String {
1332        let leaf = self.leaf_id.read().clone();
1333        let id = Uuid::new_v4().to_string();
1334        let entry = SessionEntry {
1335            id: id.clone(),
1336            parent_id: leaf,
1337            timestamp: Utc::now().timestamp_millis(),
1338            message,
1339        };
1340        self._append_entry(entry);
1341        id
1342    }
1343
1344    /// Append a thinking level change
1345    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1346        let leaf = self.leaf_id.read().clone();
1347        let id = Uuid::new_v4().to_string();
1348        let entry = SessionEntry {
1349            id: id.clone(),
1350            parent_id: leaf,
1351            timestamp: Utc::now().timestamp_millis(),
1352            message: AgentMessage::Custom {
1353                custom_type: "thinking_level_change".to_string(),
1354                content: ContentValue::String(thinking_level.to_string()),
1355                display: false,
1356                details: None,
1357                timestamp: Utc::now().timestamp_millis(),
1358            },
1359        };
1360        self._append_entry(entry);
1361        id
1362    }
1363
1364    /// Append a model change
1365    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1366        let leaf = self.leaf_id.read().clone();
1367        let id = Uuid::new_v4().to_string();
1368        let entry = SessionEntry {
1369            id: id.clone(),
1370            parent_id: leaf,
1371            timestamp: Utc::now().timestamp_millis(),
1372            message: AgentMessage::Custom {
1373                custom_type: "model_change".to_string(),
1374                content: ContentValue::String(format!("{}:{}", provider, model_id)),
1375                display: false,
1376                details: None,
1377                timestamp: Utc::now().timestamp_millis(),
1378            },
1379        };
1380        self._append_entry(entry);
1381        id
1382    }
1383
1384    /// Append a compaction summary
1385    pub fn append_compaction(
1386        &mut self,
1387        summary: &str,
1388        _first_kept_entry_id: &str,
1389        tokens_before: i64,
1390        _details: Option<serde_json::Value>,
1391        _from_hook: Option<bool>,
1392    ) -> String {
1393        let leaf = self.leaf_id.read().clone();
1394        let id = Uuid::new_v4().to_string();
1395        let entry = SessionEntry {
1396            id: id.clone(),
1397            parent_id: leaf,
1398            timestamp: Utc::now().timestamp_millis(),
1399            message: AgentMessage::CompactionSummary {
1400                summary: summary.to_string(),
1401                tokens_before,
1402                timestamp: Utc::now().timestamp_millis(),
1403            },
1404        };
1405        self._append_entry(entry);
1406        id
1407    }
1408
1409    /// Append a custom entry (for extensions)
1410    pub fn append_custom_entry(
1411        &mut self,
1412        custom_type: &str,
1413        data: Option<serde_json::Value>,
1414    ) -> String {
1415        let leaf = self.leaf_id.read().clone();
1416        let id = Uuid::new_v4().to_string();
1417        let entry = SessionEntry {
1418            id: id.clone(),
1419            parent_id: leaf,
1420            timestamp: Utc::now().timestamp_millis(),
1421            message: AgentMessage::Custom {
1422                custom_type: custom_type.to_string(),
1423                content: data
1424                    .as_ref()
1425                    .map(|d| ContentValue::String(d.to_string()))
1426                    .unwrap_or(ContentValue::String(String::new())),
1427                display: false,
1428                details: data.clone(),
1429                timestamp: Utc::now().timestamp_millis(),
1430            },
1431        };
1432        self._append_entry(entry);
1433        id
1434    }
1435
1436    /// Append a session info entry (e.g., display name)
1437    pub fn append_session_info(&mut self, name: &str) -> String {
1438        let leaf = self.leaf_id.read().clone();
1439        let id = Uuid::new_v4().to_string();
1440        let entry = SessionEntry {
1441            id: id.clone(),
1442            parent_id: leaf,
1443            timestamp: Utc::now().timestamp_millis(),
1444            message: AgentMessage::Custom {
1445                custom_type: "session_info".to_string(),
1446                content: ContentValue::String(name.trim().to_string()),
1447                display: false,
1448                details: None,
1449                timestamp: Utc::now().timestamp_millis(),
1450            },
1451        };
1452        self._append_entry(entry);
1453        id
1454    }
1455
1456    /// Get the current session name from the latest session_info entry
1457    pub fn get_session_name(&self) -> Option<String> {
1458        let entries = self.get_entries();
1459        for entry in entries.iter().rev() {
1460            if let AgentMessage::Custom {
1461                custom_type,
1462                content,
1463                ..
1464            } = &entry.message
1465                && custom_type == "session_info"
1466            {
1467                return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1468            }
1469        }
1470        None
1471    }
1472
1473    /// Append a custom message entry (for extensions) that participates in LLM context
1474    pub fn append_custom_message_entry(
1475        &mut self,
1476        custom_type: &str,
1477        content: ContentValue,
1478        display: bool,
1479        details: Option<serde_json::Value>,
1480    ) -> String {
1481        let leaf = self.leaf_id.read().clone();
1482        let id = Uuid::new_v4().to_string();
1483        let entry = SessionEntry {
1484            id: id.clone(),
1485            parent_id: leaf,
1486            timestamp: Utc::now().timestamp_millis(),
1487            message: AgentMessage::Custom {
1488                custom_type: custom_type.to_string(),
1489                content,
1490                display,
1491                details,
1492                timestamp: Utc::now().timestamp_millis(),
1493            },
1494        };
1495        self._append_entry(entry);
1496        id
1497    }
1498
1499    // =========================================================================
1500    // Tree Traversal
1501    // =========================================================================
1502
1503    /// Get the current leaf ID
1504    pub fn get_leaf_id(&self) -> Option<String> {
1505        self.leaf_id.read().clone()
1506    }
1507
1508    /// Set the leaf pointer to a specific entry, navigating to that branch.
1509    ///
1510    /// Validates that the entry exists in the session tree and updates
1511    /// the internal leaf pointer. Used for TUI branch navigation —
1512    /// after calling this, `get_branch(None)` returns the path from
1513    /// root to the target entry.
1514    pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1515        if !self.by_id.read().contains_key(entry_id) {
1516            return Err(format!("Entry {} not found", entry_id));
1517        }
1518        *self.leaf_id.write() = Some(entry_id.to_string());
1519        Ok(())
1520    }
1521
1522    /// Get the current leaf entry
1523    pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1524        self.leaf_id
1525            .read()
1526            .as_ref()
1527            .and_then(|id| self.by_id.read().get(id).cloned())
1528    }
1529
1530    /// Get an entry by ID
1531    pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1532        self.by_id.read().get(id).cloned()
1533    }
1534
1535    /// Get all direct children of an entry
1536    pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1537        self.by_id
1538            .read()
1539            .values()
1540            .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1541            .cloned()
1542            .collect()
1543    }
1544
1545    /// Get the parent of an entry
1546    pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1547        self.by_id
1548            .read()
1549            .get(id)
1550            .and_then(|e| e.parent_id.as_deref())
1551            .and_then(|pid| self.by_id.read().get(pid).cloned())
1552    }
1553
1554    /// Get the label for an entry
1555    pub fn get_label(&self, id: &str) -> Option<String> {
1556        self.labels_by_id.read().get(id).cloned()
1557    }
1558
1559    /// Set or clear a label on an entry
1560    pub fn append_label_change(
1561        &mut self,
1562        target_id: &str,
1563        label: Option<&str>,
1564    ) -> Result<String, String> {
1565        if !self.by_id.read().contains_key(target_id) {
1566            return Err(format!("Entry {} not found", target_id));
1567        }
1568
1569        let leaf = self.leaf_id.read().clone();
1570        let id = Uuid::new_v4().to_string();
1571        let entry = SessionEntry {
1572            id: id.clone(),
1573            parent_id: leaf,
1574            timestamp: Utc::now().timestamp_millis(),
1575            message: AgentMessage::Custom {
1576                custom_type: "label".to_string(),
1577                content: ContentValue::String(label.unwrap_or("").to_string()),
1578                display: false,
1579                details: Some(serde_json::json!({ "targetId": target_id })),
1580                timestamp: Utc::now().timestamp_millis(),
1581            },
1582        };
1583
1584        self._append_entry(entry);
1585
1586        if let Some(l) = label {
1587            self.labels_by_id
1588                .write()
1589                .insert(target_id.to_string(), l.to_string());
1590            self.label_timestamps_by_id
1591                .write()
1592                .insert(target_id.to_string(), Utc::now().to_rfc3339());
1593        } else {
1594            self.labels_by_id.write().remove(target_id);
1595            self.label_timestamps_by_id.write().remove(target_id);
1596        }
1597
1598        Ok(id)
1599    }
1600
1601    /// Walk from entry to root, returning all entries in path order
1602    pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1603        let mut path = Vec::new();
1604        let leaf_fallback = self.leaf_id.read().clone();
1605        let start_id = from_id.or(leaf_fallback.as_deref());
1606        let Some(start_id) = start_id else {
1607            return path;
1608        };
1609
1610        // Acquire the lock once and reuse it for the entire traversal
1611        let by_id = self.by_id.read();
1612        let mut current = by_id.get(start_id).cloned();
1613        while let Some(entry) = current {
1614            path.insert(0, entry.clone());
1615            current = entry
1616                .parent_id
1617                .as_ref()
1618                .and_then(|pid| by_id.get(pid).cloned());
1619        }
1620        path
1621    }
1622
1623    /// Get path to root for a given entry
1624    pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1625        self.get_branch(Some(from_id))
1626    }
1627
1628    /// Get ancestry (same as path to root)
1629    pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1630        self.get_branch(Some(from_id))
1631    }
1632
1633    /// Get depth of an entry
1634    pub fn get_depth(&self, id: &str) -> i64 {
1635        let mut depth = 0;
1636        let mut current = self.by_id.read().get(id).cloned();
1637        while let Some(entry) = current {
1638            depth += 1;
1639            current = entry
1640                .parent_id
1641                .as_ref()
1642                .and_then(|pid| self.by_id.read().get(pid).cloned());
1643        }
1644        depth - 1 // Root has depth 0
1645    }
1646
1647    /// Build the session context (what gets sent to the LLM)
1648    pub fn build_session_context(&self) -> SessionContext {
1649        let entries = self.get_entries();
1650        let leaf_id = self.leaf_id.read().clone();
1651        build_session_context_internal(&entries, leaf_id, None)
1652    }
1653
1654    /// Get session header
1655    pub fn get_header(&self) -> Option<SessionHeader> {
1656        self.file_entries.read().iter().find_map(|e| match e {
1657            FileEntry::Header(h) => Some(h.clone()),
1658            _ => None,
1659        })
1660    }
1661
1662    /// Get all session entries (excludes header)
1663    pub fn get_entries(&self) -> Vec<SessionEntry> {
1664        self.by_id.read().values().cloned().collect()
1665    }
1666
1667    /// Get the session as a tree structure
1668    /// If id is provided, returns tree for that session (backward compat)
1669    pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1670        let entries = self.get_entries();
1671        let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1672        let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1673
1674        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1675        let mut root_ids: Vec<String> = Vec::new();
1676
1677        // Build adjacency list
1678        for entry in &entries {
1679            adj.insert(entry.id.clone(), Vec::new());
1680        }
1681
1682        // Determine parent-child relationships
1683        for entry in &entries {
1684            let is_root = match entry.parent_id.as_deref() {
1685                Some(pid) if pid != entry.id => !adj.contains_key(pid),
1686                _ => true,
1687            };
1688            if is_root {
1689                root_ids.push(entry.id.clone());
1690            } else if let Some(ref pid) = entry.parent_id {
1691                if let Some(children) = adj.get_mut(pid.as_str()) {
1692                    children.push(entry.id.clone());
1693                } else {
1694                    root_ids.push(entry.id.clone());
1695                }
1696            }
1697        }
1698
1699        // Build entries map
1700        let entries_map: HashMap<String, SessionEntry> =
1701            entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1702
1703        // Recursively build tree nodes
1704        fn build(
1705            id: &str,
1706            adj: &HashMap<String, Vec<String>>,
1707            entries_map: &HashMap<String, SessionEntry>,
1708            labels: &HashMap<String, String>,
1709            label_timestamps: &HashMap<String, String>,
1710        ) -> anyhow::Result<SessionTreeNode> {
1711            let entry = entries_map
1712                .get(id)
1713                .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1714                .clone();
1715            let child_ids = adj.get(id).cloned().unwrap_or_default();
1716            let children: Vec<SessionTreeNode> = child_ids
1717                .iter()
1718                .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1719                .collect::<Result<Vec<_>, _>>()?;
1720            Ok(SessionTreeNode {
1721                entry,
1722                children,
1723                label: labels.get(id).cloned(),
1724                label_timestamp: label_timestamps.get(id).cloned(),
1725            })
1726        }
1727
1728        let mut roots = root_ids
1729            .into_iter()
1730            .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1731            .collect::<anyhow::Result<Vec<_>>>()?;
1732
1733        sort_tree_by_timestamp(&mut roots);
1734        Ok(roots)
1735    }
1736
1737    // =========================================================================
1738    // Branching
1739    // =========================================================================
1740
1741    /// Start a new branch from an earlier entry
1742    pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1743        if !self.by_id.read().contains_key(branch_from_id) {
1744            return Err(format!("Entry {} not found", branch_from_id));
1745        }
1746        *self.leaf_id.write() = Some(branch_from_id.to_string());
1747        Ok(())
1748    }
1749
1750    /// Reset the leaf pointer to null (before any entries)
1751    pub fn reset_leaf(&mut self) {
1752        *self.leaf_id.write() = None;
1753    }
1754
1755    /// Start a new branch with a summary of the abandoned path
1756    pub fn branch_with_summary(
1757        &mut self,
1758        branch_from_id: Option<&str>,
1759        summary: &str,
1760        _details: Option<serde_json::Value>,
1761        _from_hook: Option<bool>,
1762    ) -> String {
1763        if let Some(id) = branch_from_id
1764            && !self.by_id.read().contains_key(id)
1765        {
1766            return String::new();
1767        }
1768
1769        *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1770
1771        let id = Uuid::new_v4().to_string();
1772        let entry = SessionEntry {
1773            id: id.clone(),
1774            parent_id: branch_from_id.map(|s| s.to_string()),
1775            timestamp: Utc::now().timestamp_millis(),
1776            message: AgentMessage::BranchSummary {
1777                summary: summary.to_string(),
1778                from_id: branch_from_id.unwrap_or("root").to_string(),
1779                timestamp: Utc::now().timestamp_millis(),
1780            },
1781        };
1782
1783        self._append_entry(entry);
1784        id
1785    }
1786
1787    /// Add a label to the session
1788    pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1789        self.append_label_change(target_id, Some(label))
1790    }
1791
1792    /// Remove a label from an entry
1793    pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1794        self.append_label_change(target_id, None)
1795    }
1796
1797    // =========================================================================
1798    // Compaction Support
1799    // =========================================================================
1800
1801    /// Get the latest compaction entry
1802    pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1803        let entries = self.get_entries();
1804        for entry in entries.iter().rev() {
1805            if let AgentMessage::CompactionSummary { .. } = &entry.message {
1806                return Some(entry.clone());
1807            }
1808        }
1809        None
1810    }
1811
1812    /// Get all compaction entries
1813    pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1814        self.get_entries()
1815            .iter()
1816            .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1817            .cloned()
1818            .collect()
1819    }
1820
1821    // =========================================================================
1822    // Session Statistics
1823    // =========================================================================
1824
1825    /// Get session statistics
1826    pub fn get_session_stats(&self) -> SessionStats {
1827        let entries = self.get_entries();
1828        let mut message_count = 0i64;
1829        let mut user_message_count = 0i64;
1830        let mut assistant_message_count = 0i64;
1831        let mut total_chars = 0i64;
1832        let mut total_tokens_estimate = 0i64;
1833
1834        for entry in &entries {
1835            if let AgentMessage::User { .. } = &entry.message {
1836                user_message_count += 1;
1837            }
1838            if let AgentMessage::Assistant { .. } = &entry.message {
1839                assistant_message_count += 1;
1840            }
1841            if entry.message.is_user() || entry.message.is_assistant() {
1842                message_count += 1;
1843                // Estimate tokens from message
1844                let content = entry.content();
1845                let chars = content.len() as i64;
1846                total_chars += chars;
1847                total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1848            }
1849        }
1850
1851        SessionStats {
1852            message_count,
1853            user_message_count,
1854            assistant_message_count,
1855            total_chars,
1856            estimated_tokens: total_tokens_estimate,
1857        }
1858    }
1859
1860    // =========================================================================
1861    // Static Methods
1862    // =========================================================================
1863
1864    /// List all sessions for a directory
1865    pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1866        let dir = session_dir
1867            .map(|s| s.to_string())
1868            .unwrap_or_else(|| get_default_session_dir(cwd));
1869        list_sessions_from_dir(&dir).await
1870    }
1871
1872    /// List all sessions across all project directories
1873    pub async fn list_all() -> Result<Vec<SessionInfo>> {
1874        let sessions_dir = get_sessions_dir();
1875
1876        if !Path::new(&sessions_dir).exists() {
1877            return Ok(Vec::new());
1878        }
1879
1880        let mut all_sessions = Vec::new();
1881        let entries = fs::read_dir(&sessions_dir)?;
1882
1883        for entry in entries {
1884            let entry = entry?;
1885            let path = entry.path();
1886            if path.is_dir()
1887                && let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await
1888            {
1889                all_sessions.extend(sessions);
1890            }
1891        }
1892
1893        all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1894        Ok(all_sessions)
1895    }
1896
1897    /// Fork a session from another project directory into the current project
1898    pub fn fork_from(
1899        source_path: &str,
1900        target_cwd: &str,
1901        session_dir: Option<&str>,
1902    ) -> Result<Self, String> {
1903        let source_entries = load_entries_from_file(source_path);
1904        if source_entries.is_empty() {
1905            return Err(format!(
1906                "Cannot fork: source session file is empty or invalid: {}",
1907                source_path
1908            ));
1909        }
1910
1911        let source_header = source_entries.iter().find_map(|e| match e {
1912            FileEntry::Header(h) => Some(h),
1913            _ => None,
1914        });
1915        if source_header.is_none() {
1916            return Err(format!(
1917                "Cannot fork: source session has no header: {}",
1918                source_path
1919            ));
1920        }
1921
1922        let dir = session_dir
1923            .map(|s| s.to_string())
1924            .unwrap_or_else(|| get_default_session_dir(target_cwd));
1925
1926        if !Path::new(&dir).exists() {
1927            let _ = fs::create_dir_all(&dir);
1928        }
1929
1930        let new_session_id = Uuid::new_v4().to_string();
1931        let timestamp = Utc::now().to_rfc3339();
1932        let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1933        let short_id = &new_session_id[..8];
1934        let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1935
1936        // Write new header pointing to source as parent
1937        let new_header = SessionHeader {
1938            entry_type: "session".to_string(),
1939            version: Some(CURRENT_SESSION_VERSION),
1940            id: new_session_id.clone(),
1941            timestamp: timestamp.clone(),
1942            cwd: target_cwd.to_string(),
1943            parent_session: Some(source_path.to_string()),
1944        };
1945
1946        let mut handle = fs::OpenOptions::new()
1947            .create(true)
1948            .truncate(true)
1949            .write(true)
1950            .open(&new_session_file)
1951            .map_err(|e| e.to_string())?;
1952        writeln!(
1953            &mut handle,
1954            "{}",
1955            serde_json::to_string(&new_header).expect("session header serializable")
1956        )
1957        .map_err(|e| e.to_string())?;
1958
1959        // Copy all non-header entries from source
1960        for file_entry in &source_entries {
1961            if let FileEntry::Entry(_) = file_entry {
1962                writeln!(
1963                    &mut handle,
1964                    "{}",
1965                    serde_json::to_string(file_entry).expect("session entry serializable")
1966                )
1967                .map_err(|e| e.to_string())?;
1968            }
1969        }
1970
1971        Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1972    }
1973
1974    /// Delete a session
1975    pub fn delete_session(path: &str) -> Result<()> {
1976        fs::remove_file(path).context("Failed to delete session file")?;
1977        Ok(())
1978    }
1979
1980    /// Rename a session (set its display name)
1981    pub fn rename_session(&mut self, name: &str) -> String {
1982        self.append_session_info(name)
1983    }
1984
1985    // =========================================================================
1986    // Backward Compatibility Methods
1987    // =========================================================================
1988
1989    /// Create a new SessionManager (async for backward compatibility)
1990    pub async fn new() -> Result<Self> {
1991        Self::new_async().await
1992    }
1993
1994    /// Create a new SessionManager (async for backward compatibility)
1995    pub async fn new_async() -> Result<Self> {
1996        let home = dirs::home_dir().context("Cannot find home directory")?;
1997        let base_dir = home.join(".oxi");
1998        let sessions_dir = base_dir.join("sessions");
1999        tokio::fs::create_dir_all(&sessions_dir).await?;
2000        let cwd = std::env::current_dir()
2001            .unwrap_or_else(|_| PathBuf::from("."))
2002            .to_string_lossy()
2003            .to_string();
2004        Ok(Self::in_memory(&cwd))
2005    }
2006
2007    /// Get the session file path for a given session ID
2008    pub fn session_path(&self, id: &Uuid) -> PathBuf {
2009        if let Some(file) = &self.session_file {
2010            PathBuf::from(file)
2011        } else {
2012            PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2013        }
2014    }
2015
2016    /// List all sessions (backward compat)
2017    pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2018        // Simple implementation: scan the session dir for jsonl files
2019        let mut metas = Vec::new();
2020        let session_dir = Path::new(&self.session_dir);
2021        if !session_dir.exists() {
2022            return Ok(metas);
2023        }
2024        let entries = fs::read_dir(session_dir)?;
2025        for entry in entries {
2026            let entry = entry?;
2027            let path = entry.path();
2028            if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2029                let file_name = path
2030                    .file_stem()
2031                    .unwrap_or_else(|| std::ffi::OsStr::new(""))
2032                    .to_string_lossy()
2033                    .to_string();
2034                // Try to extract uuid from filename
2035                if let Some(uuid_part) = file_name.split('_').next_back()
2036                    && let Ok(uuid) = Uuid::parse_str(uuid_part)
2037                {
2038                    let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2039                    let now_ts = Utc::now().timestamp_millis();
2040                    metas.push(SessionMeta {
2041                        id: uuid,
2042                        parent_id: None,
2043                        root_id: None,
2044                        branch_point: None,
2045                        created_at: now_ts,
2046                        updated_at: mtime
2047                            .map(|t| {
2048                                let dt: DateTime<Utc> = DateTime::from(t);
2049                                dt.timestamp_millis()
2050                            })
2051                            .unwrap_or(now_ts),
2052                        name: None,
2053                    });
2054                }
2055            }
2056        }
2057        metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2058        Ok(metas)
2059    }
2060
2061    /// Save entries (backward compat)
2062    pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2063        self._rewrite_file();
2064        Ok(())
2065    }
2066
2067    /// Load entries (backward compat)
2068    pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2069        Ok(self.get_entries())
2070    }
2071
2072    /// Delete a session (backward compat)
2073    pub async fn delete(&self, id: Uuid) -> Result<()> {
2074        let path = self.session_path(&id);
2075        if path.exists() {
2076            fs::remove_file(path).context("Failed to delete session file")?;
2077        }
2078        Ok(())
2079    }
2080
2081    /// Create a branch from an existing session at a given entry
2082    pub async fn branch_from(
2083        &self,
2084        parent_id: Uuid,
2085        entry_id: Uuid,
2086    ) -> Result<(Uuid, Vec<SessionEntry>)> {
2087        let _entry_id_str = entry_id.to_string();
2088        let _parent_id_str = parent_id.to_string();
2089
2090        // Get entries up to the branch point
2091        let _entries = self.get_entries();
2092        let path = self.get_branch(Some(&entry_id.to_string()));
2093
2094        let new_id = Uuid::new_v4();
2095        let new_entries: Vec<SessionEntry> = path
2096            .into_iter()
2097            .map(|e| {
2098                let mut new_entry = e.clone();
2099                new_entry.id = Uuid::new_v4().to_string();
2100                new_entry
2101            })
2102            .collect();
2103
2104        // Update the last entry to have parent reference
2105        // (simplified version of the original branch_from)
2106        Ok((new_id, new_entries))
2107    }
2108
2109    /// Get branch info for a session
2110    pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2111        // Simplified implementation
2112        Ok(None)
2113    }
2114
2115    /// Get tree for a specific session (backward compat)
2116    pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2117        self.get_tree(Uuid::nil())
2118    }
2119
2120    /// Save metadata (backward compat)
2121    pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2122        Ok(())
2123    }
2124
2125    /// Load metadata (backward compat)
2126    pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2127        Ok(None)
2128    }
2129
2130    /// Create a new session (backward compat)
2131    pub async fn create_session(&mut self) -> Result<SessionMeta> {
2132        let id = Uuid::new_v4();
2133        let meta = SessionMeta::new(id);
2134        Ok(meta)
2135    }
2136
2137    /// Fork from current session at a specific entry, creating a new session file. Synchronous.
2138    pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2139        let path = self
2140            .get_session_file()
2141            .ok_or_else(|| "No session file path".to_string())?;
2142        let source_entries = load_entries_from_file(&path);
2143        if source_entries.is_empty() {
2144            return Err("Cannot fork: source session is empty".to_string());
2145        }
2146        // Validate header exists (content will be replaced with fresh header below)
2147        let _header = source_entries
2148            .iter()
2149            .find_map(|e| match e {
2150                FileEntry::Header(h) => Some(h),
2151                _ => None,
2152            })
2153            .ok_or_else(|| "Missing session header".to_string())?;
2154        let new_id = Uuid::new_v4().to_string();
2155        let timestamp = chrono::Utc::now().to_rfc3339();
2156        let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2157        let short_id = &new_id[..8];
2158        let dir = std::path::Path::new(&path)
2159            .parent()
2160            .map(|p| p.to_string_lossy().into_owned())
2161            .unwrap_or_else(|| ".".to_string());
2162        let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2163        let mut found = false;
2164        let mut new_entries = vec![FileEntry::Header(SessionHeader {
2165            entry_type: "session".to_string(),
2166            version: Some(CURRENT_SESSION_VERSION),
2167            id: new_id.clone(),
2168            timestamp,
2169            cwd: self.get_cwd(),
2170            parent_session: Some(path),
2171        })];
2172        for file_entry in &source_entries {
2173            if let FileEntry::Entry(entry) = file_entry {
2174                let eid = match entry {
2175                    SessionEntryEnum::Message(m) => m.base.id.clone(),
2176                    SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2177                    SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2178                    SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2179                    SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2180                    SessionEntryEnum::Custom(m) => m.base.id.clone(),
2181                    SessionEntryEnum::Label(m) => m.base.id.clone(),
2182                    SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2183                    SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2184                };
2185                if eid == entry_id {
2186                    found = true;
2187                    // First entry in the fork: clear parent_id so the chain
2188                    // starts fresh in the new file (the old parent doesn't exist here).
2189                    let mut entry = entry.clone();
2190                    clear_entry_parent_id(&mut entry);
2191                    new_entries.push(FileEntry::Entry(entry));
2192                } else if found {
2193                    new_entries.push(FileEntry::Entry(entry.clone()));
2194                }
2195            }
2196        }
2197        if !found {
2198            return Err(format!("Entry not found: {}", entry_id));
2199        }
2200        let mut handle = std::fs::OpenOptions::new()
2201            .create(true)
2202            .truncate(true)
2203            .write(true)
2204            .open(&new_file)
2205            .map_err(|e| e.to_string())?;
2206        for entry in &new_entries {
2207            let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2208            writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2209        }
2210        Ok(new_file)
2211    }
2212}
2213
2214// ============================================================================
2215// Internal Conversion Functions
2216// ============================================================================
2217
2218/// Clear the parent_id of a session entry so it becomes a root entry.
2219/// Used by `branch_from_entry` to fix the parent chain in forked sessions.
2220fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2221    match entry {
2222        SessionEntryEnum::Message(m) => m.base.parent_id = None,
2223        SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2224        SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2225        SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2226        SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2227        SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2228        SessionEntryEnum::Label(m) => m.base.parent_id = None,
2229        SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2230        SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2231    }
2232}
2233
2234/// Convert internal enum to simple SessionEntry struct
2235fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2236    match entry {
2237        SessionEntryEnum::Message(m) => Some(SessionEntry {
2238            id: m.base.id.clone(),
2239            parent_id: m.base.parent_id.clone(),
2240            timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2241                .map(|dt| dt.timestamp_millis())
2242                .unwrap_or(0),
2243            message: m.message.clone(),
2244        }),
2245        _ => None, // For now, we only convert message entries to the simple struct
2246    }
2247}
2248
2249/// Convert simple SessionEntry to internal FileEntry for persistence
2250fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2251    let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2252        .map(|dt| dt.to_rfc3339())
2253        .unwrap_or_else(|| Utc::now().to_rfc3339());
2254
2255    SessionEntryEnum::Message(SessionMessageEntry {
2256        base: SessionEntryBase {
2257            entry_type: "message".to_string(),
2258            id: entry.id.clone(),
2259            parent_id: entry.parent_id.clone(),
2260            timestamp,
2261        },
2262        message: entry.message.clone(),
2263    })
2264}
2265
2266// ============================================================================
2267// Session Statistics
2268// ============================================================================
2269
2270/// Session Stats.
2271#[derive(Debug, Clone)]
2272pub struct SessionStats {
2273    /// The message count.
2274    pub message_count: i64,
2275    /// The user message count.
2276    pub user_message_count: i64,
2277    /// The assistant message count.
2278    pub assistant_message_count: i64,
2279    /// The total chars.
2280    pub total_chars: i64,
2281    /// The estimated tokens.
2282    pub estimated_tokens: i64,
2283}
2284
2285// ============================================================================
2286// NewSessionOptions
2287// ============================================================================
2288
2289/// New Session Options.
2290#[derive(Debug, Clone)]
2291pub struct NewSessionOptions {
2292    /// The id.
2293    pub id: Option<String>,
2294    /// The parent session.
2295    pub parent_session: Option<String>,
2296}
2297
2298// ============================================================================
2299// Helper Functions
2300// ============================================================================
2301
2302/// Get default session dir.
2303pub fn get_default_session_dir(cwd: &str) -> String {
2304    let agent_dir = get_agent_dir();
2305    let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2306    let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2307
2308    if !Path::new(&session_dir).exists() {
2309        let _ = fs::create_dir_all(&session_dir);
2310    }
2311
2312    session_dir
2313}
2314
2315fn get_agent_dir() -> String {
2316    dirs::home_dir()
2317        .map(|h| h.join(".oxi").to_string_lossy().to_string())
2318        .unwrap_or_else(|| ".oxi".to_string())
2319}
2320
2321fn get_sessions_dir() -> String {
2322    format!("{}/sessions", get_agent_dir())
2323}
2324
2325/// Load entries from a JSONL file
2326fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2327    if !Path::new(file_path).exists() {
2328        return Vec::new();
2329    }
2330
2331    let file = match File::open(file_path) {
2332        Ok(f) => f,
2333        Err(_) => return Vec::new(),
2334    };
2335
2336    let reader = BufReader::new(file);
2337    let mut entries = Vec::new();
2338
2339    for line in reader.lines() {
2340        let line = match line {
2341            Ok(l) => l,
2342            Err(_) => continue,
2343        };
2344        if line.trim().is_empty() {
2345            continue;
2346        }
2347        match serde_json::from_str::<FileEntry>(&line) {
2348            Ok(entry) => entries.push(entry),
2349            Err(_) => continue,
2350        }
2351    }
2352
2353    // Validate session header
2354    if entries.is_empty() {
2355        return entries;
2356    }
2357    let header = match &entries[0] {
2358        FileEntry::Header(h) => h,
2359        _ => return Vec::new(),
2360    };
2361    if header.entry_type != "session" || header.id.is_empty() {
2362        return Vec::new();
2363    }
2364
2365    entries
2366}
2367
2368/// Check if a file is a valid session file
2369fn is_valid_session_file(file_path: &str) -> bool {
2370    if let Ok(mut file) = File::open(file_path) {
2371        use std::io::Read;
2372        let mut buffer = vec![0u8; 512];
2373        if let Ok(bytes_read) = file.read(&mut buffer)
2374            && let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec())
2375            && let Some(first_line) = content.split('\n').next()
2376            && let Ok(header) = serde_json::from_str::<SessionHeader>(first_line)
2377        {
2378            return header.entry_type == "session" && !header.id.is_empty();
2379        }
2380    }
2381    false
2382}
2383
2384/// Find the path of the most recent session for the given working directory.
2385pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2386    let dir = get_default_session_dir(cwd);
2387    find_most_recent_session(&dir)
2388}
2389
2390fn find_most_recent_session(session_dir: &str) -> Option<String> {
2391    if !Path::new(session_dir).exists() {
2392        return None;
2393    }
2394
2395    let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2396
2397    if let Ok(entries) = fs::read_dir(session_dir) {
2398        for entry in entries.flatten() {
2399            let path = entry.path();
2400            if path.extension().map(|e| e == "jsonl").unwrap_or(false)
2401                && let Some(path_str) = path.to_str()
2402                && is_valid_session_file(path_str)
2403                && let Ok(metadata) = entry.metadata()
2404                && let Ok(mtime) = metadata.modified()
2405            {
2406                files.push((path_str.to_string(), mtime));
2407            }
2408        }
2409    }
2410
2411    files.sort_by_key(|b| std::cmp::Reverse(b.1));
2412    files.into_iter().next().map(|(p, _)| p)
2413}
2414
2415/// Resolve a session file path from user input, handling relative paths and ~.
2416pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2417    let path = input.trim();
2418    if path.is_empty() {
2419        return Err("Empty path".to_string());
2420    }
2421    let resolved = if let Some(rest) = path.strip_prefix('~') {
2422        if rest.is_empty() {
2423            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2424            home.to_string_lossy().into_owned()
2425        } else if let Some(rest) = rest.strip_prefix('/') {
2426            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2427            format!("{}/{}", home.to_string_lossy(), rest)
2428        } else {
2429            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2430            format!("{}/{}", home.to_string_lossy(), rest)
2431        }
2432    } else if path.starts_with('/') || path.contains(':') {
2433        path.to_string()
2434    } else {
2435        if let Some(stripped) = path.strip_prefix("./") {
2436            format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2437        } else {
2438            format!("{}/{}", cwd.trim_end_matches('/'), path)
2439        }
2440    };
2441    let p = std::path::Path::new(&resolved);
2442    p.canonicalize()
2443        .map(|c| c.to_string_lossy().into_owned())
2444        .or(Ok(resolved))
2445}
2446
2447/// Build session context from entries using tree traversal
2448fn build_session_context_internal(
2449    entries: &[SessionEntry],
2450    leaf_id: Option<String>,
2451    _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2452) -> SessionContext {
2453    // Find leaf
2454    let leaf: Option<&SessionEntry> = leaf_id
2455        .as_ref()
2456        .and_then(|id| entries.iter().find(|e| e.id == *id));
2457
2458    let leaf = leaf.or_else(|| entries.last());
2459
2460    let Some(leaf) = leaf else {
2461        return SessionContext {
2462            messages: Vec::new(),
2463            thinking_level: "off".to_string(),
2464            model: None,
2465        };
2466    };
2467
2468    // Walk from leaf to root, collecting path
2469    let mut path: Vec<&SessionEntry> = Vec::new();
2470    let mut current: Option<&SessionEntry> = Some(leaf);
2471    while let Some(entry) = current {
2472        path.insert(0, entry);
2473        current = entry
2474            .parent_id
2475            .as_ref()
2476            .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2477    }
2478
2479    // Extract settings
2480    let mut thinking_level = "off".to_string();
2481    let mut model: Option<ModelInfo> = None;
2482
2483    for entry in &path {
2484        if let AgentMessage::Assistant {
2485            provider, model_id, ..
2486        } = &entry.message
2487        {
2488            model = Some(ModelInfo {
2489                provider: provider.clone().unwrap_or_default(),
2490                model_id: model_id.clone().unwrap_or_default(),
2491            });
2492        }
2493        if let AgentMessage::Custom {
2494            custom_type,
2495            content,
2496            ..
2497        } = &entry.message
2498            && custom_type == "thinking_level_change"
2499        {
2500            thinking_level = content.as_str().to_string();
2501        }
2502    }
2503
2504    // Build messages - include all messages in the path
2505    let messages: Vec<AgentMessage> = path
2506        .iter()
2507        .filter(|e| {
2508            e.message.is_user()
2509                || e.message.is_assistant()
2510                || matches!(&e.message, AgentMessage::BranchSummary { .. })
2511                || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2512        })
2513        .map(|e| e.message.clone())
2514        .collect();
2515
2516    SessionContext {
2517        messages,
2518        thinking_level,
2519        model,
2520    }
2521}
2522
2523/// Sort tree nodes by timestamp
2524fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2525    nodes.sort_by_key(|a| a.entry.timestamp);
2526
2527    for node in nodes {
2528        sort_tree_by_timestamp(&mut node.children);
2529    }
2530}
2531
2532/// List sessions from a directory
2533async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2534    if !Path::new(dir).exists() {
2535        return Ok(Vec::new());
2536    }
2537
2538    let mut sessions = Vec::new();
2539
2540    let entries = fs::read_dir(dir)?;
2541    let files: Vec<String> = entries
2542        .filter_map(|e| e.ok())
2543        .filter(|e| {
2544            e.path()
2545                .extension()
2546                .map(|ext| ext == "jsonl")
2547                .unwrap_or(false)
2548        })
2549        .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2550        .collect();
2551
2552    for file in files {
2553        if let Some(info) = build_session_info(&file).await {
2554            sessions.push(info);
2555        }
2556    }
2557
2558    Ok(sessions)
2559}
2560
2561/// Build session info from a file
2562async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2563    let content = fs::read_to_string(file_path).ok()?;
2564    let entries = parse_session_entries(&content)?;
2565
2566    if entries.is_empty() {
2567        return None;
2568    }
2569
2570    let header = match &entries[0] {
2571        FileEntry::Header(h) => h,
2572        _ => return None,
2573    };
2574
2575    let stats = fs::metadata(file_path).ok()?;
2576    let mut message_count = 0i64;
2577    let mut first_message = String::new();
2578    let mut all_messages = Vec::new();
2579    let mut name: Option<String> = None;
2580
2581    for entry in &entries {
2582        if let FileEntry::Entry(e) = entry {
2583            // Check for session_info
2584            if let SessionEntryEnum::SessionInfo(si) = e {
2585                name = si
2586                    .name
2587                    .clone()
2588                    .map(|n| n.trim().to_string())
2589                    .filter(|n| !n.is_empty());
2590            }
2591            // Check for messages
2592            if let SessionEntryEnum::Message(m) = e {
2593                if m.message.is_user() {
2594                    message_count += 1;
2595                    let text = m.message.content();
2596                    if !text.is_empty() {
2597                        all_messages.push(text.clone());
2598                        if first_message.is_empty() {
2599                            first_message = text;
2600                        }
2601                    }
2602                } else if m.message.is_assistant() {
2603                    // Use assistant text as first_message fallback
2604                    if first_message.is_empty() {
2605                        let text = m.message.content();
2606                        if !text.is_empty() {
2607                            first_message = text;
2608                        }
2609                    }
2610                }
2611            }
2612        }
2613    }
2614
2615    // Skip sessions with no readable content at all — these are sessions
2616    // where the assistant returned empty content (e.g. only thinking blocks)
2617    // and the user never sent a message. Not useful for resuming.
2618    if first_message.is_empty() {
2619        return None;
2620    }
2621
2622    let cwd = header.cwd.clone();
2623    let parent_session_path = header.parent_session.clone();
2624    let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2625        .map(|dt| dt.with_timezone(&Utc))
2626        .unwrap_or_else(|_| Utc::now());
2627    let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2628
2629    Some(SessionInfo {
2630        path: file_path.to_string(),
2631        id: header.id.clone(),
2632        cwd,
2633        name,
2634        parent_session_path,
2635        created,
2636        modified,
2637        message_count,
2638        first_message: if first_message.is_empty() {
2639            "(no messages)".to_string()
2640        } else {
2641            first_message
2642        },
2643        all_messages_text: all_messages.join(" "),
2644    })
2645}
2646
2647/// Parse session entries from content
2648fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2649    let mut entries = Vec::new();
2650
2651    for line in content.trim().lines() {
2652        if line.trim().is_empty() {
2653            continue;
2654        }
2655        if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2656            entries.push(entry);
2657        }
2658    }
2659
2660    Some(entries)
2661}
2662
2663/// Get session modified date
2664fn get_session_modified_date(
2665    entries: &[FileEntry],
2666    header_timestamp: &str,
2667    stats: &std::fs::Metadata,
2668) -> DateTime<Utc> {
2669    let last_activity_time = get_last_activity_time(entries);
2670    if let Some(t) = last_activity_time
2671        && t > 0
2672    {
2673        return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2674    }
2675
2676    let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2677        .map(|dt| dt.timestamp_millis())
2678        .unwrap_or(-1);
2679
2680    if header_time > 0 {
2681        return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2682    }
2683
2684    if let Ok(mtime) = stats.modified() {
2685        return DateTime::from(mtime);
2686    }
2687
2688    Utc::now()
2689}
2690
2691/// Get last activity time from entries
2692fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2693    let mut last_activity: Option<i64> = None;
2694
2695    for entry in entries {
2696        let entry = match entry {
2697            FileEntry::Entry(e) => e,
2698            _ => continue,
2699        };
2700
2701        if let SessionEntryEnum::Message(m) = entry
2702            && (m.message.is_user() || m.message.is_assistant())
2703        {
2704            last_activity = Some(std::cmp::max(
2705                last_activity.unwrap_or(0),
2706                m.base.timestamp.parse().unwrap_or(0),
2707            ));
2708        }
2709    }
2710
2711    last_activity
2712}
2713
2714// ============================================================================
2715// Tests
2716// ============================================================================
2717
2718#[cfg(test)]
2719mod tests {
2720    use super::*;
2721
2722    #[test]
2723    fn test_session_creation() {
2724        let manager = SessionManager::in_memory("/tmp");
2725        assert!(!manager.get_session_id().is_empty());
2726        assert_eq!(manager.get_entries().len(), 0);
2727    }
2728
2729    #[test]
2730    fn test_append_message() {
2731        let mut manager = SessionManager::in_memory("/tmp");
2732        let id = manager.append_message(AgentMessage::User {
2733            content: ContentValue::String("Hello".to_string()),
2734        });
2735        assert!(!id.is_empty());
2736        assert_eq!(manager.get_entries().len(), 1);
2737        assert_eq!(manager.get_leaf_id(), Some(id));
2738    }
2739
2740    #[test]
2741    fn test_tree_traversal() {
2742        let mut manager = SessionManager::in_memory("/tmp");
2743        let id1 = manager.append_message(AgentMessage::User {
2744            content: ContentValue::String("Hello".to_string()),
2745        });
2746        let id2 = manager.append_message(AgentMessage::Assistant {
2747            content: vec![],
2748            provider: None,
2749            model_id: None,
2750            usage: None,
2751            stop_reason: None,
2752        });
2753
2754        // Get branch from root
2755        let branch = manager.get_branch(None);
2756        assert_eq!(branch.len(), 2);
2757
2758        // Get branch from specific entry
2759        let branch = manager.get_branch(Some(&id1));
2760        assert_eq!(branch.len(), 1);
2761
2762        // Get children
2763        let children = manager.get_children(&id1);
2764        assert_eq!(children.len(), 1);
2765
2766        // Get parent
2767        let parent = manager.get_parent(&id2);
2768        assert!(parent.is_some());
2769        assert_eq!(parent.unwrap().id, id1);
2770    }
2771
2772    #[test]
2773    fn test_branching() {
2774        let mut manager = SessionManager::in_memory("/tmp");
2775        let id1 = manager.append_message(AgentMessage::User {
2776            content: ContentValue::String("Hello".to_string()),
2777        });
2778        let _id2 = manager.append_message(AgentMessage::Assistant {
2779            content: vec![],
2780            provider: None,
2781            model_id: None,
2782            usage: None,
2783            stop_reason: None,
2784        });
2785        let _id3 = manager.append_message(AgentMessage::User {
2786            content: ContentValue::String("How are you?".to_string()),
2787        });
2788
2789        // Branch from first message
2790        manager.branch(&id1).unwrap();
2791        assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2792
2793        // Add new message on branch
2794        let id4 = manager.append_message(AgentMessage::Assistant {
2795            content: vec![],
2796            provider: None,
2797            model_id: None,
2798            usage: None,
2799            stop_reason: None,
2800        });
2801
2802        // Should have 4 entries total (3 original + 1 new branch)
2803        assert_eq!(manager.get_entries().len(), 4);
2804
2805        // Leaf should be the new message
2806        assert_eq!(manager.get_leaf_id(), Some(id4));
2807
2808        // Get tree - 1 root (id1), with 2 children (id2 and id4)
2809        let tree = manager.get_tree(Uuid::nil()).unwrap();
2810        assert_eq!(tree.len(), 1); // One root
2811        assert_eq!(tree[0].children.len(), 2); // id1 has 2 children: id2 and id4
2812    }
2813
2814    #[test]
2815    fn test_session_context() {
2816        let mut manager = SessionManager::in_memory("/tmp");
2817        manager.append_message(AgentMessage::User {
2818            content: ContentValue::String("Hello".to_string()),
2819        });
2820        manager.append_message(AgentMessage::Assistant {
2821            content: vec![AssistantContentBlock::Text {
2822                text: "Hi there!".to_string(),
2823            }],
2824            provider: Some("test".to_string()),
2825            model_id: Some("model".to_string()),
2826            usage: None,
2827            stop_reason: None,
2828        });
2829
2830        let context = manager.build_session_context();
2831        assert_eq!(context.messages.len(), 2);
2832        assert!(context.model.is_some());
2833    }
2834
2835    #[test]
2836    fn test_compaction_entry() {
2837        let mut manager = SessionManager::in_memory("/tmp");
2838        let id1 = manager.append_message(AgentMessage::User {
2839            content: ContentValue::String("First message".to_string()),
2840        });
2841        let _id2 = manager.append_message(AgentMessage::Assistant {
2842            content: vec![],
2843            provider: None,
2844            model_id: None,
2845            usage: None,
2846            stop_reason: None,
2847        });
2848
2849        let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2850        assert!(!id3.is_empty());
2851
2852        let latest = manager.get_latest_compaction_entry();
2853        assert!(latest.is_some());
2854    }
2855
2856    #[test]
2857    fn test_labels() {
2858        let mut manager = SessionManager::in_memory("/tmp");
2859        let id1 = manager.append_message(AgentMessage::User {
2860            content: ContentValue::String("Hello".to_string()),
2861        });
2862
2863        manager.add_label(&id1, "important").unwrap();
2864        assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2865
2866        manager.remove_label(&id1).unwrap();
2867        assert_eq!(manager.get_label(&id1), None);
2868    }
2869
2870    // ========================================================================
2871    // Session tree and branching tests
2872    // ========================================================================
2873
2874    /// Helper: create a user message
2875    fn user_msg(text: &str) -> AgentMessage {
2876        AgentMessage::User {
2877            content: ContentValue::String(text.to_string()),
2878        }
2879    }
2880
2881    /// Helper: create an assistant message
2882    fn assistant_msg(text: &str) -> AgentMessage {
2883        AgentMessage::Assistant {
2884            content: vec![AssistantContentBlock::Text {
2885                text: text.to_string(),
2886            }],
2887            provider: Some("anthropic".to_string()),
2888            model_id: Some("claude-test".to_string()),
2889            usage: None,
2890            stop_reason: None,
2891        }
2892    }
2893
2894    /// Helper: create a bare assistant message (no content/metadata)
2895    fn bare_assistant_msg() -> AgentMessage {
2896        AgentMessage::Assistant {
2897            content: vec![],
2898            provider: None,
2899            model_id: None,
2900            usage: None,
2901            stop_reason: None,
2902        }
2903    }
2904
2905    // ------------------------------------------------------------------------
2906    // append operations integration into tree
2907    // ------------------------------------------------------------------------
2908
2909    #[test]
2910    fn test_append_thinking_level_change_integrates() {
2911        let mut manager = SessionManager::in_memory("/tmp");
2912        let msg_id = manager.append_message(user_msg("hello"));
2913        let thinking_id = manager.append_thinking_level_change("high");
2914        let msg2_id = manager.append_message(assistant_msg("response"));
2915
2916        let entries = manager.get_entries();
2917        assert_eq!(entries.len(), 3);
2918
2919        // Thinking entry should be between the two messages
2920        let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2921        assert_eq!(thinking_entry.parent_id, Some(msg_id));
2922
2923        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2924        assert_eq!(msg2.parent_id, Some(thinking_id));
2925    }
2926
2927    #[test]
2928    fn test_append_model_change_integrates() {
2929        let mut manager = SessionManager::in_memory("/tmp");
2930        let msg_id = manager.append_message(user_msg("hello"));
2931        let model_id = manager.append_model_change("openai", "gpt-4");
2932        let msg2_id = manager.append_message(assistant_msg("response"));
2933
2934        let entries = manager.get_entries();
2935        let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2936        assert_eq!(model_entry.parent_id, Some(msg_id));
2937
2938        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2939        assert_eq!(msg2.parent_id, Some(model_id));
2940    }
2941
2942    #[test]
2943    fn test_append_compaction_integrates_into_tree() {
2944        let mut manager = SessionManager::in_memory("/tmp");
2945        let id1 = manager.append_message(user_msg("1"));
2946        let id2 = manager.append_message(assistant_msg("2"));
2947        let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2948        let id3 = manager.append_message(user_msg("3"));
2949
2950        let entries = manager.get_entries();
2951        let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2952        assert_eq!(compaction.parent_id, Some(id2));
2953
2954        let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2955        assert_eq!(msg3.parent_id, Some(compaction_id));
2956
2957        // Verify compaction content
2958        if let AgentMessage::CompactionSummary {
2959            summary,
2960            tokens_before,
2961            ..
2962        } = &compaction.message
2963        {
2964            assert_eq!(summary, "summary");
2965            assert_eq!(*tokens_before, 1000);
2966        } else {
2967            panic!("Expected CompactionSummary");
2968        }
2969    }
2970
2971    #[test]
2972    fn test_leaf_pointer_advances() {
2973        let mut manager = SessionManager::in_memory("/tmp");
2974        assert!(manager.get_leaf_id().is_none());
2975
2976        let id1 = manager.append_message(user_msg("1"));
2977        assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2978
2979        let id2 = manager.append_message(assistant_msg("2"));
2980        assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2981
2982        let id3 = manager.append_thinking_level_change("high");
2983        assert_eq!(manager.get_leaf_id(), Some(id3));
2984    }
2985
2986    #[test]
2987    fn test_get_entry() {
2988        let mut manager = SessionManager::in_memory("/tmp");
2989        assert!(manager.get_entry("nonexistent").is_none());
2990
2991        let id1 = manager.append_message(user_msg("first"));
2992        let id2 = manager.append_message(assistant_msg("second"));
2993
2994        let entry1 = manager.get_entry(&id1);
2995        assert!(entry1.is_some());
2996        assert!(entry1.unwrap().message.is_user());
2997
2998        let entry2 = manager.get_entry(&id2);
2999        assert!(entry2.is_some());
3000        assert!(entry2.unwrap().message.is_assistant());
3001    }
3002
3003    #[test]
3004    fn test_get_leaf_entry() {
3005        let manager = SessionManager::in_memory("/tmp");
3006        assert!(manager.get_leaf_entry().is_none());
3007
3008        let mut manager = SessionManager::in_memory("/tmp");
3009        manager.append_message(user_msg("1"));
3010        let id2 = manager.append_message(assistant_msg("2"));
3011
3012        let leaf = manager.get_leaf_entry();
3013        assert!(leaf.is_some());
3014        assert_eq!(leaf.unwrap().id, id2);
3015    }
3016
3017    // ------------------------------------------------------------------------
3018    // getBranch / getPath
3019    // ------------------------------------------------------------------------
3020
3021    #[test]
3022    fn test_get_branch_full_path_root_to_leaf() {
3023        let mut manager = SessionManager::in_memory("/tmp");
3024        let id1 = manager.append_message(user_msg("1"));
3025        let id2 = manager.append_message(assistant_msg("2"));
3026        let id3 = manager.append_thinking_level_change("high");
3027        let id4 = manager.append_message(user_msg("3"));
3028
3029        let branch = manager.get_branch(None);
3030        assert_eq!(branch.len(), 4);
3031        assert_eq!(branch[0].id, id1);
3032        assert_eq!(branch[1].id, id2);
3033        assert_eq!(branch[2].id, id3);
3034        assert_eq!(branch[3].id, id4);
3035    }
3036
3037    #[test]
3038    fn test_get_branch_from_specific_entry() {
3039        let mut manager = SessionManager::in_memory("/tmp");
3040        let id1 = manager.append_message(user_msg("1"));
3041        let id2 = manager.append_message(assistant_msg("2"));
3042        manager.append_message(user_msg("3"));
3043        manager.append_message(assistant_msg("4"));
3044
3045        let branch = manager.get_branch(Some(&id2));
3046        assert_eq!(branch.len(), 2);
3047        assert_eq!(branch[0].id, id1);
3048        assert_eq!(branch[1].id, id2);
3049    }
3050
3051    // ------------------------------------------------------------------------
3052    // Multiple branches at same point (3 siblings)
3053    // ------------------------------------------------------------------------
3054
3055    #[test]
3056    fn test_multiple_branches_at_same_point() {
3057        let mut manager = SessionManager::in_memory("/tmp");
3058        manager.append_message(user_msg("root"));
3059        let id2 = manager.append_message(bare_assistant_msg());
3060
3061        // Branch A
3062        manager.branch(&id2).unwrap();
3063        let id_a = manager.append_message(user_msg("branch-A"));
3064
3065        // Branch B
3066        manager.branch(&id2).unwrap();
3067        let id_b = manager.append_message(user_msg("branch-B"));
3068
3069        // Branch C
3070        manager.branch(&id2).unwrap();
3071        let id_c = manager.append_message(user_msg("branch-C"));
3072
3073        let tree = manager.get_tree(Uuid::nil()).unwrap();
3074        let node2 = &tree[0].children[0];
3075        assert_eq!(node2.entry.id, id2);
3076        assert_eq!(node2.children.len(), 3);
3077
3078        let mut branch_ids: Vec<String> =
3079            node2.children.iter().map(|c| c.entry.id.clone()).collect();
3080        branch_ids.sort();
3081        let mut expected = vec![id_a, id_b, id_c];
3082        expected.sort();
3083        assert_eq!(branch_ids, expected);
3084    }
3085
3086    // ------------------------------------------------------------------------
3087    // Deep branching
3088    // ------------------------------------------------------------------------
3089
3090    #[test]
3091    fn test_deep_branching() {
3092        let mut manager = SessionManager::in_memory("/tmp");
3093
3094        // Main path: 1 -> 2 -> 3 -> 4
3095        manager.append_message(user_msg("1"));
3096        let id2 = manager.append_message(bare_assistant_msg());
3097        let id3 = manager.append_message(user_msg("3"));
3098        manager.append_message(bare_assistant_msg());
3099
3100        // Branch from 2: 2 -> 5 -> 6
3101        manager.branch(&id2).unwrap();
3102        let id5 = manager.append_message(user_msg("5"));
3103        manager.append_message(bare_assistant_msg());
3104
3105        // Branch from 5: 5 -> 7
3106        manager.branch(&id5).unwrap();
3107        manager.append_message(user_msg("7"));
3108
3109        let tree = manager.get_tree(Uuid::nil()).unwrap();
3110
3111        // node2 has 2 children: id3 and id5
3112        let node2 = &tree[0].children[0];
3113        assert_eq!(node2.children.len(), 2);
3114
3115        let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3116        assert_eq!(node5.children.len(), 2); // id6 and id7
3117
3118        let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3119        assert_eq!(node3.children.len(), 1); // id4
3120    }
3121
3122    // ------------------------------------------------------------------------
3123    // branch_with_summary
3124    // ------------------------------------------------------------------------
3125
3126    #[test]
3127    fn test_branch_with_summary_inserts_and_advances() {
3128        let mut manager = SessionManager::in_memory("/tmp");
3129        let id1 = manager.append_message(user_msg("1"));
3130        manager.append_message(bare_assistant_msg());
3131        manager.append_message(user_msg("3"));
3132
3133        let summary_id =
3134            manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3135        assert!(!summary_id.is_empty());
3136        assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3137
3138        // Verify branch_summary entry
3139        let entries = manager.get_entries();
3140        let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3141        assert_eq!(summary_entry.parent_id, Some(id1));
3142
3143        if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3144            assert_eq!(summary, "Summary of abandoned work");
3145        } else {
3146            panic!("Expected BranchSummary");
3147        }
3148    }
3149
3150    // ------------------------------------------------------------------------
3151    // build_session_context with branches
3152    // ------------------------------------------------------------------------
3153
3154    #[test]
3155    fn test_build_session_context_returns_branch_messages() {
3156        let mut manager = SessionManager::in_memory("/tmp");
3157
3158        // Main: 1 -> 2 -> 3
3159        manager.append_message(user_msg("msg1"));
3160        let id2 = manager.append_message(bare_assistant_msg());
3161        manager.append_message(user_msg("msg3"));
3162
3163        // Branch from 2: 2 -> 4
3164        manager.branch(&id2).unwrap();
3165        manager.append_message(assistant_msg("msg4-branch"));
3166
3167        let ctx = manager.build_session_context();
3168        // Should have msg1, msg2, msg4-branch (NOT msg3)
3169        assert_eq!(ctx.messages.len(), 3);
3170        assert!(ctx.messages[0].is_user());
3171        assert!(ctx.messages[1].is_assistant());
3172        assert!(ctx.messages[2].is_assistant());
3173    }
3174
3175    #[test]
3176    fn test_build_session_context_follows_branch_path() {
3177        // Tree: 1 -> 2 -> 3 (branch A)
3178        //             \-> 4 (branch B)
3179        let mut manager = SessionManager::in_memory("/tmp");
3180        manager.append_message(user_msg("start"));
3181        let id2 = manager.append_message(bare_assistant_msg());
3182        manager.append_message(user_msg("branch A"));
3183
3184        // Switch to branch B
3185        manager.branch(&id2).unwrap();
3186        manager.append_message(user_msg("branch B"));
3187
3188        let ctx = manager.build_session_context();
3189        assert_eq!(ctx.messages.len(), 3);
3190        // Last message should be "branch B"
3191        let last = ctx.messages.last().unwrap();
3192        assert_eq!(last.content(), "branch B");
3193    }
3194
3195    #[test]
3196    fn test_build_session_context_includes_branch_summary() {
3197        let mut manager = SessionManager::in_memory("/tmp");
3198        manager.append_message(user_msg("start"));
3199        let id2 = manager.append_message(bare_assistant_msg());
3200        manager.append_message(user_msg("abandoned path"));
3201
3202        // Branch with summary
3203        manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3204        manager.append_message(user_msg("new direction"));
3205
3206        let ctx = manager.build_session_context();
3207        // Should include: start, response, branch_summary, new direction
3208        assert!(ctx.messages.len() >= 3);
3209
3210        // Branch summary should be in messages
3211        let has_summary = ctx.messages.iter().any(|m| {
3212            if let AgentMessage::BranchSummary { summary, .. } = m {
3213                summary == "Summary of abandoned work"
3214            } else {
3215                false
3216            }
3217        });
3218        assert!(has_summary, "Branch summary should be in context messages");
3219    }
3220
3221    #[test]
3222    fn test_build_session_context_with_compaction() {
3223        let mut manager = SessionManager::in_memory("/tmp");
3224
3225        // Build conversation
3226        let id1 = manager.append_message(user_msg("first"));
3227        manager.append_message(assistant_msg("response1"));
3228        manager.append_message(user_msg("second"));
3229        manager.append_message(assistant_msg("response2"));
3230
3231        // Add compaction
3232        manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3233
3234        // Continue after compaction
3235        manager.append_message(user_msg("third"));
3236        manager.append_message(assistant_msg("response3"));
3237
3238        let ctx = manager.build_session_context();
3239        // CompactionSummary is NOT included in context messages (only user/assistant/branch_summary)
3240        // but the path from leaf should include all entries
3241        assert!(ctx.messages.len() >= 4); // at minimum: user, assistant, user, assistant from after-compaction path
3242
3243        // Compaction entry should exist in the entries
3244        let compaction_entries = manager.get_compaction_entries();
3245        assert_eq!(compaction_entries.len(), 1);
3246    }
3247
3248    #[test]
3249    fn test_build_session_context_tracks_thinking_level() {
3250        let mut manager = SessionManager::in_memory("/tmp");
3251        manager.append_message(user_msg("hello"));
3252        manager.append_thinking_level_change("high");
3253        manager.append_message(assistant_msg("thinking hard"));
3254
3255        let ctx = manager.build_session_context();
3256        assert_eq!(ctx.thinking_level, "high");
3257    }
3258
3259    // ------------------------------------------------------------------------
3260    // Labels in tree nodes
3261    // ------------------------------------------------------------------------
3262
3263    #[test]
3264    fn test_labels_in_tree_nodes() {
3265        let mut manager = SessionManager::in_memory("/tmp");
3266        let id1 = manager.append_message(user_msg("hello"));
3267        let id2 = manager.append_message(assistant_msg("hi"));
3268
3269        manager.add_label(&id1, "start").unwrap();
3270        manager.add_label(&id2, "response").unwrap();
3271
3272        let tree = manager.get_tree(Uuid::nil()).unwrap();
3273        let node1 = &tree[0];
3274        assert_eq!(node1.label, Some("start".to_string()));
3275
3276        let node2 = &node1.children[0];
3277        assert_eq!(node2.label, Some("response".to_string()));
3278    }
3279
3280    #[test]
3281    fn test_last_label_wins() {
3282        let mut manager = SessionManager::in_memory("/tmp");
3283        let id1 = manager.append_message(user_msg("hello"));
3284
3285        manager.add_label(&id1, "first").unwrap();
3286        manager.add_label(&id1, "second").unwrap();
3287        manager.add_label(&id1, "third").unwrap();
3288
3289        assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3290    }
3291
3292    // ------------------------------------------------------------------------
3293    // branch throws for non-existent
3294    // ------------------------------------------------------------------------
3295
3296    #[test]
3297    fn test_branch_throws_for_nonexistent() {
3298        let mut manager = SessionManager::in_memory("/tmp");
3299        manager.append_message(user_msg("hello"));
3300
3301        let result = manager.branch("nonexistent");
3302        assert!(result.is_err());
3303    }
3304
3305    // ------------------------------------------------------------------------
3306    // Labels not included in buildSessionContext
3307    // ------------------------------------------------------------------------
3308
3309    #[test]
3310    fn test_labels_not_in_session_context() {
3311        let mut manager = SessionManager::in_memory("/tmp");
3312        let msg_id = manager.append_message(user_msg("hello"));
3313        manager.add_label(&msg_id, "checkpoint").unwrap();
3314
3315        let ctx = manager.build_session_context();
3316        // Should only have the user message, not label entries
3317        assert_eq!(ctx.messages.len(), 1);
3318        assert!(ctx.messages[0].is_user());
3319    }
3320
3321    // ------------------------------------------------------------------------
3322    // appendCustomEntry integration
3323    // ------------------------------------------------------------------------
3324
3325    #[test]
3326    fn test_custom_entry_integrates_into_tree() {
3327        let mut manager = SessionManager::in_memory("/tmp");
3328        let msg_id = manager.append_message(user_msg("hello"));
3329        let custom_id =
3330            manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3331        let msg2_id = manager.append_message(assistant_msg("response"));
3332
3333        let entries = manager.get_entries();
3334        let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3335        assert_eq!(custom.parent_id, Some(msg_id));
3336
3337        if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3338            assert_eq!(custom_type, "my_data");
3339        } else {
3340            panic!("Expected Custom message");
3341        }
3342
3343        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3344        assert_eq!(msg2.parent_id, Some(custom_id));
3345
3346        // buildSessionContext should work (custom entries skipped in messages)
3347        let ctx = manager.build_session_context();
3348        // Only the 2 real messages; custom entry is not user/assistant/branch_summary
3349        assert_eq!(ctx.messages.len(), 2);
3350    }
3351
3352    // ------------------------------------------------------------------------
3353    // Empty session edge cases
3354    // ------------------------------------------------------------------------
3355
3356    #[test]
3357    fn test_get_branch_empty_session() {
3358        let manager = SessionManager::in_memory("/tmp");
3359        let branch = manager.get_branch(None);
3360        assert!(branch.is_empty());
3361    }
3362
3363    #[test]
3364    fn test_get_tree_empty_session() {
3365        let manager = SessionManager::in_memory("/tmp");
3366        let tree = manager.get_tree(Uuid::nil()).unwrap();
3367        assert!(tree.is_empty());
3368    }
3369
3370    // ------------------------------------------------------------------------
3371    // Complex tree with branches and compaction
3372    // ------------------------------------------------------------------------
3373
3374    #[test]
3375    fn test_complex_tree_with_branches_and_compaction() {
3376        let mut manager = SessionManager::in_memory("/tmp");
3377
3378        // Main path: 1 -> 2 -> 3 -> 4 -> compaction(5) -> 6 -> 7
3379        manager.append_message(user_msg("start"));
3380        manager.append_message(assistant_msg("r1"));
3381        let id3 = manager.append_message(user_msg("q2"));
3382        manager.append_message(assistant_msg("r2"));
3383        manager.append_compaction("Compacted history", &id3, 1000, None, None);
3384        manager.append_message(user_msg("q3"));
3385        manager.append_message(assistant_msg("r3"));
3386
3387        // Abandoned branch from 3
3388        manager.branch(&id3).unwrap();
3389        manager.append_message(user_msg("wrong path"));
3390        manager.append_message(assistant_msg("wrong response"));
3391
3392        // Branch summary resuming from 3
3393        manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3394        manager.append_message(user_msg("better approach"));
3395
3396        let tree = manager.get_tree(Uuid::nil()).unwrap();
3397        // Root node
3398        assert_eq!(tree.len(), 1);
3399
3400        // Walk tree to verify structure
3401        let root = &tree[0];
3402        assert!(root.entry.message.is_user());
3403    }
3404
3405    // ------------------------------------------------------------------------
3406    // get_latest_compaction_entry returns the most recent
3407    // ------------------------------------------------------------------------
3408
3409    #[test]
3410    fn test_multiple_compactions_returns_latest() {
3411        let mut manager = SessionManager::in_memory("/tmp");
3412        let id1 = manager.append_message(user_msg("a"));
3413        manager.append_message(bare_assistant_msg());
3414        manager.append_compaction("First summary", &id1, 1000, None, None);
3415        manager.append_message(user_msg("c"));
3416        manager.append_message(bare_assistant_msg());
3417        manager.append_compaction("Second summary", &id1, 2000, None, None);
3418
3419        // get_compaction_entries returns all compaction entries
3420        let compactions = manager.get_compaction_entries();
3421        assert_eq!(compactions.len(), 2);
3422
3423        // At least one should exist with the second summary
3424        let latest = manager.get_latest_compaction_entry();
3425        assert!(latest.is_some());
3426    }
3427
3428    // ------------------------------------------------------------------------
3429    // get_compaction_entries returns all
3430    // ------------------------------------------------------------------------
3431
3432    #[test]
3433    fn test_get_all_compaction_entries() {
3434        let mut manager = SessionManager::in_memory("/tmp");
3435        let id1 = manager.append_message(user_msg("a"));
3436        manager.append_message(bare_assistant_msg());
3437        manager.append_compaction("First", &id1, 1000, None, None);
3438        manager.append_message(user_msg("b"));
3439        manager.append_message(bare_assistant_msg());
3440        manager.append_compaction("Second", &id1, 2000, None, None);
3441
3442        let compactions = manager.get_compaction_entries();
3443        assert_eq!(compactions.len(), 2);
3444    }
3445}