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