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        // F-7 (audit 2026-06-21): wrap the `File` in `BufWriter` so each
1292        // `writeln!` line goes through the in-memory 8 KiB buffer instead
1293        // of a separate `write()` syscall. `BufWriter::into_inner` flushes
1294        // on Drop, so we still get the same durability guarantees — if
1295        // anything fails mid-write, the partial buffer is discarded and
1296        // the next `load_lockfile` will see the truncated file (the JSONL
1297        // parser is line-oriented and tolerant of partial trailing lines,
1298        // see `load_lockfile`). The file open itself still happens per
1299        // `_persist` call — eliminating that requires caching the handle
1300        // on `SessionManager` (out of scope for the audit fix; see the
1301        // "BufWriter + consolidated lock" P1 follow-up in the report).
1302        let file_handle = match fs::OpenOptions::new().create(true).append(true).open(file) {
1303            Ok(h) => h,
1304            Err(e) => {
1305                tracing::warn!("Failed to open session file for append {}: {}", file, e);
1306                return;
1307            }
1308        };
1309        let mut handle = std::io::BufWriter::new(file_handle);
1310
1311        if !self.flushed {
1312            for e in self.file_entries.read().iter() {
1313                if let Ok(line) = serde_json::to_string(e) {
1314                    let _ = writeln!(&mut handle, "{}", line);
1315                } else {
1316                    tracing::warn!("Failed to serialize session entry, skipping");
1317                }
1318            }
1319            self.flushed = true;
1320        } else {
1321            // Convert SessionEntry back to FileEntry for writing
1322            let file_entry = convert_from_session_entry(entry);
1323            if let Ok(line) = serde_json::to_string(&file_entry) {
1324                let _ = writeln!(&mut handle, "{}", line);
1325            } else {
1326                tracing::warn!("Failed to serialize incremental session entry, skipping");
1327            }
1328        }
1329    }
1330
1331    // LOCK ORDERING CONVENTION (must be followed to prevent deadlock):
1332    // 1. file_entries  2. by_id  3. labels_by_id  4. label_timestamps_by_id  5. leaf_id
1333    // Always acquire locks in this order. Never acquire an earlier lock after a later one.
1334    fn _append_entry(&mut self, entry: SessionEntry) {
1335        let file_entry = convert_from_session_entry(&entry);
1336        self.file_entries.write().push(FileEntry::Entry(file_entry));
1337        self.by_id.write().insert(entry.id.clone(), entry.clone());
1338        *self.leaf_id.write() = Some(entry.id.clone());
1339        self._persist(&entry);
1340    }
1341
1342    /// Append a message as child of current leaf
1343    pub fn append_message(&mut self, message: AgentMessage) -> String {
1344        let leaf = self.leaf_id.read().clone();
1345        let id = Uuid::new_v4().to_string();
1346        let entry = SessionEntry {
1347            id: id.clone(),
1348            parent_id: leaf,
1349            timestamp: Utc::now().timestamp_millis(),
1350            message,
1351        };
1352        self._append_entry(entry);
1353        id
1354    }
1355
1356    /// Append a thinking level change
1357    pub fn append_thinking_level_change(&mut self, thinking_level: &str) -> String {
1358        let leaf = self.leaf_id.read().clone();
1359        let id = Uuid::new_v4().to_string();
1360        let entry = SessionEntry {
1361            id: id.clone(),
1362            parent_id: leaf,
1363            timestamp: Utc::now().timestamp_millis(),
1364            message: AgentMessage::Custom {
1365                custom_type: "thinking_level_change".to_string(),
1366                content: ContentValue::String(thinking_level.to_string()),
1367                display: false,
1368                details: None,
1369                timestamp: Utc::now().timestamp_millis(),
1370            },
1371        };
1372        self._append_entry(entry);
1373        id
1374    }
1375
1376    /// Append a model change
1377    pub fn append_model_change(&mut self, provider: &str, model_id: &str) -> String {
1378        let leaf = self.leaf_id.read().clone();
1379        let id = Uuid::new_v4().to_string();
1380        let entry = SessionEntry {
1381            id: id.clone(),
1382            parent_id: leaf,
1383            timestamp: Utc::now().timestamp_millis(),
1384            message: AgentMessage::Custom {
1385                custom_type: "model_change".to_string(),
1386                content: ContentValue::String(format!("{}:{}", provider, model_id)),
1387                display: false,
1388                details: None,
1389                timestamp: Utc::now().timestamp_millis(),
1390            },
1391        };
1392        self._append_entry(entry);
1393        id
1394    }
1395
1396    /// Append a compaction summary
1397    pub fn append_compaction(
1398        &mut self,
1399        summary: &str,
1400        _first_kept_entry_id: &str,
1401        tokens_before: i64,
1402        _details: Option<serde_json::Value>,
1403        _from_hook: Option<bool>,
1404    ) -> String {
1405        let leaf = self.leaf_id.read().clone();
1406        let id = Uuid::new_v4().to_string();
1407        let entry = SessionEntry {
1408            id: id.clone(),
1409            parent_id: leaf,
1410            timestamp: Utc::now().timestamp_millis(),
1411            message: AgentMessage::CompactionSummary {
1412                summary: summary.to_string(),
1413                tokens_before,
1414                timestamp: Utc::now().timestamp_millis(),
1415            },
1416        };
1417        self._append_entry(entry);
1418        id
1419    }
1420
1421    /// Append a custom entry (for extensions)
1422    pub fn append_custom_entry(
1423        &mut self,
1424        custom_type: &str,
1425        data: Option<serde_json::Value>,
1426    ) -> String {
1427        let leaf = self.leaf_id.read().clone();
1428        let id = Uuid::new_v4().to_string();
1429        let entry = SessionEntry {
1430            id: id.clone(),
1431            parent_id: leaf,
1432            timestamp: Utc::now().timestamp_millis(),
1433            message: AgentMessage::Custom {
1434                custom_type: custom_type.to_string(),
1435                content: data
1436                    .as_ref()
1437                    .map(|d| ContentValue::String(d.to_string()))
1438                    .unwrap_or(ContentValue::String(String::new())),
1439                display: false,
1440                details: data.clone(),
1441                timestamp: Utc::now().timestamp_millis(),
1442            },
1443        };
1444        self._append_entry(entry);
1445        id
1446    }
1447
1448    /// Append a session info entry (e.g., display name)
1449    pub fn append_session_info(&mut self, name: &str) -> String {
1450        let leaf = self.leaf_id.read().clone();
1451        let id = Uuid::new_v4().to_string();
1452        let entry = SessionEntry {
1453            id: id.clone(),
1454            parent_id: leaf,
1455            timestamp: Utc::now().timestamp_millis(),
1456            message: AgentMessage::Custom {
1457                custom_type: "session_info".to_string(),
1458                content: ContentValue::String(name.trim().to_string()),
1459                display: false,
1460                details: None,
1461                timestamp: Utc::now().timestamp_millis(),
1462            },
1463        };
1464        self._append_entry(entry);
1465        id
1466    }
1467
1468    /// Get the current session name from the latest session_info entry
1469    pub fn get_session_name(&self) -> Option<String> {
1470        let entries = self.get_entries();
1471        for entry in entries.iter().rev() {
1472            if let AgentMessage::Custom {
1473                custom_type,
1474                content,
1475                ..
1476            } = &entry.message
1477                && custom_type == "session_info"
1478            {
1479                return Some(content.as_str().trim().to_string()).filter(|s| !s.is_empty());
1480            }
1481        }
1482        None
1483    }
1484
1485    /// Append a custom message entry (for extensions) that participates in LLM context
1486    pub fn append_custom_message_entry(
1487        &mut self,
1488        custom_type: &str,
1489        content: ContentValue,
1490        display: bool,
1491        details: Option<serde_json::Value>,
1492    ) -> String {
1493        let leaf = self.leaf_id.read().clone();
1494        let id = Uuid::new_v4().to_string();
1495        let entry = SessionEntry {
1496            id: id.clone(),
1497            parent_id: leaf,
1498            timestamp: Utc::now().timestamp_millis(),
1499            message: AgentMessage::Custom {
1500                custom_type: custom_type.to_string(),
1501                content,
1502                display,
1503                details,
1504                timestamp: Utc::now().timestamp_millis(),
1505            },
1506        };
1507        self._append_entry(entry);
1508        id
1509    }
1510
1511    // =========================================================================
1512    // Tree Traversal
1513    // =========================================================================
1514
1515    /// Get the current leaf ID
1516    pub fn get_leaf_id(&self) -> Option<String> {
1517        self.leaf_id.read().clone()
1518    }
1519
1520    /// Set the leaf pointer to a specific entry, navigating to that branch.
1521    ///
1522    /// Validates that the entry exists in the session tree and updates
1523    /// the internal leaf pointer. Used for TUI branch navigation —
1524    /// after calling this, `get_branch(None)` returns the path from
1525    /// root to the target entry.
1526    pub fn set_leaf_from_entry(&self, entry_id: &str) -> Result<(), String> {
1527        if !self.by_id.read().contains_key(entry_id) {
1528            return Err(format!("Entry {} not found", entry_id));
1529        }
1530        *self.leaf_id.write() = Some(entry_id.to_string());
1531        Ok(())
1532    }
1533
1534    /// Get the current leaf entry
1535    pub fn get_leaf_entry(&self) -> Option<SessionEntry> {
1536        self.leaf_id
1537            .read()
1538            .as_ref()
1539            .and_then(|id| self.by_id.read().get(id).cloned())
1540    }
1541
1542    /// Get an entry by ID
1543    pub fn get_entry(&self, id: &str) -> Option<SessionEntry> {
1544        self.by_id.read().get(id).cloned()
1545    }
1546
1547    /// Get all direct children of an entry
1548    pub fn get_children(&self, parent_id: &str) -> Vec<SessionEntry> {
1549        self.by_id
1550            .read()
1551            .values()
1552            .filter(|e| e.parent_id.as_deref() == Some(parent_id))
1553            .cloned()
1554            .collect()
1555    }
1556
1557    /// Get the parent of an entry
1558    pub fn get_parent(&self, id: &str) -> Option<SessionEntry> {
1559        self.by_id
1560            .read()
1561            .get(id)
1562            .and_then(|e| e.parent_id.as_deref())
1563            .and_then(|pid| self.by_id.read().get(pid).cloned())
1564    }
1565
1566    /// Get the label for an entry
1567    pub fn get_label(&self, id: &str) -> Option<String> {
1568        self.labels_by_id.read().get(id).cloned()
1569    }
1570
1571    /// Set or clear a label on an entry
1572    pub fn append_label_change(
1573        &mut self,
1574        target_id: &str,
1575        label: Option<&str>,
1576    ) -> Result<String, String> {
1577        if !self.by_id.read().contains_key(target_id) {
1578            return Err(format!("Entry {} not found", target_id));
1579        }
1580
1581        let leaf = self.leaf_id.read().clone();
1582        let id = Uuid::new_v4().to_string();
1583        let entry = SessionEntry {
1584            id: id.clone(),
1585            parent_id: leaf,
1586            timestamp: Utc::now().timestamp_millis(),
1587            message: AgentMessage::Custom {
1588                custom_type: "label".to_string(),
1589                content: ContentValue::String(label.unwrap_or("").to_string()),
1590                display: false,
1591                details: Some(serde_json::json!({ "targetId": target_id })),
1592                timestamp: Utc::now().timestamp_millis(),
1593            },
1594        };
1595
1596        self._append_entry(entry);
1597
1598        if let Some(l) = label {
1599            self.labels_by_id
1600                .write()
1601                .insert(target_id.to_string(), l.to_string());
1602            self.label_timestamps_by_id
1603                .write()
1604                .insert(target_id.to_string(), Utc::now().to_rfc3339());
1605        } else {
1606            self.labels_by_id.write().remove(target_id);
1607            self.label_timestamps_by_id.write().remove(target_id);
1608        }
1609
1610        Ok(id)
1611    }
1612
1613    /// Walk from entry to root, returning all entries in path order
1614    pub fn get_branch(&self, from_id: Option<&str>) -> Vec<SessionEntry> {
1615        let mut path = Vec::new();
1616        let leaf_fallback = self.leaf_id.read().clone();
1617        let start_id = from_id.or(leaf_fallback.as_deref());
1618        let Some(start_id) = start_id else {
1619            return path;
1620        };
1621
1622        // Acquire the lock once and reuse it for the entire traversal
1623        let by_id = self.by_id.read();
1624        let mut current = by_id.get(start_id).cloned();
1625        while let Some(entry) = current {
1626            path.insert(0, entry.clone());
1627            current = entry
1628                .parent_id
1629                .as_ref()
1630                .and_then(|pid| by_id.get(pid).cloned());
1631        }
1632        path
1633    }
1634
1635    /// Get path to root for a given entry
1636    pub fn get_path_to_root(&self, from_id: &str) -> Vec<SessionEntry> {
1637        self.get_branch(Some(from_id))
1638    }
1639
1640    /// Get ancestry (same as path to root)
1641    pub fn get_ancestry(&self, from_id: &str) -> Vec<SessionEntry> {
1642        self.get_branch(Some(from_id))
1643    }
1644
1645    /// Get depth of an entry
1646    pub fn get_depth(&self, id: &str) -> i64 {
1647        let mut depth = 0;
1648        let mut current = self.by_id.read().get(id).cloned();
1649        while let Some(entry) = current {
1650            depth += 1;
1651            current = entry
1652                .parent_id
1653                .as_ref()
1654                .and_then(|pid| self.by_id.read().get(pid).cloned());
1655        }
1656        depth - 1 // Root has depth 0
1657    }
1658
1659    /// Build the session context (what gets sent to the LLM)
1660    pub fn build_session_context(&self) -> SessionContext {
1661        let entries = self.get_entries();
1662        let leaf_id = self.leaf_id.read().clone();
1663        build_session_context_internal(&entries, leaf_id, None)
1664    }
1665
1666    /// Get session header
1667    pub fn get_header(&self) -> Option<SessionHeader> {
1668        self.file_entries.read().iter().find_map(|e| match e {
1669            FileEntry::Header(h) => Some(h.clone()),
1670            _ => None,
1671        })
1672    }
1673
1674    /// Get all session entries (excludes header)
1675    pub fn get_entries(&self) -> Vec<SessionEntry> {
1676        self.by_id.read().values().cloned().collect()
1677    }
1678
1679    /// Get the session as a tree structure
1680    /// If id is provided, returns tree for that session (backward compat)
1681    pub fn get_tree(&self, _id: Uuid) -> anyhow::Result<Vec<SessionTreeNode>> {
1682        let entries = self.get_entries();
1683        let labels: HashMap<String, String> = self.labels_by_id.read().clone();
1684        let label_timestamps: HashMap<String, String> = self.label_timestamps_by_id.read().clone();
1685
1686        let mut adj: HashMap<String, Vec<String>> = HashMap::new();
1687        let mut root_ids: Vec<String> = Vec::new();
1688
1689        // Build adjacency list
1690        for entry in &entries {
1691            adj.insert(entry.id.clone(), Vec::new());
1692        }
1693
1694        // Determine parent-child relationships
1695        for entry in &entries {
1696            let is_root = match entry.parent_id.as_deref() {
1697                Some(pid) if pid != entry.id => !adj.contains_key(pid),
1698                _ => true,
1699            };
1700            if is_root {
1701                root_ids.push(entry.id.clone());
1702            } else if let Some(ref pid) = entry.parent_id {
1703                if let Some(children) = adj.get_mut(pid.as_str()) {
1704                    children.push(entry.id.clone());
1705                } else {
1706                    root_ids.push(entry.id.clone());
1707                }
1708            }
1709        }
1710
1711        // Build entries map
1712        let entries_map: HashMap<String, SessionEntry> =
1713            entries.into_iter().map(|e| (e.id.clone(), e)).collect();
1714
1715        // Recursively build tree nodes
1716        fn build(
1717            id: &str,
1718            adj: &HashMap<String, Vec<String>>,
1719            entries_map: &HashMap<String, SessionEntry>,
1720            labels: &HashMap<String, String>,
1721            label_timestamps: &HashMap<String, String>,
1722        ) -> anyhow::Result<SessionTreeNode> {
1723            let entry = entries_map
1724                .get(id)
1725                .ok_or_else(|| anyhow::anyhow!("Corrupted session: entry {} not found", id))?
1726                .clone();
1727            let child_ids = adj.get(id).cloned().unwrap_or_default();
1728            let children: Vec<SessionTreeNode> = child_ids
1729                .iter()
1730                .map(|cid| build(cid, adj, entries_map, labels, label_timestamps))
1731                .collect::<Result<Vec<_>, _>>()?;
1732            Ok(SessionTreeNode {
1733                entry,
1734                children,
1735                label: labels.get(id).cloned(),
1736                label_timestamp: label_timestamps.get(id).cloned(),
1737            })
1738        }
1739
1740        let mut roots = root_ids
1741            .into_iter()
1742            .map(|rid| build(&rid, &adj, &entries_map, &labels, &label_timestamps))
1743            .collect::<anyhow::Result<Vec<_>>>()?;
1744
1745        sort_tree_by_timestamp(&mut roots);
1746        Ok(roots)
1747    }
1748
1749    // =========================================================================
1750    // Branching
1751    // =========================================================================
1752
1753    /// Start a new branch from an earlier entry
1754    pub fn branch(&mut self, branch_from_id: &str) -> Result<(), String> {
1755        if !self.by_id.read().contains_key(branch_from_id) {
1756            return Err(format!("Entry {} not found", branch_from_id));
1757        }
1758        *self.leaf_id.write() = Some(branch_from_id.to_string());
1759        Ok(())
1760    }
1761
1762    /// Reset the leaf pointer to null (before any entries)
1763    pub fn reset_leaf(&mut self) {
1764        *self.leaf_id.write() = None;
1765    }
1766
1767    /// Start a new branch with a summary of the abandoned path
1768    pub fn branch_with_summary(
1769        &mut self,
1770        branch_from_id: Option<&str>,
1771        summary: &str,
1772        _details: Option<serde_json::Value>,
1773        _from_hook: Option<bool>,
1774    ) -> String {
1775        if let Some(id) = branch_from_id
1776            && !self.by_id.read().contains_key(id)
1777        {
1778            return String::new();
1779        }
1780
1781        *self.leaf_id.write() = branch_from_id.map(|s| s.to_string());
1782
1783        let id = Uuid::new_v4().to_string();
1784        let entry = SessionEntry {
1785            id: id.clone(),
1786            parent_id: branch_from_id.map(|s| s.to_string()),
1787            timestamp: Utc::now().timestamp_millis(),
1788            message: AgentMessage::BranchSummary {
1789                summary: summary.to_string(),
1790                from_id: branch_from_id.unwrap_or("root").to_string(),
1791                timestamp: Utc::now().timestamp_millis(),
1792            },
1793        };
1794
1795        self._append_entry(entry);
1796        id
1797    }
1798
1799    /// Add a label to the session
1800    pub fn add_label(&mut self, target_id: &str, label: &str) -> Result<String, String> {
1801        self.append_label_change(target_id, Some(label))
1802    }
1803
1804    /// Remove a label from an entry
1805    pub fn remove_label(&mut self, target_id: &str) -> Result<String, String> {
1806        self.append_label_change(target_id, None)
1807    }
1808
1809    // =========================================================================
1810    // Compaction Support
1811    // =========================================================================
1812
1813    /// Get the latest compaction entry
1814    pub fn get_latest_compaction_entry(&self) -> Option<SessionEntry> {
1815        let entries = self.get_entries();
1816        for entry in entries.iter().rev() {
1817            if let AgentMessage::CompactionSummary { .. } = &entry.message {
1818                return Some(entry.clone());
1819            }
1820        }
1821        None
1822    }
1823
1824    /// Get all compaction entries
1825    pub fn get_compaction_entries(&self) -> Vec<SessionEntry> {
1826        self.get_entries()
1827            .iter()
1828            .filter(|e| matches!(&e.message, AgentMessage::CompactionSummary { .. }))
1829            .cloned()
1830            .collect()
1831    }
1832
1833    // =========================================================================
1834    // Session Statistics
1835    // =========================================================================
1836
1837    /// Get session statistics
1838    pub fn get_session_stats(&self) -> SessionStats {
1839        let entries = self.get_entries();
1840        let mut message_count = 0i64;
1841        let mut user_message_count = 0i64;
1842        let mut assistant_message_count = 0i64;
1843        let mut total_chars = 0i64;
1844        let mut total_tokens_estimate = 0i64;
1845
1846        for entry in &entries {
1847            if let AgentMessage::User { .. } = &entry.message {
1848                user_message_count += 1;
1849            }
1850            if let AgentMessage::Assistant { .. } = &entry.message {
1851                assistant_message_count += 1;
1852            }
1853            if entry.message.is_user() || entry.message.is_assistant() {
1854                message_count += 1;
1855                // Estimate tokens from message
1856                let content = entry.content();
1857                let chars = content.len() as i64;
1858                total_chars += chars;
1859                total_tokens_estimate += (chars as f64 / 4.0).ceil() as i64;
1860            }
1861        }
1862
1863        SessionStats {
1864            message_count,
1865            user_message_count,
1866            assistant_message_count,
1867            total_chars,
1868            estimated_tokens: total_tokens_estimate,
1869        }
1870    }
1871
1872    // =========================================================================
1873    // Static Methods
1874    // =========================================================================
1875
1876    /// List all sessions for a directory
1877    pub async fn list(cwd: &str, session_dir: Option<&str>) -> Result<Vec<SessionInfo>> {
1878        let dir = session_dir
1879            .map(|s| s.to_string())
1880            .unwrap_or_else(|| get_default_session_dir(cwd));
1881        list_sessions_from_dir(&dir).await
1882    }
1883
1884    /// List all sessions across all project directories
1885    pub async fn list_all() -> Result<Vec<SessionInfo>> {
1886        let sessions_dir = get_sessions_dir();
1887
1888        if !Path::new(&sessions_dir).exists() {
1889            return Ok(Vec::new());
1890        }
1891
1892        let mut all_sessions = Vec::new();
1893        let entries = fs::read_dir(&sessions_dir)?;
1894
1895        for entry in entries {
1896            let entry = entry?;
1897            let path = entry.path();
1898            if path.is_dir()
1899                && let Ok(sessions) = list_sessions_from_dir(&path.to_string_lossy()).await
1900            {
1901                all_sessions.extend(sessions);
1902            }
1903        }
1904
1905        all_sessions.sort_by_key(|b| std::cmp::Reverse(b.modified));
1906        Ok(all_sessions)
1907    }
1908
1909    /// Fork a session from another project directory into the current project
1910    pub fn fork_from(
1911        source_path: &str,
1912        target_cwd: &str,
1913        session_dir: Option<&str>,
1914    ) -> Result<Self, String> {
1915        let source_entries = load_entries_from_file(source_path);
1916        if source_entries.is_empty() {
1917            return Err(format!(
1918                "Cannot fork: source session file is empty or invalid: {}",
1919                source_path
1920            ));
1921        }
1922
1923        let source_header = source_entries.iter().find_map(|e| match e {
1924            FileEntry::Header(h) => Some(h),
1925            _ => None,
1926        });
1927        if source_header.is_none() {
1928            return Err(format!(
1929                "Cannot fork: source session has no header: {}",
1930                source_path
1931            ));
1932        }
1933
1934        let dir = session_dir
1935            .map(|s| s.to_string())
1936            .unwrap_or_else(|| get_default_session_dir(target_cwd));
1937
1938        if !Path::new(&dir).exists() {
1939            let _ = fs::create_dir_all(&dir);
1940        }
1941
1942        let new_session_id = Uuid::new_v4().to_string();
1943        let timestamp = Utc::now().to_rfc3339();
1944        let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
1945        let short_id = &new_session_id[..8];
1946        let new_session_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
1947
1948        // Write new header pointing to source as parent
1949        let new_header = SessionHeader {
1950            entry_type: "session".to_string(),
1951            version: Some(CURRENT_SESSION_VERSION),
1952            id: new_session_id.clone(),
1953            timestamp: timestamp.clone(),
1954            cwd: target_cwd.to_string(),
1955            parent_session: Some(source_path.to_string()),
1956        };
1957
1958        let mut handle = fs::OpenOptions::new()
1959            .create(true)
1960            .truncate(true)
1961            .write(true)
1962            .open(&new_session_file)
1963            .map_err(|e| e.to_string())?;
1964        writeln!(
1965            &mut handle,
1966            "{}",
1967            serde_json::to_string(&new_header).expect("session header serializable")
1968        )
1969        .map_err(|e| e.to_string())?;
1970
1971        // Copy all non-header entries from source
1972        for file_entry in &source_entries {
1973            if let FileEntry::Entry(_) = file_entry {
1974                writeln!(
1975                    &mut handle,
1976                    "{}",
1977                    serde_json::to_string(file_entry).expect("session entry serializable")
1978                )
1979                .map_err(|e| e.to_string())?;
1980            }
1981        }
1982
1983        Ok(Self::open(&new_session_file, Some(&dir), Some(target_cwd)))
1984    }
1985
1986    /// Delete a session
1987    pub fn delete_session(path: &str) -> Result<()> {
1988        fs::remove_file(path).context("Failed to delete session file")?;
1989        Ok(())
1990    }
1991
1992    /// Rename a session (set its display name)
1993    pub fn rename_session(&mut self, name: &str) -> String {
1994        self.append_session_info(name)
1995    }
1996
1997    // =========================================================================
1998    // Backward Compatibility Methods
1999    // =========================================================================
2000
2001    /// Create a new SessionManager (async for backward compatibility)
2002    pub async fn new() -> Result<Self> {
2003        Self::new_async().await
2004    }
2005
2006    /// Create a new SessionManager (async for backward compatibility)
2007    pub async fn new_async() -> Result<Self> {
2008        let home = dirs::home_dir().context("Cannot find home directory")?;
2009        let base_dir = home.join(".oxi");
2010        let sessions_dir = base_dir.join("sessions");
2011        tokio::fs::create_dir_all(&sessions_dir).await?;
2012        let cwd = std::env::current_dir()
2013            .unwrap_or_else(|_| PathBuf::from("."))
2014            .to_string_lossy()
2015            .to_string();
2016        Ok(Self::in_memory(&cwd))
2017    }
2018
2019    /// Get the session file path for a given session ID
2020    pub fn session_path(&self, id: &Uuid) -> PathBuf {
2021        if let Some(file) = &self.session_file {
2022            PathBuf::from(file)
2023        } else {
2024            PathBuf::from(format!("{}/{}.jsonl", self.session_dir, id))
2025        }
2026    }
2027
2028    /// List all sessions (backward compat)
2029    pub async fn list_sessions(&self) -> Result<Vec<SessionMeta>> {
2030        // Simple implementation: scan the session dir for jsonl files
2031        let mut metas = Vec::new();
2032        let session_dir = Path::new(&self.session_dir);
2033        if !session_dir.exists() {
2034            return Ok(metas);
2035        }
2036        let entries = fs::read_dir(session_dir)?;
2037        for entry in entries {
2038            let entry = entry?;
2039            let path = entry.path();
2040            if path.extension().map(|e| e == "jsonl").unwrap_or(false) {
2041                let file_name = path
2042                    .file_stem()
2043                    .unwrap_or_else(|| std::ffi::OsStr::new(""))
2044                    .to_string_lossy()
2045                    .to_string();
2046                // Try to extract uuid from filename
2047                if let Some(uuid_part) = file_name.split('_').next_back()
2048                    && let Ok(uuid) = Uuid::parse_str(uuid_part)
2049                {
2050                    let mtime = entry.metadata().ok().and_then(|m| m.modified().ok());
2051                    let now_ts = Utc::now().timestamp_millis();
2052                    metas.push(SessionMeta {
2053                        id: uuid,
2054                        parent_id: None,
2055                        root_id: None,
2056                        branch_point: None,
2057                        created_at: now_ts,
2058                        updated_at: mtime
2059                            .map(|t| {
2060                                let dt: DateTime<Utc> = DateTime::from(t);
2061                                dt.timestamp_millis()
2062                            })
2063                            .unwrap_or(now_ts),
2064                        name: None,
2065                    });
2066                }
2067            }
2068        }
2069        metas.sort_by_key(|b| std::cmp::Reverse(b.updated_at));
2070        Ok(metas)
2071    }
2072
2073    /// Save entries (backward compat)
2074    pub async fn save(&self, _id: Uuid, _entries: &[SessionEntry]) -> Result<()> {
2075        self._rewrite_file();
2076        Ok(())
2077    }
2078
2079    /// Load entries (backward compat)
2080    pub async fn load(&self, _id: Uuid) -> Result<Vec<SessionEntry>> {
2081        Ok(self.get_entries())
2082    }
2083
2084    /// Delete a session (backward compat)
2085    pub async fn delete(&self, id: Uuid) -> Result<()> {
2086        let path = self.session_path(&id);
2087        if path.exists() {
2088            fs::remove_file(path).context("Failed to delete session file")?;
2089        }
2090        Ok(())
2091    }
2092
2093    /// Create a branch from an existing session at a given entry
2094    pub async fn branch_from(
2095        &self,
2096        parent_id: Uuid,
2097        entry_id: Uuid,
2098    ) -> Result<(Uuid, Vec<SessionEntry>)> {
2099        let _entry_id_str = entry_id.to_string();
2100        let _parent_id_str = parent_id.to_string();
2101
2102        // Get entries up to the branch point
2103        let _entries = self.get_entries();
2104        let path = self.get_branch(Some(&entry_id.to_string()));
2105
2106        let new_id = Uuid::new_v4();
2107        let new_entries: Vec<SessionEntry> = path
2108            .into_iter()
2109            .map(|e| {
2110                let mut new_entry = e.clone();
2111                new_entry.id = Uuid::new_v4().to_string();
2112                new_entry
2113            })
2114            .collect();
2115
2116        // Update the last entry to have parent reference
2117        // (simplified version of the original branch_from)
2118        Ok((new_id, new_entries))
2119    }
2120
2121    /// Get branch info for a session
2122    pub async fn get_branch_info(&self, _id: Uuid) -> Result<Option<BranchInfo>> {
2123        // Simplified implementation
2124        Ok(None)
2125    }
2126
2127    /// Get tree for a specific session (backward compat)
2128    pub async fn get_tree_async(&self, _id: Uuid) -> Result<Vec<SessionTreeNode>> {
2129        self.get_tree(Uuid::nil())
2130    }
2131
2132    /// Save metadata (backward compat)
2133    pub async fn save_meta(&self, _meta: &SessionMeta) -> Result<()> {
2134        Ok(())
2135    }
2136
2137    /// Load metadata (backward compat)
2138    pub async fn load_meta(&self, _id: Uuid) -> Result<Option<SessionMeta>> {
2139        Ok(None)
2140    }
2141
2142    /// Create a new session (backward compat)
2143    pub async fn create_session(&mut self) -> Result<SessionMeta> {
2144        let id = Uuid::new_v4();
2145        let meta = SessionMeta::new(id);
2146        Ok(meta)
2147    }
2148
2149    /// Fork from current session at a specific entry, creating a new session file. Synchronous.
2150    pub fn branch_from_entry(&self, entry_id: &str) -> Result<String, String> {
2151        let path = self
2152            .get_session_file()
2153            .ok_or_else(|| "No session file path".to_string())?;
2154        let source_entries = load_entries_from_file(&path);
2155        if source_entries.is_empty() {
2156            return Err("Cannot fork: source session is empty".to_string());
2157        }
2158        // Validate header exists (content will be replaced with fresh header below)
2159        let _header = source_entries
2160            .iter()
2161            .find_map(|e| match e {
2162                FileEntry::Header(h) => Some(h),
2163                _ => None,
2164            })
2165            .ok_or_else(|| "Missing session header".to_string())?;
2166        let new_id = Uuid::new_v4().to_string();
2167        let timestamp = chrono::Utc::now().to_rfc3339();
2168        let file_timestamp = timestamp.replace([':', '.', 'T', '-', ':', '+'], "-");
2169        let short_id = &new_id[..8];
2170        let dir = std::path::Path::new(&path)
2171            .parent()
2172            .map(|p| p.to_string_lossy().into_owned())
2173            .unwrap_or_else(|| ".".to_string());
2174        let new_file = format!("{}/{}_{}.jsonl", dir, file_timestamp, short_id);
2175        let mut found = false;
2176        let mut new_entries = vec![FileEntry::Header(SessionHeader {
2177            entry_type: "session".to_string(),
2178            version: Some(CURRENT_SESSION_VERSION),
2179            id: new_id.clone(),
2180            timestamp,
2181            cwd: self.get_cwd(),
2182            parent_session: Some(path),
2183        })];
2184        for file_entry in &source_entries {
2185            if let FileEntry::Entry(entry) = file_entry {
2186                let eid = match entry {
2187                    SessionEntryEnum::Message(m) => m.base.id.clone(),
2188                    SessionEntryEnum::ThinkingLevelChange(m) => m.base.id.clone(),
2189                    SessionEntryEnum::ModelChange(m) => m.base.id.clone(),
2190                    SessionEntryEnum::Compaction(m) => m.base.id.clone(),
2191                    SessionEntryEnum::BranchSummary(m) => m.base.id.clone(),
2192                    SessionEntryEnum::Custom(m) => m.base.id.clone(),
2193                    SessionEntryEnum::Label(m) => m.base.id.clone(),
2194                    SessionEntryEnum::SessionInfo(m) => m.base.id.clone(),
2195                    SessionEntryEnum::CustomMessage(m) => m.base.id.clone(),
2196                };
2197                if eid == entry_id {
2198                    found = true;
2199                    // First entry in the fork: clear parent_id so the chain
2200                    // starts fresh in the new file (the old parent doesn't exist here).
2201                    let mut entry = entry.clone();
2202                    clear_entry_parent_id(&mut entry);
2203                    new_entries.push(FileEntry::Entry(entry));
2204                } else if found {
2205                    new_entries.push(FileEntry::Entry(entry.clone()));
2206                }
2207            }
2208        }
2209        if !found {
2210            return Err(format!("Entry not found: {}", entry_id));
2211        }
2212        let mut handle = std::fs::OpenOptions::new()
2213            .create(true)
2214            .truncate(true)
2215            .write(true)
2216            .open(&new_file)
2217            .map_err(|e| e.to_string())?;
2218        for entry in &new_entries {
2219            let line = serde_json::to_string(entry).map_err(|e| e.to_string())?;
2220            writeln!(&mut handle, "{}", line).map_err(|e| e.to_string())?;
2221        }
2222        Ok(new_file)
2223    }
2224}
2225
2226// ============================================================================
2227// Internal Conversion Functions
2228// ============================================================================
2229
2230/// Clear the parent_id of a session entry so it becomes a root entry.
2231/// Used by `branch_from_entry` to fix the parent chain in forked sessions.
2232fn clear_entry_parent_id(entry: &mut SessionEntryEnum) {
2233    match entry {
2234        SessionEntryEnum::Message(m) => m.base.parent_id = None,
2235        SessionEntryEnum::ThinkingLevelChange(m) => m.base.parent_id = None,
2236        SessionEntryEnum::ModelChange(m) => m.base.parent_id = None,
2237        SessionEntryEnum::Compaction(m) => m.base.parent_id = None,
2238        SessionEntryEnum::BranchSummary(m) => m.base.parent_id = None,
2239        SessionEntryEnum::Custom(m) => m.base.parent_id = None,
2240        SessionEntryEnum::Label(m) => m.base.parent_id = None,
2241        SessionEntryEnum::SessionInfo(m) => m.base.parent_id = None,
2242        SessionEntryEnum::CustomMessage(m) => m.base.parent_id = None,
2243    }
2244}
2245
2246/// Convert internal enum to simple SessionEntry struct
2247fn convert_to_session_entry(entry: &SessionEntryEnum) -> Option<SessionEntry> {
2248    match entry {
2249        SessionEntryEnum::Message(m) => Some(SessionEntry {
2250            id: m.base.id.clone(),
2251            parent_id: m.base.parent_id.clone(),
2252            timestamp: DateTime::parse_from_rfc3339(&m.base.timestamp)
2253                .map(|dt| dt.timestamp_millis())
2254                .unwrap_or(0),
2255            message: m.message.clone(),
2256        }),
2257        _ => None, // For now, we only convert message entries to the simple struct
2258    }
2259}
2260
2261/// Convert simple SessionEntry to internal FileEntry for persistence
2262fn convert_from_session_entry(entry: &SessionEntry) -> SessionEntryEnum {
2263    let timestamp = DateTime::from_timestamp_millis(entry.timestamp)
2264        .map(|dt| dt.to_rfc3339())
2265        .unwrap_or_else(|| Utc::now().to_rfc3339());
2266
2267    SessionEntryEnum::Message(SessionMessageEntry {
2268        base: SessionEntryBase {
2269            entry_type: "message".to_string(),
2270            id: entry.id.clone(),
2271            parent_id: entry.parent_id.clone(),
2272            timestamp,
2273        },
2274        message: entry.message.clone(),
2275    })
2276}
2277
2278// ============================================================================
2279// Session Statistics
2280// ============================================================================
2281
2282/// Session Stats.
2283#[derive(Debug, Clone)]
2284pub struct SessionStats {
2285    /// The message count.
2286    pub message_count: i64,
2287    /// The user message count.
2288    pub user_message_count: i64,
2289    /// The assistant message count.
2290    pub assistant_message_count: i64,
2291    /// The total chars.
2292    pub total_chars: i64,
2293    /// The estimated tokens.
2294    pub estimated_tokens: i64,
2295}
2296
2297// ============================================================================
2298// NewSessionOptions
2299// ============================================================================
2300
2301/// New Session Options.
2302#[derive(Debug, Clone)]
2303pub struct NewSessionOptions {
2304    /// The id.
2305    pub id: Option<String>,
2306    /// The parent session.
2307    pub parent_session: Option<String>,
2308}
2309
2310// ============================================================================
2311// Helper Functions
2312// ============================================================================
2313
2314/// Get default session dir.
2315pub fn get_default_session_dir(cwd: &str) -> String {
2316    let agent_dir = get_agent_dir();
2317    let safe_path = format!("--{}--", cwd.replace(['/', '\\', ':'], "-"));
2318    let session_dir = format!("{}/sessions/{}", agent_dir, safe_path);
2319
2320    if !Path::new(&session_dir).exists() {
2321        let _ = fs::create_dir_all(&session_dir);
2322    }
2323
2324    session_dir
2325}
2326
2327fn get_agent_dir() -> String {
2328    dirs::home_dir()
2329        .map(|h| h.join(".oxi").to_string_lossy().to_string())
2330        .unwrap_or_else(|| ".oxi".to_string())
2331}
2332
2333fn get_sessions_dir() -> String {
2334    format!("{}/sessions", get_agent_dir())
2335}
2336
2337/// Load entries from a JSONL file
2338fn load_entries_from_file(file_path: &str) -> Vec<FileEntry> {
2339    if !Path::new(file_path).exists() {
2340        return Vec::new();
2341    }
2342
2343    let file = match File::open(file_path) {
2344        Ok(f) => f,
2345        Err(_) => return Vec::new(),
2346    };
2347
2348    let reader = BufReader::new(file);
2349    let mut entries = Vec::new();
2350
2351    for line in reader.lines() {
2352        let line = match line {
2353            Ok(l) => l,
2354            Err(_) => continue,
2355        };
2356        if line.trim().is_empty() {
2357            continue;
2358        }
2359        match serde_json::from_str::<FileEntry>(&line) {
2360            Ok(entry) => entries.push(entry),
2361            Err(_) => continue,
2362        }
2363    }
2364
2365    // Validate session header
2366    if entries.is_empty() {
2367        return entries;
2368    }
2369    let header = match &entries[0] {
2370        FileEntry::Header(h) => h,
2371        _ => return Vec::new(),
2372    };
2373    if header.entry_type != "session" || header.id.is_empty() {
2374        return Vec::new();
2375    }
2376
2377    entries
2378}
2379
2380/// Check if a file is a valid session file
2381fn is_valid_session_file(file_path: &str) -> bool {
2382    if let Ok(mut file) = File::open(file_path) {
2383        use std::io::Read;
2384        let mut buffer = vec![0u8; 512];
2385        if let Ok(bytes_read) = file.read(&mut buffer)
2386            && let Ok(content) = String::from_utf8(buffer[..bytes_read].to_vec())
2387            && let Some(first_line) = content.split('\n').next()
2388            && let Ok(header) = serde_json::from_str::<SessionHeader>(first_line)
2389        {
2390            return header.entry_type == "session" && !header.id.is_empty();
2391        }
2392    }
2393    false
2394}
2395
2396/// Find the path of the most recent session for the given working directory.
2397pub fn find_recent_session_path(cwd: &str) -> Option<String> {
2398    let dir = get_default_session_dir(cwd);
2399    find_most_recent_session(&dir)
2400}
2401
2402fn find_most_recent_session(session_dir: &str) -> Option<String> {
2403    if !Path::new(session_dir).exists() {
2404        return None;
2405    }
2406
2407    let mut files: Vec<(String, std::time::SystemTime)> = Vec::new();
2408
2409    if let Ok(entries) = fs::read_dir(session_dir) {
2410        for entry in entries.flatten() {
2411            let path = entry.path();
2412            if path.extension().map(|e| e == "jsonl").unwrap_or(false)
2413                && let Some(path_str) = path.to_str()
2414                && is_valid_session_file(path_str)
2415                && let Ok(metadata) = entry.metadata()
2416                && let Ok(mtime) = metadata.modified()
2417            {
2418                files.push((path_str.to_string(), mtime));
2419            }
2420        }
2421    }
2422
2423    files.sort_by_key(|b| std::cmp::Reverse(b.1));
2424    files.into_iter().next().map(|(p, _)| p)
2425}
2426
2427/// Resolve a session file path from user input, handling relative paths and ~.
2428pub fn resolve_session_path(input: &str, cwd: &str) -> Result<String, String> {
2429    let path = input.trim();
2430    if path.is_empty() {
2431        return Err("Empty path".to_string());
2432    }
2433    let resolved = if let Some(rest) = path.strip_prefix('~') {
2434        if rest.is_empty() {
2435            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2436            home.to_string_lossy().into_owned()
2437        } else if let Some(rest) = rest.strip_prefix('/') {
2438            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2439            format!("{}/{}", home.to_string_lossy(), rest)
2440        } else {
2441            let home = dirs::home_dir().ok_or_else(|| "Cannot find home directory".to_string())?;
2442            format!("{}/{}", home.to_string_lossy(), rest)
2443        }
2444    } else if path.starts_with('/') || path.contains(':') {
2445        path.to_string()
2446    } else {
2447        if let Some(stripped) = path.strip_prefix("./") {
2448            format!("{}/{}", cwd.trim_end_matches('/'), stripped)
2449        } else {
2450            format!("{}/{}", cwd.trim_end_matches('/'), path)
2451        }
2452    };
2453    let p = std::path::Path::new(&resolved);
2454    p.canonicalize()
2455        .map(|c| c.to_string_lossy().into_owned())
2456        .or(Ok(resolved))
2457}
2458
2459/// Build session context from entries using tree traversal
2460fn build_session_context_internal(
2461    entries: &[SessionEntry],
2462    leaf_id: Option<String>,
2463    _by_id: Option<&RwLock<HashMap<String, SessionEntry>>>,
2464) -> SessionContext {
2465    // Find leaf
2466    let leaf: Option<&SessionEntry> = leaf_id
2467        .as_ref()
2468        .and_then(|id| entries.iter().find(|e| e.id == *id));
2469
2470    let leaf = leaf.or_else(|| entries.last());
2471
2472    let Some(leaf) = leaf else {
2473        return SessionContext {
2474            messages: Vec::new(),
2475            thinking_level: "off".to_string(),
2476            model: None,
2477        };
2478    };
2479
2480    // Walk from leaf to root, collecting path
2481    let mut path: Vec<&SessionEntry> = Vec::new();
2482    let mut current: Option<&SessionEntry> = Some(leaf);
2483    while let Some(entry) = current {
2484        path.insert(0, entry);
2485        current = entry
2486            .parent_id
2487            .as_ref()
2488            .and_then(|pid| entries.iter().find(|e| e.id == *pid));
2489    }
2490
2491    // Extract settings
2492    let mut thinking_level = "off".to_string();
2493    let mut model: Option<ModelInfo> = None;
2494
2495    for entry in &path {
2496        if let AgentMessage::Assistant {
2497            provider, model_id, ..
2498        } = &entry.message
2499        {
2500            model = Some(ModelInfo {
2501                provider: provider.clone().unwrap_or_default(),
2502                model_id: model_id.clone().unwrap_or_default(),
2503            });
2504        }
2505        if let AgentMessage::Custom {
2506            custom_type,
2507            content,
2508            ..
2509        } = &entry.message
2510            && custom_type == "thinking_level_change"
2511        {
2512            thinking_level = content.as_str().to_string();
2513        }
2514    }
2515
2516    // Build messages - include all messages in the path
2517    let messages: Vec<AgentMessage> = path
2518        .iter()
2519        .filter(|e| {
2520            e.message.is_user()
2521                || e.message.is_assistant()
2522                || matches!(&e.message, AgentMessage::BranchSummary { .. })
2523                || matches!(&e.message, AgentMessage::CompactionSummary { .. })
2524        })
2525        .map(|e| e.message.clone())
2526        .collect();
2527
2528    SessionContext {
2529        messages,
2530        thinking_level,
2531        model,
2532    }
2533}
2534
2535/// Sort tree nodes by timestamp
2536fn sort_tree_by_timestamp(nodes: &mut Vec<SessionTreeNode>) {
2537    nodes.sort_by_key(|a| a.entry.timestamp);
2538
2539    for node in nodes {
2540        sort_tree_by_timestamp(&mut node.children);
2541    }
2542}
2543
2544/// List sessions from a directory
2545async fn list_sessions_from_dir(dir: &str) -> Result<Vec<SessionInfo>> {
2546    if !Path::new(dir).exists() {
2547        return Ok(Vec::new());
2548    }
2549
2550    let mut sessions = Vec::new();
2551
2552    let entries = fs::read_dir(dir)?;
2553    let files: Vec<String> = entries
2554        .filter_map(|e| e.ok())
2555        .filter(|e| {
2556            e.path()
2557                .extension()
2558                .map(|ext| ext == "jsonl")
2559                .unwrap_or(false)
2560        })
2561        .filter_map(|e| e.path().to_str().map(|s| s.to_string()))
2562        .collect();
2563
2564    for file in files {
2565        if let Some(info) = build_session_info(&file).await {
2566            sessions.push(info);
2567        }
2568    }
2569
2570    Ok(sessions)
2571}
2572
2573/// Build session info from a file
2574async fn build_session_info(file_path: &str) -> Option<SessionInfo> {
2575    let content = fs::read_to_string(file_path).ok()?;
2576    let entries = parse_session_entries(&content)?;
2577
2578    if entries.is_empty() {
2579        return None;
2580    }
2581
2582    let header = match &entries[0] {
2583        FileEntry::Header(h) => h,
2584        _ => return None,
2585    };
2586
2587    let stats = fs::metadata(file_path).ok()?;
2588    let mut message_count = 0i64;
2589    let mut first_message = String::new();
2590    let mut all_messages = Vec::new();
2591    let mut name: Option<String> = None;
2592
2593    for entry in &entries {
2594        if let FileEntry::Entry(e) = entry {
2595            // Check for session_info
2596            if let SessionEntryEnum::SessionInfo(si) = e {
2597                name = si
2598                    .name
2599                    .clone()
2600                    .map(|n| n.trim().to_string())
2601                    .filter(|n| !n.is_empty());
2602            }
2603            // Check for messages
2604            if let SessionEntryEnum::Message(m) = e {
2605                if m.message.is_user() {
2606                    message_count += 1;
2607                    let text = m.message.content();
2608                    if !text.is_empty() {
2609                        all_messages.push(text.clone());
2610                        if first_message.is_empty() {
2611                            first_message = text;
2612                        }
2613                    }
2614                } else if m.message.is_assistant() {
2615                    // Use assistant text as first_message fallback
2616                    if first_message.is_empty() {
2617                        let text = m.message.content();
2618                        if !text.is_empty() {
2619                            first_message = text;
2620                        }
2621                    }
2622                }
2623            }
2624        }
2625    }
2626
2627    // Skip sessions with no readable content at all — these are sessions
2628    // where the assistant returned empty content (e.g. only thinking blocks)
2629    // and the user never sent a message. Not useful for resuming.
2630    if first_message.is_empty() {
2631        return None;
2632    }
2633
2634    let cwd = header.cwd.clone();
2635    let parent_session_path = header.parent_session.clone();
2636    let created = chrono::DateTime::parse_from_rfc3339(&header.timestamp)
2637        .map(|dt| dt.with_timezone(&Utc))
2638        .unwrap_or_else(|_| Utc::now());
2639    let modified = get_session_modified_date(&entries, &header.timestamp, &stats);
2640
2641    Some(SessionInfo {
2642        path: file_path.to_string(),
2643        id: header.id.clone(),
2644        cwd,
2645        name,
2646        parent_session_path,
2647        created,
2648        modified,
2649        message_count,
2650        first_message: if first_message.is_empty() {
2651            "(no messages)".to_string()
2652        } else {
2653            first_message
2654        },
2655        all_messages_text: all_messages.join(" "),
2656    })
2657}
2658
2659/// Parse session entries from content
2660fn parse_session_entries(content: &str) -> Option<Vec<FileEntry>> {
2661    let mut entries = Vec::new();
2662
2663    for line in content.trim().lines() {
2664        if line.trim().is_empty() {
2665            continue;
2666        }
2667        if let Ok(entry) = serde_json::from_str::<FileEntry>(line) {
2668            entries.push(entry);
2669        }
2670    }
2671
2672    Some(entries)
2673}
2674
2675/// Get session modified date
2676fn get_session_modified_date(
2677    entries: &[FileEntry],
2678    header_timestamp: &str,
2679    stats: &std::fs::Metadata,
2680) -> DateTime<Utc> {
2681    let last_activity_time = get_last_activity_time(entries);
2682    if let Some(t) = last_activity_time
2683        && t > 0
2684    {
2685        return DateTime::from_timestamp_millis(t).unwrap_or_else(Utc::now);
2686    }
2687
2688    let header_time = chrono::DateTime::parse_from_rfc3339(header_timestamp)
2689        .map(|dt| dt.timestamp_millis())
2690        .unwrap_or(-1);
2691
2692    if header_time > 0 {
2693        return DateTime::from_timestamp_millis(header_time).unwrap_or_else(Utc::now);
2694    }
2695
2696    if let Ok(mtime) = stats.modified() {
2697        return DateTime::from(mtime);
2698    }
2699
2700    Utc::now()
2701}
2702
2703/// Get last activity time from entries
2704fn get_last_activity_time(entries: &[FileEntry]) -> Option<i64> {
2705    let mut last_activity: Option<i64> = None;
2706
2707    for entry in entries {
2708        let entry = match entry {
2709            FileEntry::Entry(e) => e,
2710            _ => continue,
2711        };
2712
2713        if let SessionEntryEnum::Message(m) = entry
2714            && (m.message.is_user() || m.message.is_assistant())
2715        {
2716            last_activity = Some(std::cmp::max(
2717                last_activity.unwrap_or(0),
2718                m.base.timestamp.parse().unwrap_or(0),
2719            ));
2720        }
2721    }
2722
2723    last_activity
2724}
2725
2726// ============================================================================
2727// Tests
2728// ============================================================================
2729
2730#[cfg(test)]
2731mod tests {
2732    use super::*;
2733
2734    #[test]
2735    fn test_session_creation() {
2736        let manager = SessionManager::in_memory("/tmp");
2737        assert!(!manager.get_session_id().is_empty());
2738        assert_eq!(manager.get_entries().len(), 0);
2739    }
2740
2741    #[test]
2742    fn test_append_message() {
2743        let mut manager = SessionManager::in_memory("/tmp");
2744        let id = manager.append_message(AgentMessage::User {
2745            content: ContentValue::String("Hello".to_string()),
2746        });
2747        assert!(!id.is_empty());
2748        assert_eq!(manager.get_entries().len(), 1);
2749        assert_eq!(manager.get_leaf_id(), Some(id));
2750    }
2751
2752    #[test]
2753    fn test_tree_traversal() {
2754        let mut manager = SessionManager::in_memory("/tmp");
2755        let id1 = manager.append_message(AgentMessage::User {
2756            content: ContentValue::String("Hello".to_string()),
2757        });
2758        let id2 = manager.append_message(AgentMessage::Assistant {
2759            content: vec![],
2760            provider: None,
2761            model_id: None,
2762            usage: None,
2763            stop_reason: None,
2764        });
2765
2766        // Get branch from root
2767        let branch = manager.get_branch(None);
2768        assert_eq!(branch.len(), 2);
2769
2770        // Get branch from specific entry
2771        let branch = manager.get_branch(Some(&id1));
2772        assert_eq!(branch.len(), 1);
2773
2774        // Get children
2775        let children = manager.get_children(&id1);
2776        assert_eq!(children.len(), 1);
2777
2778        // Get parent
2779        let parent = manager.get_parent(&id2);
2780        assert!(parent.is_some());
2781        assert_eq!(parent.unwrap().id, id1);
2782    }
2783
2784    #[test]
2785    fn test_branching() {
2786        let mut manager = SessionManager::in_memory("/tmp");
2787        let id1 = manager.append_message(AgentMessage::User {
2788            content: ContentValue::String("Hello".to_string()),
2789        });
2790        let _id2 = manager.append_message(AgentMessage::Assistant {
2791            content: vec![],
2792            provider: None,
2793            model_id: None,
2794            usage: None,
2795            stop_reason: None,
2796        });
2797        let _id3 = manager.append_message(AgentMessage::User {
2798            content: ContentValue::String("How are you?".to_string()),
2799        });
2800
2801        // Branch from first message
2802        manager.branch(&id1).unwrap();
2803        assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2804
2805        // Add new message on branch
2806        let id4 = manager.append_message(AgentMessage::Assistant {
2807            content: vec![],
2808            provider: None,
2809            model_id: None,
2810            usage: None,
2811            stop_reason: None,
2812        });
2813
2814        // Should have 4 entries total (3 original + 1 new branch)
2815        assert_eq!(manager.get_entries().len(), 4);
2816
2817        // Leaf should be the new message
2818        assert_eq!(manager.get_leaf_id(), Some(id4));
2819
2820        // Get tree - 1 root (id1), with 2 children (id2 and id4)
2821        let tree = manager.get_tree(Uuid::nil()).unwrap();
2822        assert_eq!(tree.len(), 1); // One root
2823        assert_eq!(tree[0].children.len(), 2); // id1 has 2 children: id2 and id4
2824    }
2825
2826    #[test]
2827    fn test_session_context() {
2828        let mut manager = SessionManager::in_memory("/tmp");
2829        manager.append_message(AgentMessage::User {
2830            content: ContentValue::String("Hello".to_string()),
2831        });
2832        manager.append_message(AgentMessage::Assistant {
2833            content: vec![AssistantContentBlock::Text {
2834                text: "Hi there!".to_string(),
2835            }],
2836            provider: Some("test".to_string()),
2837            model_id: Some("model".to_string()),
2838            usage: None,
2839            stop_reason: None,
2840        });
2841
2842        let context = manager.build_session_context();
2843        assert_eq!(context.messages.len(), 2);
2844        assert!(context.model.is_some());
2845    }
2846
2847    #[test]
2848    fn test_compaction_entry() {
2849        let mut manager = SessionManager::in_memory("/tmp");
2850        let id1 = manager.append_message(AgentMessage::User {
2851            content: ContentValue::String("First message".to_string()),
2852        });
2853        let _id2 = manager.append_message(AgentMessage::Assistant {
2854            content: vec![],
2855            provider: None,
2856            model_id: None,
2857            usage: None,
2858            stop_reason: None,
2859        });
2860
2861        let id3 = manager.append_compaction("Summarized conversation", &id1, 1000, None, None);
2862        assert!(!id3.is_empty());
2863
2864        let latest = manager.get_latest_compaction_entry();
2865        assert!(latest.is_some());
2866    }
2867
2868    #[test]
2869    fn test_labels() {
2870        let mut manager = SessionManager::in_memory("/tmp");
2871        let id1 = manager.append_message(AgentMessage::User {
2872            content: ContentValue::String("Hello".to_string()),
2873        });
2874
2875        manager.add_label(&id1, "important").unwrap();
2876        assert_eq!(manager.get_label(&id1), Some("important".to_string()));
2877
2878        manager.remove_label(&id1).unwrap();
2879        assert_eq!(manager.get_label(&id1), None);
2880    }
2881
2882    // ========================================================================
2883    // Session tree and branching tests
2884    // ========================================================================
2885
2886    /// Helper: create a user message
2887    fn user_msg(text: &str) -> AgentMessage {
2888        AgentMessage::User {
2889            content: ContentValue::String(text.to_string()),
2890        }
2891    }
2892
2893    /// Helper: create an assistant message
2894    fn assistant_msg(text: &str) -> AgentMessage {
2895        AgentMessage::Assistant {
2896            content: vec![AssistantContentBlock::Text {
2897                text: text.to_string(),
2898            }],
2899            provider: Some("anthropic".to_string()),
2900            model_id: Some("claude-test".to_string()),
2901            usage: None,
2902            stop_reason: None,
2903        }
2904    }
2905
2906    /// Helper: create a bare assistant message (no content/metadata)
2907    fn bare_assistant_msg() -> AgentMessage {
2908        AgentMessage::Assistant {
2909            content: vec![],
2910            provider: None,
2911            model_id: None,
2912            usage: None,
2913            stop_reason: None,
2914        }
2915    }
2916
2917    // ------------------------------------------------------------------------
2918    // append operations integration into tree
2919    // ------------------------------------------------------------------------
2920
2921    #[test]
2922    fn test_append_thinking_level_change_integrates() {
2923        let mut manager = SessionManager::in_memory("/tmp");
2924        let msg_id = manager.append_message(user_msg("hello"));
2925        let thinking_id = manager.append_thinking_level_change("high");
2926        let msg2_id = manager.append_message(assistant_msg("response"));
2927
2928        let entries = manager.get_entries();
2929        assert_eq!(entries.len(), 3);
2930
2931        // Thinking entry should be between the two messages
2932        let thinking_entry = entries.iter().find(|e| e.id == thinking_id).unwrap();
2933        assert_eq!(thinking_entry.parent_id, Some(msg_id));
2934
2935        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2936        assert_eq!(msg2.parent_id, Some(thinking_id));
2937    }
2938
2939    #[test]
2940    fn test_append_model_change_integrates() {
2941        let mut manager = SessionManager::in_memory("/tmp");
2942        let msg_id = manager.append_message(user_msg("hello"));
2943        let model_id = manager.append_model_change("openai", "gpt-4");
2944        let msg2_id = manager.append_message(assistant_msg("response"));
2945
2946        let entries = manager.get_entries();
2947        let model_entry = entries.iter().find(|e| e.id == model_id).unwrap();
2948        assert_eq!(model_entry.parent_id, Some(msg_id));
2949
2950        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
2951        assert_eq!(msg2.parent_id, Some(model_id));
2952    }
2953
2954    #[test]
2955    fn test_append_compaction_integrates_into_tree() {
2956        let mut manager = SessionManager::in_memory("/tmp");
2957        let id1 = manager.append_message(user_msg("1"));
2958        let id2 = manager.append_message(assistant_msg("2"));
2959        let compaction_id = manager.append_compaction("summary", &id1, 1000, None, None);
2960        let id3 = manager.append_message(user_msg("3"));
2961
2962        let entries = manager.get_entries();
2963        let compaction = entries.iter().find(|e| e.id == compaction_id).unwrap();
2964        assert_eq!(compaction.parent_id, Some(id2));
2965
2966        let msg3 = entries.iter().find(|e| e.id == id3).unwrap();
2967        assert_eq!(msg3.parent_id, Some(compaction_id));
2968
2969        // Verify compaction content
2970        if let AgentMessage::CompactionSummary {
2971            summary,
2972            tokens_before,
2973            ..
2974        } = &compaction.message
2975        {
2976            assert_eq!(summary, "summary");
2977            assert_eq!(*tokens_before, 1000);
2978        } else {
2979            panic!("Expected CompactionSummary");
2980        }
2981    }
2982
2983    #[test]
2984    fn test_leaf_pointer_advances() {
2985        let mut manager = SessionManager::in_memory("/tmp");
2986        assert!(manager.get_leaf_id().is_none());
2987
2988        let id1 = manager.append_message(user_msg("1"));
2989        assert_eq!(manager.get_leaf_id(), Some(id1.clone()));
2990
2991        let id2 = manager.append_message(assistant_msg("2"));
2992        assert_eq!(manager.get_leaf_id(), Some(id2.clone()));
2993
2994        let id3 = manager.append_thinking_level_change("high");
2995        assert_eq!(manager.get_leaf_id(), Some(id3));
2996    }
2997
2998    #[test]
2999    fn test_get_entry() {
3000        let mut manager = SessionManager::in_memory("/tmp");
3001        assert!(manager.get_entry("nonexistent").is_none());
3002
3003        let id1 = manager.append_message(user_msg("first"));
3004        let id2 = manager.append_message(assistant_msg("second"));
3005
3006        let entry1 = manager.get_entry(&id1);
3007        assert!(entry1.is_some());
3008        assert!(entry1.unwrap().message.is_user());
3009
3010        let entry2 = manager.get_entry(&id2);
3011        assert!(entry2.is_some());
3012        assert!(entry2.unwrap().message.is_assistant());
3013    }
3014
3015    #[test]
3016    fn test_get_leaf_entry() {
3017        let manager = SessionManager::in_memory("/tmp");
3018        assert!(manager.get_leaf_entry().is_none());
3019
3020        let mut manager = SessionManager::in_memory("/tmp");
3021        manager.append_message(user_msg("1"));
3022        let id2 = manager.append_message(assistant_msg("2"));
3023
3024        let leaf = manager.get_leaf_entry();
3025        assert!(leaf.is_some());
3026        assert_eq!(leaf.unwrap().id, id2);
3027    }
3028
3029    // ------------------------------------------------------------------------
3030    // getBranch / getPath
3031    // ------------------------------------------------------------------------
3032
3033    #[test]
3034    fn test_get_branch_full_path_root_to_leaf() {
3035        let mut manager = SessionManager::in_memory("/tmp");
3036        let id1 = manager.append_message(user_msg("1"));
3037        let id2 = manager.append_message(assistant_msg("2"));
3038        let id3 = manager.append_thinking_level_change("high");
3039        let id4 = manager.append_message(user_msg("3"));
3040
3041        let branch = manager.get_branch(None);
3042        assert_eq!(branch.len(), 4);
3043        assert_eq!(branch[0].id, id1);
3044        assert_eq!(branch[1].id, id2);
3045        assert_eq!(branch[2].id, id3);
3046        assert_eq!(branch[3].id, id4);
3047    }
3048
3049    #[test]
3050    fn test_get_branch_from_specific_entry() {
3051        let mut manager = SessionManager::in_memory("/tmp");
3052        let id1 = manager.append_message(user_msg("1"));
3053        let id2 = manager.append_message(assistant_msg("2"));
3054        manager.append_message(user_msg("3"));
3055        manager.append_message(assistant_msg("4"));
3056
3057        let branch = manager.get_branch(Some(&id2));
3058        assert_eq!(branch.len(), 2);
3059        assert_eq!(branch[0].id, id1);
3060        assert_eq!(branch[1].id, id2);
3061    }
3062
3063    // ------------------------------------------------------------------------
3064    // Multiple branches at same point (3 siblings)
3065    // ------------------------------------------------------------------------
3066
3067    #[test]
3068    fn test_multiple_branches_at_same_point() {
3069        let mut manager = SessionManager::in_memory("/tmp");
3070        manager.append_message(user_msg("root"));
3071        let id2 = manager.append_message(bare_assistant_msg());
3072
3073        // Branch A
3074        manager.branch(&id2).unwrap();
3075        let id_a = manager.append_message(user_msg("branch-A"));
3076
3077        // Branch B
3078        manager.branch(&id2).unwrap();
3079        let id_b = manager.append_message(user_msg("branch-B"));
3080
3081        // Branch C
3082        manager.branch(&id2).unwrap();
3083        let id_c = manager.append_message(user_msg("branch-C"));
3084
3085        let tree = manager.get_tree(Uuid::nil()).unwrap();
3086        let node2 = &tree[0].children[0];
3087        assert_eq!(node2.entry.id, id2);
3088        assert_eq!(node2.children.len(), 3);
3089
3090        let mut branch_ids: Vec<String> =
3091            node2.children.iter().map(|c| c.entry.id.clone()).collect();
3092        branch_ids.sort();
3093        let mut expected = vec![id_a, id_b, id_c];
3094        expected.sort();
3095        assert_eq!(branch_ids, expected);
3096    }
3097
3098    // ------------------------------------------------------------------------
3099    // Deep branching
3100    // ------------------------------------------------------------------------
3101
3102    #[test]
3103    fn test_deep_branching() {
3104        let mut manager = SessionManager::in_memory("/tmp");
3105
3106        // Main path: 1 -> 2 -> 3 -> 4
3107        manager.append_message(user_msg("1"));
3108        let id2 = manager.append_message(bare_assistant_msg());
3109        let id3 = manager.append_message(user_msg("3"));
3110        manager.append_message(bare_assistant_msg());
3111
3112        // Branch from 2: 2 -> 5 -> 6
3113        manager.branch(&id2).unwrap();
3114        let id5 = manager.append_message(user_msg("5"));
3115        manager.append_message(bare_assistant_msg());
3116
3117        // Branch from 5: 5 -> 7
3118        manager.branch(&id5).unwrap();
3119        manager.append_message(user_msg("7"));
3120
3121        let tree = manager.get_tree(Uuid::nil()).unwrap();
3122
3123        // node2 has 2 children: id3 and id5
3124        let node2 = &tree[0].children[0];
3125        assert_eq!(node2.children.len(), 2);
3126
3127        let node5 = node2.children.iter().find(|c| c.entry.id == id5).unwrap();
3128        assert_eq!(node5.children.len(), 2); // id6 and id7
3129
3130        let node3 = node2.children.iter().find(|c| c.entry.id == id3).unwrap();
3131        assert_eq!(node3.children.len(), 1); // id4
3132    }
3133
3134    // ------------------------------------------------------------------------
3135    // branch_with_summary
3136    // ------------------------------------------------------------------------
3137
3138    #[test]
3139    fn test_branch_with_summary_inserts_and_advances() {
3140        let mut manager = SessionManager::in_memory("/tmp");
3141        let id1 = manager.append_message(user_msg("1"));
3142        manager.append_message(bare_assistant_msg());
3143        manager.append_message(user_msg("3"));
3144
3145        let summary_id =
3146            manager.branch_with_summary(Some(&id1), "Summary of abandoned work", None, None);
3147        assert!(!summary_id.is_empty());
3148        assert_eq!(manager.get_leaf_id(), Some(summary_id.clone()));
3149
3150        // Verify branch_summary entry
3151        let entries = manager.get_entries();
3152        let summary_entry = entries.iter().find(|e| e.id == summary_id).unwrap();
3153        assert_eq!(summary_entry.parent_id, Some(id1));
3154
3155        if let AgentMessage::BranchSummary { summary, .. } = &summary_entry.message {
3156            assert_eq!(summary, "Summary of abandoned work");
3157        } else {
3158            panic!("Expected BranchSummary");
3159        }
3160    }
3161
3162    // ------------------------------------------------------------------------
3163    // build_session_context with branches
3164    // ------------------------------------------------------------------------
3165
3166    #[test]
3167    fn test_build_session_context_returns_branch_messages() {
3168        let mut manager = SessionManager::in_memory("/tmp");
3169
3170        // Main: 1 -> 2 -> 3
3171        manager.append_message(user_msg("msg1"));
3172        let id2 = manager.append_message(bare_assistant_msg());
3173        manager.append_message(user_msg("msg3"));
3174
3175        // Branch from 2: 2 -> 4
3176        manager.branch(&id2).unwrap();
3177        manager.append_message(assistant_msg("msg4-branch"));
3178
3179        let ctx = manager.build_session_context();
3180        // Should have msg1, msg2, msg4-branch (NOT msg3)
3181        assert_eq!(ctx.messages.len(), 3);
3182        assert!(ctx.messages[0].is_user());
3183        assert!(ctx.messages[1].is_assistant());
3184        assert!(ctx.messages[2].is_assistant());
3185    }
3186
3187    #[test]
3188    fn test_build_session_context_follows_branch_path() {
3189        // Tree: 1 -> 2 -> 3 (branch A)
3190        //             \-> 4 (branch B)
3191        let mut manager = SessionManager::in_memory("/tmp");
3192        manager.append_message(user_msg("start"));
3193        let id2 = manager.append_message(bare_assistant_msg());
3194        manager.append_message(user_msg("branch A"));
3195
3196        // Switch to branch B
3197        manager.branch(&id2).unwrap();
3198        manager.append_message(user_msg("branch B"));
3199
3200        let ctx = manager.build_session_context();
3201        assert_eq!(ctx.messages.len(), 3);
3202        // Last message should be "branch B"
3203        let last = ctx.messages.last().unwrap();
3204        assert_eq!(last.content(), "branch B");
3205    }
3206
3207    #[test]
3208    fn test_build_session_context_includes_branch_summary() {
3209        let mut manager = SessionManager::in_memory("/tmp");
3210        manager.append_message(user_msg("start"));
3211        let id2 = manager.append_message(bare_assistant_msg());
3212        manager.append_message(user_msg("abandoned path"));
3213
3214        // Branch with summary
3215        manager.branch_with_summary(Some(&id2), "Summary of abandoned work", None, None);
3216        manager.append_message(user_msg("new direction"));
3217
3218        let ctx = manager.build_session_context();
3219        // Should include: start, response, branch_summary, new direction
3220        assert!(ctx.messages.len() >= 3);
3221
3222        // Branch summary should be in messages
3223        let has_summary = ctx.messages.iter().any(|m| {
3224            if let AgentMessage::BranchSummary { summary, .. } = m {
3225                summary == "Summary of abandoned work"
3226            } else {
3227                false
3228            }
3229        });
3230        assert!(has_summary, "Branch summary should be in context messages");
3231    }
3232
3233    #[test]
3234    fn test_build_session_context_with_compaction() {
3235        let mut manager = SessionManager::in_memory("/tmp");
3236
3237        // Build conversation
3238        let id1 = manager.append_message(user_msg("first"));
3239        manager.append_message(assistant_msg("response1"));
3240        manager.append_message(user_msg("second"));
3241        manager.append_message(assistant_msg("response2"));
3242
3243        // Add compaction
3244        manager.append_compaction("Summary of first two turns", &id1, 1000, None, None);
3245
3246        // Continue after compaction
3247        manager.append_message(user_msg("third"));
3248        manager.append_message(assistant_msg("response3"));
3249
3250        let ctx = manager.build_session_context();
3251        // CompactionSummary is NOT included in context messages (only user/assistant/branch_summary)
3252        // but the path from leaf should include all entries
3253        assert!(ctx.messages.len() >= 4); // at minimum: user, assistant, user, assistant from after-compaction path
3254
3255        // Compaction entry should exist in the entries
3256        let compaction_entries = manager.get_compaction_entries();
3257        assert_eq!(compaction_entries.len(), 1);
3258    }
3259
3260    #[test]
3261    fn test_build_session_context_tracks_thinking_level() {
3262        let mut manager = SessionManager::in_memory("/tmp");
3263        manager.append_message(user_msg("hello"));
3264        manager.append_thinking_level_change("high");
3265        manager.append_message(assistant_msg("thinking hard"));
3266
3267        let ctx = manager.build_session_context();
3268        assert_eq!(ctx.thinking_level, "high");
3269    }
3270
3271    // ------------------------------------------------------------------------
3272    // Labels in tree nodes
3273    // ------------------------------------------------------------------------
3274
3275    #[test]
3276    fn test_labels_in_tree_nodes() {
3277        let mut manager = SessionManager::in_memory("/tmp");
3278        let id1 = manager.append_message(user_msg("hello"));
3279        let id2 = manager.append_message(assistant_msg("hi"));
3280
3281        manager.add_label(&id1, "start").unwrap();
3282        manager.add_label(&id2, "response").unwrap();
3283
3284        let tree = manager.get_tree(Uuid::nil()).unwrap();
3285        let node1 = &tree[0];
3286        assert_eq!(node1.label, Some("start".to_string()));
3287
3288        let node2 = &node1.children[0];
3289        assert_eq!(node2.label, Some("response".to_string()));
3290    }
3291
3292    #[test]
3293    fn test_last_label_wins() {
3294        let mut manager = SessionManager::in_memory("/tmp");
3295        let id1 = manager.append_message(user_msg("hello"));
3296
3297        manager.add_label(&id1, "first").unwrap();
3298        manager.add_label(&id1, "second").unwrap();
3299        manager.add_label(&id1, "third").unwrap();
3300
3301        assert_eq!(manager.get_label(&id1), Some("third".to_string()));
3302    }
3303
3304    // ------------------------------------------------------------------------
3305    // branch throws for non-existent
3306    // ------------------------------------------------------------------------
3307
3308    #[test]
3309    fn test_branch_throws_for_nonexistent() {
3310        let mut manager = SessionManager::in_memory("/tmp");
3311        manager.append_message(user_msg("hello"));
3312
3313        let result = manager.branch("nonexistent");
3314        assert!(result.is_err());
3315    }
3316
3317    // ------------------------------------------------------------------------
3318    // Labels not included in buildSessionContext
3319    // ------------------------------------------------------------------------
3320
3321    #[test]
3322    fn test_labels_not_in_session_context() {
3323        let mut manager = SessionManager::in_memory("/tmp");
3324        let msg_id = manager.append_message(user_msg("hello"));
3325        manager.add_label(&msg_id, "checkpoint").unwrap();
3326
3327        let ctx = manager.build_session_context();
3328        // Should only have the user message, not label entries
3329        assert_eq!(ctx.messages.len(), 1);
3330        assert!(ctx.messages[0].is_user());
3331    }
3332
3333    // ------------------------------------------------------------------------
3334    // appendCustomEntry integration
3335    // ------------------------------------------------------------------------
3336
3337    #[test]
3338    fn test_custom_entry_integrates_into_tree() {
3339        let mut manager = SessionManager::in_memory("/tmp");
3340        let msg_id = manager.append_message(user_msg("hello"));
3341        let custom_id =
3342            manager.append_custom_entry("my_data", Some(serde_json::json!({"foo": "bar"})));
3343        let msg2_id = manager.append_message(assistant_msg("response"));
3344
3345        let entries = manager.get_entries();
3346        let custom = entries.iter().find(|e| e.id == custom_id).unwrap();
3347        assert_eq!(custom.parent_id, Some(msg_id));
3348
3349        if let AgentMessage::Custom { custom_type, .. } = &custom.message {
3350            assert_eq!(custom_type, "my_data");
3351        } else {
3352            panic!("Expected Custom message");
3353        }
3354
3355        let msg2 = entries.iter().find(|e| e.id == msg2_id).unwrap();
3356        assert_eq!(msg2.parent_id, Some(custom_id));
3357
3358        // buildSessionContext should work (custom entries skipped in messages)
3359        let ctx = manager.build_session_context();
3360        // Only the 2 real messages; custom entry is not user/assistant/branch_summary
3361        assert_eq!(ctx.messages.len(), 2);
3362    }
3363
3364    // ------------------------------------------------------------------------
3365    // Empty session edge cases
3366    // ------------------------------------------------------------------------
3367
3368    #[test]
3369    fn test_get_branch_empty_session() {
3370        let manager = SessionManager::in_memory("/tmp");
3371        let branch = manager.get_branch(None);
3372        assert!(branch.is_empty());
3373    }
3374
3375    #[test]
3376    fn test_get_tree_empty_session() {
3377        let manager = SessionManager::in_memory("/tmp");
3378        let tree = manager.get_tree(Uuid::nil()).unwrap();
3379        assert!(tree.is_empty());
3380    }
3381
3382    // ------------------------------------------------------------------------
3383    // Complex tree with branches and compaction
3384    // ------------------------------------------------------------------------
3385
3386    #[test]
3387    fn test_complex_tree_with_branches_and_compaction() {
3388        let mut manager = SessionManager::in_memory("/tmp");
3389
3390        // Main path: 1 -> 2 -> 3 -> 4 -> compaction(5) -> 6 -> 7
3391        manager.append_message(user_msg("start"));
3392        manager.append_message(assistant_msg("r1"));
3393        let id3 = manager.append_message(user_msg("q2"));
3394        manager.append_message(assistant_msg("r2"));
3395        manager.append_compaction("Compacted history", &id3, 1000, None, None);
3396        manager.append_message(user_msg("q3"));
3397        manager.append_message(assistant_msg("r3"));
3398
3399        // Abandoned branch from 3
3400        manager.branch(&id3).unwrap();
3401        manager.append_message(user_msg("wrong path"));
3402        manager.append_message(assistant_msg("wrong response"));
3403
3404        // Branch summary resuming from 3
3405        manager.branch_with_summary(Some(&id3), "Tried wrong approach", None, None);
3406        manager.append_message(user_msg("better approach"));
3407
3408        let tree = manager.get_tree(Uuid::nil()).unwrap();
3409        // Root node
3410        assert_eq!(tree.len(), 1);
3411
3412        // Walk tree to verify structure
3413        let root = &tree[0];
3414        assert!(root.entry.message.is_user());
3415    }
3416
3417    // ------------------------------------------------------------------------
3418    // get_latest_compaction_entry returns the most recent
3419    // ------------------------------------------------------------------------
3420
3421    #[test]
3422    fn test_multiple_compactions_returns_latest() {
3423        let mut manager = SessionManager::in_memory("/tmp");
3424        let id1 = manager.append_message(user_msg("a"));
3425        manager.append_message(bare_assistant_msg());
3426        manager.append_compaction("First summary", &id1, 1000, None, None);
3427        manager.append_message(user_msg("c"));
3428        manager.append_message(bare_assistant_msg());
3429        manager.append_compaction("Second summary", &id1, 2000, None, None);
3430
3431        // get_compaction_entries returns all compaction entries
3432        let compactions = manager.get_compaction_entries();
3433        assert_eq!(compactions.len(), 2);
3434
3435        // At least one should exist with the second summary
3436        let latest = manager.get_latest_compaction_entry();
3437        assert!(latest.is_some());
3438    }
3439
3440    // ------------------------------------------------------------------------
3441    // get_compaction_entries returns all
3442    // ------------------------------------------------------------------------
3443
3444    #[test]
3445    fn test_get_all_compaction_entries() {
3446        let mut manager = SessionManager::in_memory("/tmp");
3447        let id1 = manager.append_message(user_msg("a"));
3448        manager.append_message(bare_assistant_msg());
3449        manager.append_compaction("First", &id1, 1000, None, None);
3450        manager.append_message(user_msg("b"));
3451        manager.append_message(bare_assistant_msg());
3452        manager.append_compaction("Second", &id1, 2000, None, None);
3453
3454        let compactions = manager.get_compaction_entries();
3455        assert_eq!(compactions.len(), 2);
3456    }
3457}