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