Skip to main content

pi/
session.rs

1//! Session management and persistence.
2//!
3//! Sessions are stored as JSONL files with a tree structure that enables
4//! branching and history navigation.
5
6use crate::agent_cx::AgentCx;
7use crate::cli::Cli;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::extensions::ExtensionSession;
11use crate::model::{
12    AssistantMessage, ContentBlock, Message, TextContent, ToolResultMessage, UserContent,
13    UserMessage,
14};
15use crate::session_index::{SessionIndex, enqueue_session_index_snapshot_update};
16use crate::session_store_v2::{self, SessionStoreV2};
17use crate::tui::PiConsole;
18use asupersync::channel::oneshot;
19use asupersync::sync::Mutex;
20use async_trait::async_trait;
21use fs4::fs_std::FileExt;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::collections::{HashMap, HashSet};
25use std::fmt::Write as _;
26use std::io::{BufRead, BufReader, IsTerminal, Write};
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::{Arc, OnceLock};
30use std::thread;
31use std::time::{Instant, SystemTime, UNIX_EPOCH};
32
33/// Current session file format version.
34pub const SESSION_VERSION: u8 = 3;
35
36type JsonlSaveResult = std::result::Result<Vec<SessionEntry>, (Error, Vec<SessionEntry>)>;
37
38/// Handle to a thread-safe shared session.
39#[derive(Clone, Debug)]
40pub struct SessionHandle(pub Arc<Mutex<Session>>);
41
42#[async_trait]
43impl ExtensionSession for SessionHandle {
44    async fn get_state(&self) -> Value {
45        let cx = AgentCx::for_request();
46        let Ok(session) = self.0.lock(cx.cx()).await else {
47            return serde_json::json!({
48                "model": null,
49                "thinkingLevel": "off",
50                "durabilityMode": "balanced",
51                "isStreaming": false,
52                "isCompacting": false,
53                "steeringMode": "one-at-a-time",
54                "followUpMode": "one-at-a-time",
55                "sessionFile": null,
56                "sessionId": "",
57                "sessionName": null,
58                "autoCompactionEnabled": false,
59                "messageCount": 0,
60                "pendingMessageCount": 0,
61            });
62        };
63        let session_file = session.path.as_ref().map(|p| p.display().to_string());
64        let session_id = session.header.id.clone();
65        let session_name = session.get_name();
66        let model = session
67            .header
68            .provider
69            .as_ref()
70            .zip(session.header.model_id.as_ref())
71            .map_or(Value::Null, |(provider, model_id)| {
72                serde_json::json!({
73                    "provider": provider,
74                    "id": model_id,
75                })
76            });
77        let thinking_level = session
78            .header
79            .thinking_level
80            .clone()
81            .unwrap_or_else(|| "off".to_string());
82        let message_count = session
83            .entries_for_current_path()
84            .iter()
85            .filter(|entry| matches!(entry, SessionEntry::Message(_)))
86            .count();
87        let pending_message_count = session.autosave_metrics().pending_mutations;
88        let durability_mode = session.autosave_durability_mode().as_str();
89        serde_json::json!({
90            "model": model,
91            "thinkingLevel": thinking_level,
92            "durabilityMode": durability_mode,
93            "isStreaming": false,
94            "isCompacting": false,
95            "steeringMode": "one-at-a-time",
96            "followUpMode": "one-at-a-time",
97            "sessionFile": session_file,
98            "sessionId": session_id,
99            "sessionName": session_name,
100            "autoCompactionEnabled": false,
101            "messageCount": message_count,
102            "pendingMessageCount": pending_message_count,
103        })
104    }
105
106    async fn get_messages(&self) -> Vec<SessionMessage> {
107        let cx = AgentCx::for_request();
108        let Ok(session) = self.0.lock(cx.cx()).await else {
109            return Vec::new();
110        };
111        // Return messages for the current branch only, filtered to
112        // user/assistant/toolResult/bashExecution/custom per spec §3.3.
113        session
114            .entries_for_current_path()
115            .iter()
116            .filter_map(|entry| match entry {
117                SessionEntry::Message(msg) => match msg.message {
118                    SessionMessage::User { .. }
119                    | SessionMessage::Assistant { .. }
120                    | SessionMessage::ToolResult { .. }
121                    | SessionMessage::BashExecution { .. }
122                    | SessionMessage::Custom { .. } => Some(msg.message.clone()),
123                    _ => None,
124                },
125                _ => None,
126            })
127            .collect()
128    }
129
130    async fn get_entries(&self) -> Vec<Value> {
131        let cx = AgentCx::for_request();
132        let Ok(session) = self.0.lock(cx.cx()).await else {
133            return Vec::new();
134        };
135        session
136            .entries
137            .iter()
138            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
139            .collect()
140    }
141
142    async fn get_branch(&self) -> Vec<Value> {
143        let cx = AgentCx::for_request();
144        let Ok(session) = self.0.lock(cx.cx()).await else {
145            return Vec::new();
146        };
147        session
148            .entries_for_current_path()
149            .iter()
150            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
151            .collect()
152    }
153
154    async fn set_name(&self, name: String) -> Result<()> {
155        let cx = AgentCx::for_request();
156        let mut session = self
157            .0
158            .lock(cx.cx())
159            .await
160            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
161        session.set_name(&name);
162        Ok(())
163    }
164
165    async fn append_message(&self, message: SessionMessage) -> Result<()> {
166        let cx = AgentCx::for_request();
167        let mut session = self
168            .0
169            .lock(cx.cx())
170            .await
171            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
172        session.append_message(message);
173        Ok(())
174    }
175
176    async fn append_custom_entry(&self, custom_type: String, data: Option<Value>) -> Result<()> {
177        let cx = AgentCx::for_request();
178        let mut session = self
179            .0
180            .lock(cx.cx())
181            .await
182            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
183        if custom_type.trim().is_empty() {
184            return Err(Error::validation("customType must not be empty"));
185        }
186        session.append_custom_entry(custom_type, data);
187        Ok(())
188    }
189
190    async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
191        let cx = AgentCx::for_request();
192        let mut session = self
193            .0
194            .lock(cx.cx())
195            .await
196            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
197        session.append_model_change(provider.clone(), model_id.clone());
198        session.set_model_header(Some(provider), Some(model_id), None);
199        Ok(())
200    }
201
202    async fn get_model(&self) -> (Option<String>, Option<String>) {
203        let cx = AgentCx::for_request();
204        let Ok(session) = self.0.lock(cx.cx()).await else {
205            return (None, None);
206        };
207        (
208            session.header.provider.clone(),
209            session.header.model_id.clone(),
210        )
211    }
212
213    async fn set_thinking_level(&self, level: String) -> Result<()> {
214        let cx = AgentCx::for_request();
215        let mut session = self
216            .0
217            .lock(cx.cx())
218            .await
219            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
220        session.append_thinking_level_change(level.clone());
221        session.set_model_header(None, None, Some(level));
222        Ok(())
223    }
224
225    async fn get_thinking_level(&self) -> Option<String> {
226        let cx = AgentCx::for_request();
227        let Ok(session) = self.0.lock(cx.cx()).await else {
228            return None;
229        };
230        session.header.thinking_level.clone()
231    }
232
233    async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
234        let cx = AgentCx::for_request();
235        let mut session = self
236            .0
237            .lock(cx.cx())
238            .await
239            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
240        if session.add_label(&target_id, label).is_none() {
241            return Err(Error::validation(format!(
242                "target entry '{target_id}' not found in session"
243            )));
244        }
245        Ok(())
246    }
247}
248
249/// Default base URL for the Pi session share viewer.
250pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://buildwithpi.ai/session/";
251
252fn build_share_viewer_url(base_url: Option<&str>, gist_id: &str) -> String {
253    let base_url = base_url
254        .filter(|value| !value.is_empty())
255        .unwrap_or(DEFAULT_SHARE_VIEWER_URL);
256    format!("{base_url}#{gist_id}")
257}
258
259/// Get the share viewer URL for a gist ID.
260///
261/// Matches legacy Pi Agent semantics:
262/// - Use `PI_SHARE_VIEWER_URL` env var when set and non-empty
263/// - Otherwise fall back to `DEFAULT_SHARE_VIEWER_URL`
264/// - Final URL is `{base}#{gist_id}` (no trailing-slash normalization)
265#[must_use]
266pub fn get_share_viewer_url(gist_id: &str) -> String {
267    let base_url = std::env::var("PI_SHARE_VIEWER_URL").ok();
268    build_share_viewer_url(base_url.as_deref(), gist_id)
269}
270
271/// Session persistence backend.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum SessionStoreKind {
274    Jsonl,
275    #[cfg(feature = "sqlite-sessions")]
276    Sqlite,
277}
278
279impl SessionStoreKind {
280    fn from_config(config: &Config) -> Self {
281        let Some(value) = config.session_store.as_deref() else {
282            return Self::Jsonl;
283        };
284
285        if value.eq_ignore_ascii_case("jsonl") {
286            return Self::Jsonl;
287        }
288
289        if value.eq_ignore_ascii_case("sqlite") {
290            #[cfg(feature = "sqlite-sessions")]
291            {
292                return Self::Sqlite;
293            }
294
295            #[cfg(not(feature = "sqlite-sessions"))]
296            {
297                tracing::warn!(
298                    "Config requests session_store=sqlite but binary lacks `sqlite-sessions`; falling back to jsonl"
299                );
300                return Self::Jsonl;
301            }
302        }
303
304        tracing::warn!("Unknown session_store `{value}`, falling back to jsonl");
305        Self::Jsonl
306    }
307
308    const fn extension(self) -> &'static str {
309        match self {
310            Self::Jsonl => "jsonl",
311            #[cfg(feature = "sqlite-sessions")]
312            Self::Sqlite => "sqlite",
313        }
314    }
315}
316
317/// Default upper bound for queued autosave mutations before backpressure coalescing kicks in.
318const DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS: usize = 256;
319
320fn autosave_max_pending_mutations() -> usize {
321    std::env::var("PI_SESSION_AUTOSAVE_MAX_PENDING")
322        .ok()
323        .and_then(|raw| raw.parse::<usize>().ok())
324        .filter(|value| *value > 0)
325        .unwrap_or(DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS)
326}
327
328/// Default number of incremental appends before forcing a full checkpoint rewrite.
329const DEFAULT_COMPACTION_CHECKPOINT_INTERVAL: u64 = 50;
330
331fn compaction_checkpoint_interval() -> u64 {
332    std::env::var("PI_SESSION_COMPACTION_INTERVAL")
333        .ok()
334        .and_then(|raw| raw.parse::<u64>().ok())
335        .filter(|value| *value > 0)
336        .unwrap_or(DEFAULT_COMPACTION_CHECKPOINT_INTERVAL)
337}
338
339/// Durability mode for write-behind autosave behavior.
340#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum AutosaveDurabilityMode {
342    Strict,
343    Balanced,
344    Throughput,
345}
346
347impl AutosaveDurabilityMode {
348    fn parse(raw: &str) -> Option<Self> {
349        match raw.trim().to_ascii_lowercase().as_str() {
350            "strict" => Some(Self::Strict),
351            "balanced" => Some(Self::Balanced),
352            "throughput" => Some(Self::Throughput),
353            _ => None,
354        }
355    }
356
357    fn from_env() -> Self {
358        std::env::var("PI_SESSION_DURABILITY_MODE")
359            .ok()
360            .as_deref()
361            .and_then(Self::parse)
362            .unwrap_or(Self::Balanced)
363    }
364
365    const fn should_flush_on_shutdown(self) -> bool {
366        matches!(self, Self::Strict | Self::Balanced)
367    }
368
369    const fn best_effort_on_shutdown(self) -> bool {
370        matches!(self, Self::Balanced)
371    }
372
373    pub const fn as_str(self) -> &'static str {
374        match self {
375            Self::Strict => "strict",
376            Self::Balanced => "balanced",
377            Self::Throughput => "throughput",
378        }
379    }
380}
381
382fn resolve_autosave_durability_mode(
383    cli_mode: Option<&str>,
384    config_mode: Option<&str>,
385    env_mode: Option<&str>,
386) -> AutosaveDurabilityMode {
387    cli_mode
388        .and_then(AutosaveDurabilityMode::parse)
389        .or_else(|| config_mode.and_then(AutosaveDurabilityMode::parse))
390        .or_else(|| env_mode.and_then(AutosaveDurabilityMode::parse))
391        .unwrap_or(AutosaveDurabilityMode::Balanced)
392}
393
394/// Autosave flush trigger used for observability.
395#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AutosaveFlushTrigger {
397    Manual,
398    Periodic,
399    Shutdown,
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403enum AutosaveMutationKind {
404    Message,
405    Metadata,
406    Label,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410struct AutosaveFlushTicket {
411    batch_size: usize,
412    started_at: Instant,
413    trigger: AutosaveFlushTrigger,
414}
415
416/// Snapshot of autosave queue state and lifecycle counters.
417#[derive(Debug, Clone, Copy, Default)]
418pub struct AutosaveQueueMetrics {
419    pub pending_mutations: usize,
420    pub max_pending_mutations: usize,
421    pub coalesced_mutations: u64,
422    pub backpressure_events: u64,
423    pub flush_started: u64,
424    pub flush_succeeded: u64,
425    pub flush_failed: u64,
426    pub last_flush_batch_size: usize,
427    pub last_flush_duration_ms: Option<u64>,
428    pub last_flush_trigger: Option<AutosaveFlushTrigger>,
429}
430
431#[derive(Debug, Clone)]
432struct AutosaveQueue {
433    pending_mutations: usize,
434    max_pending_mutations: usize,
435    coalesced_mutations: u64,
436    backpressure_events: u64,
437    flush_started: u64,
438    flush_succeeded: u64,
439    flush_failed: u64,
440    last_flush_batch_size: usize,
441    last_flush_duration_ms: Option<u64>,
442    last_flush_trigger: Option<AutosaveFlushTrigger>,
443}
444
445impl AutosaveQueue {
446    fn new() -> Self {
447        Self {
448            pending_mutations: 0,
449            max_pending_mutations: autosave_max_pending_mutations(),
450            coalesced_mutations: 0,
451            backpressure_events: 0,
452            flush_started: 0,
453            flush_succeeded: 0,
454            flush_failed: 0,
455            last_flush_batch_size: 0,
456            last_flush_duration_ms: None,
457            last_flush_trigger: None,
458        }
459    }
460
461    #[cfg(test)]
462    fn with_limit(max_pending_mutations: usize) -> Self {
463        let mut queue = Self::new();
464        queue.max_pending_mutations = max_pending_mutations.max(1);
465        queue
466    }
467
468    const fn metrics(&self) -> AutosaveQueueMetrics {
469        AutosaveQueueMetrics {
470            pending_mutations: self.pending_mutations,
471            max_pending_mutations: self.max_pending_mutations,
472            coalesced_mutations: self.coalesced_mutations,
473            backpressure_events: self.backpressure_events,
474            flush_started: self.flush_started,
475            flush_succeeded: self.flush_succeeded,
476            flush_failed: self.flush_failed,
477            last_flush_batch_size: self.last_flush_batch_size,
478            last_flush_duration_ms: self.last_flush_duration_ms,
479            last_flush_trigger: self.last_flush_trigger,
480        }
481    }
482
483    const fn enqueue_mutation(&mut self, _kind: AutosaveMutationKind) {
484        if self.pending_mutations == 0 {
485            self.pending_mutations = 1;
486            return;
487        }
488        self.coalesced_mutations = self.coalesced_mutations.saturating_add(1);
489        if self.pending_mutations < self.max_pending_mutations {
490            self.pending_mutations += 1;
491        } else {
492            self.backpressure_events = self.backpressure_events.saturating_add(1);
493        }
494    }
495
496    fn begin_flush(&mut self, trigger: AutosaveFlushTrigger) -> Option<AutosaveFlushTicket> {
497        if self.pending_mutations == 0 {
498            return None;
499        }
500        let batch_size = self.pending_mutations;
501        self.pending_mutations = 0;
502        self.flush_started = self.flush_started.saturating_add(1);
503        self.last_flush_batch_size = batch_size;
504        self.last_flush_trigger = Some(trigger);
505        Some(AutosaveFlushTicket {
506            batch_size,
507            started_at: Instant::now(),
508            trigger,
509        })
510    }
511
512    fn finish_flush(&mut self, ticket: AutosaveFlushTicket, success: bool) {
513        let elapsed = ticket.started_at.elapsed().as_millis();
514        let elapsed = u64::try_from(elapsed.min(u128::from(u64::MAX)))
515            .expect("elapsed milliseconds clamped to u64::MAX");
516        self.last_flush_duration_ms = Some(elapsed);
517        self.last_flush_trigger = Some(ticket.trigger);
518        if success {
519            self.flush_succeeded = self.flush_succeeded.saturating_add(1);
520            return;
521        }
522
523        self.flush_failed = self.flush_failed.saturating_add(1);
524        // New mutations may have arrived while the flush was in flight.
525        // Restore only into remaining capacity so pending count never exceeds
526        // `max_pending_mutations`.
527        let available_capacity = self
528            .max_pending_mutations
529            .saturating_sub(self.pending_mutations);
530        let restored = ticket.batch_size.min(available_capacity);
531        self.pending_mutations = self.pending_mutations.saturating_add(restored);
532        let dropped = ticket.batch_size.saturating_sub(restored);
533        if dropped > 0 {
534            let dropped = dropped as u64;
535            self.backpressure_events = self.backpressure_events.saturating_add(dropped);
536            self.coalesced_mutations = self.coalesced_mutations.saturating_add(dropped);
537        }
538    }
539}
540
541// ============================================================================
542// Session
543// ============================================================================
544
545/// A session manages conversation state and persistence.
546#[derive(Debug)]
547pub struct Session {
548    /// Session header
549    pub header: SessionHeader,
550    /// Session entries (messages, changes, etc.)
551    pub entries: Vec<SessionEntry>,
552    /// Path to the session file (None for in-memory)
553    pub path: Option<PathBuf>,
554    /// Current leaf entry ID
555    pub leaf_id: Option<String>,
556    /// Base directory for session storage (optional override)
557    pub session_dir: Option<PathBuf>,
558    store_kind: SessionStoreKind,
559    /// Cached entry IDs for O(1) uniqueness checks when appending.
560    entry_ids: HashSet<String>,
561
562    // -- Performance caches (Gaps A/B/C) --
563    /// True when all entries form a linear chain (no branching).
564    /// When true, `entries_for_current_path()` returns all entries without
565    /// building a parent map — the 99% fast path.
566    is_linear: bool,
567    /// Map from entry ID to index in `self.entries` for O(1) lookup.
568    entry_index: HashMap<String, usize>,
569    /// Incrementally maintained message count (avoids O(n) scan on save).
570    cached_message_count: u64,
571    /// Most recent session name from `SessionInfo` entries.
572    cached_name: Option<String>,
573    /// Write-behind autosave queue state and lifecycle counters.
574    autosave_queue: AutosaveQueue,
575    /// Current durability policy for shutdown final flush behavior.
576    autosave_durability: AutosaveDurabilityMode,
577
578    // -- Incremental append state --
579    /// Number of entries already persisted to disk (high-water mark).
580    /// Uses Arc<AtomicUsize> to allow atomic updates from detached background threads,
581    /// ensuring state consistency even if the save future is dropped/cancelled.
582    persisted_entry_count: Arc<AtomicUsize>,
583    /// True when header was modified since last save (forces full rewrite).
584    header_dirty: bool,
585    /// Incremental appends since last full rewrite (checkpoint counter).
586    appends_since_checkpoint: u64,
587    /// Sidecar root when session was loaded from V2 storage.
588    v2_sidecar_root: Option<PathBuf>,
589    /// True when current in-memory entries are a partial hydration view from V2.
590    v2_partial_hydration: bool,
591    /// Resume mode used when loading from V2 sidecar.
592    v2_resume_mode: Option<V2OpenMode>,
593    /// Offset to add to `cached_message_count` to account for messages not loaded in memory
594    /// (e.g. when using V2 tail hydration).
595    v2_message_count_offset: u64,
596}
597
598impl Clone for Session {
599    fn clone(&self) -> Self {
600        Self {
601            header: self.header.clone(),
602            entries: self.entries.clone(),
603            path: self.path.clone(),
604            leaf_id: self.leaf_id.clone(),
605            session_dir: self.session_dir.clone(),
606            store_kind: self.store_kind,
607            entry_ids: self.entry_ids.clone(),
608            is_linear: self.is_linear,
609            entry_index: self.entry_index.clone(),
610            cached_message_count: self.cached_message_count,
611            cached_name: self.cached_name.clone(),
612            autosave_queue: self.autosave_queue.clone(),
613            autosave_durability: self.autosave_durability,
614            // Deep copy the atomic value to preserve value semantics for clones.
615            // If we just cloned the Arc, a save on the clone would increment the
616            // counter on the original, desynchronizing it from its own entries.
617            persisted_entry_count: Arc::new(AtomicUsize::new(
618                self.persisted_entry_count.load(Ordering::SeqCst),
619            )),
620            header_dirty: self.header_dirty,
621            appends_since_checkpoint: self.appends_since_checkpoint,
622            v2_sidecar_root: self.v2_sidecar_root.clone(),
623            v2_partial_hydration: self.v2_partial_hydration,
624            v2_resume_mode: self.v2_resume_mode,
625            v2_message_count_offset: self.v2_message_count_offset,
626        }
627    }
628}
629
630/// Result of planning a `/fork` operation from a specific user message.
631///
632/// Mirrors legacy semantics:
633/// - The new session's leaf is the *parent* of the selected user message (or `None` if root),
634///   so the selected message can be re-submitted as a new branch without creating consecutive
635///   user messages.
636/// - The selected user message text is returned for editor pre-fill.
637#[derive(Debug, Clone)]
638pub struct ForkPlan {
639    /// Entries to copy into the new session file (path to the fork leaf, inclusive).
640    pub entries: Vec<SessionEntry>,
641    /// Leaf ID to set in the new session (parent of selected user entry).
642    pub leaf_id: Option<String>,
643    /// Text of the selected user message (for editor pre-fill).
644    pub selected_text: String,
645}
646
647/// Lightweight snapshot of session data for non-blocking export.
648///
649/// Captures only the header and entries needed for HTML rendering,
650/// avoiding a full `Session` clone (which includes caches, autosave
651/// queue, and other internal state).
652#[derive(Debug, Clone)]
653pub struct ExportSnapshot {
654    /// Session header (id, timestamp, cwd).
655    pub header: SessionHeader,
656    /// Session entries to render.
657    pub entries: Vec<SessionEntry>,
658    /// Session file path (for default output filename).
659    pub path: Option<PathBuf>,
660}
661
662impl ExportSnapshot {
663    /// Render this snapshot as a standalone HTML document.
664    ///
665    /// Delegates to the shared rendering logic used by `Session::to_html()`.
666    pub fn to_html(&self) -> String {
667        render_session_html(&self.header, &self.entries)
668    }
669}
670
671/// Diagnostics captured while opening a session file.
672#[derive(Debug, Clone, Default)]
673pub struct SessionOpenDiagnostics {
674    pub skipped_entries: Vec<SessionOpenSkippedEntry>,
675    pub orphaned_parent_links: Vec<SessionOpenOrphanedParentLink>,
676}
677
678#[derive(Debug, Clone)]
679pub struct SessionOpenSkippedEntry {
680    /// 1-based line number in the session file.
681    pub line_number: usize,
682    pub error: String,
683}
684
685#[derive(Debug, Clone)]
686pub struct SessionOpenOrphanedParentLink {
687    pub entry_id: String,
688    pub missing_parent_id: String,
689}
690
691/// Loading strategy for reconstructing a `Session` from a V2 store.
692#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693pub enum V2OpenMode {
694    Full,
695    ActivePath,
696    Tail(u64),
697}
698
699const DEFAULT_V2_LAZY_HYDRATION_THRESHOLD: u64 = 10_000;
700const DEFAULT_V2_TAIL_HYDRATION_COUNT: u64 = 256;
701
702fn parse_v2_open_mode(raw: &str) -> Option<V2OpenMode> {
703    let normalized = raw.trim().to_ascii_lowercase();
704    if normalized.is_empty() {
705        return None;
706    }
707    match normalized.as_str() {
708        "full" => Some(V2OpenMode::Full),
709        "active" | "active_path" | "active-path" => Some(V2OpenMode::ActivePath),
710        "tail" => Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT)),
711        _ => normalized
712            .strip_prefix("tail:")
713            .and_then(|value| value.parse::<u64>().ok().map(V2OpenMode::Tail)),
714    }
715}
716
717fn resolve_v2_lazy_hydration_threshold(env_raw: Option<&str>) -> u64 {
718    env_raw
719        .and_then(|raw| raw.trim().parse::<u64>().ok())
720        .unwrap_or(DEFAULT_V2_LAZY_HYDRATION_THRESHOLD)
721}
722
723fn select_v2_open_mode_for_resume(
724    entry_count: u64,
725    mode_override_raw: Option<&str>,
726    threshold_override_raw: Option<&str>,
727) -> (V2OpenMode, &'static str, u64) {
728    let lazy_threshold = resolve_v2_lazy_hydration_threshold(threshold_override_raw);
729    if let Some(raw) = mode_override_raw {
730        if let Some(mode) = parse_v2_open_mode(raw) {
731            return (mode, "env_override", lazy_threshold);
732        }
733    }
734
735    if lazy_threshold > 0 && entry_count > lazy_threshold {
736        return (
737            V2OpenMode::ActivePath,
738            "entry_count_above_lazy_threshold",
739            lazy_threshold,
740        );
741    }
742
743    (V2OpenMode::Full, "default_full", lazy_threshold)
744}
745
746impl SessionOpenDiagnostics {
747    fn warning_lines(&self) -> Vec<String> {
748        let mut lines = Vec::new();
749        for skipped in &self.skipped_entries {
750            lines.push(format!(
751                "Warning: Skipping corrupted entry at line {} in session file: {}",
752                skipped.line_number, skipped.error
753            ));
754        }
755
756        if !self.skipped_entries.is_empty() {
757            lines.push(format!(
758                "Warning: Skipped {} corrupted entries while loading session",
759                self.skipped_entries.len()
760            ));
761        }
762
763        for orphan in &self.orphaned_parent_links {
764            lines.push(format!(
765                "Warning: Entry {} references missing parent {}",
766                orphan.entry_id, orphan.missing_parent_id
767            ));
768        }
769
770        if !self.orphaned_parent_links.is_empty() {
771            lines.push(format!(
772                "Warning: Detected {} orphaned parent links while loading session",
773                self.orphaned_parent_links.len()
774            ));
775        }
776
777        lines
778    }
779}
780
781impl Session {
782    /// Create a new session from CLI args and config.
783    pub async fn new(cli: &Cli, config: &Config) -> Result<Self> {
784        let session_dir = cli.session_dir.as_ref().map(PathBuf::from);
785        let durability_mode = resolve_autosave_durability_mode(
786            cli.session_durability.as_deref(),
787            config.session_durability.as_deref(),
788            std::env::var("PI_SESSION_DURABILITY_MODE").ok().as_deref(),
789        );
790        if cli.no_session {
791            let mut session = Self::in_memory();
792            session.set_autosave_durability_mode(durability_mode);
793            return Ok(session);
794        }
795
796        if let Some(path) = &cli.session {
797            let mut session = Self::open(path).await?;
798            session.set_autosave_durability_mode(durability_mode);
799            return Ok(session);
800        }
801
802        if cli.resume {
803            let picker_input_override = config
804                .session_picker_input
805                .filter(|value| *value > 0)
806                .map(|value| value.to_string());
807            let mut session = Box::pin(Self::resume_with_picker(
808                session_dir.as_deref(),
809                config,
810                picker_input_override,
811            ))
812            .await?;
813            session.set_autosave_durability_mode(durability_mode);
814            return Ok(session);
815        }
816
817        if cli.r#continue {
818            let mut session = Self::continue_recent_in_dir(session_dir.as_deref(), config).await?;
819            session.set_autosave_durability_mode(durability_mode);
820            return Ok(session);
821        }
822
823        let store_kind = SessionStoreKind::from_config(config);
824        let mut session = Self::create_with_dir_and_store(session_dir, store_kind);
825        session.set_autosave_durability_mode(durability_mode);
826
827        // Create a new session
828        Ok(session)
829    }
830
831    /// Resume a session by prompting the user to select from recent sessions.
832    #[allow(clippy::too_many_lines)]
833    pub async fn resume_with_picker(
834        override_dir: Option<&Path>,
835        config: &Config,
836        picker_input_override: Option<String>,
837    ) -> Result<Self> {
838        let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
839        let mut picker_input_override = picker_input_override;
840        if picker_input_override.is_none() && is_interactive {
841            if let Some(session) = crate::session_picker::pick_session(override_dir).await {
842                return Ok(session);
843            }
844        }
845
846        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
847        let store_kind = SessionStoreKind::from_config(config);
848        let cwd = std::env::current_dir()?;
849        let encoded_cwd = encode_cwd(&cwd);
850        let project_session_dir = base_dir.join(&encoded_cwd);
851
852        if !project_session_dir.exists() {
853            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
854        }
855
856        let base_dir_clone = base_dir.clone();
857        let cwd_display = cwd.display().to_string();
858        let (tx, rx) = oneshot::channel();
859
860        thread::spawn(move || {
861            let entries: Vec<SessionPickEntry> = SessionIndex::for_sessions_root(&base_dir_clone)
862                .list_sessions(Some(&cwd_display))
863                .map(|list| {
864                    list.into_iter()
865                        .filter_map(SessionPickEntry::from_meta)
866                        .collect()
867                })
868                .unwrap_or_default();
869            let cx = AgentCx::for_request();
870            let _ = tx.send(cx.cx(), entries);
871        });
872
873        let cx = AgentCx::for_request();
874        let entries = rx.recv(cx.cx()).await.unwrap_or_default();
875
876        let scanned = scan_sessions_on_disk(&project_session_dir, entries.clone()).await?;
877        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
878        for entry in entries.into_iter().chain(scanned.into_iter()) {
879            by_path
880                .entry(entry.path.clone())
881                .and_modify(|existing| {
882                    if entry.last_modified_ms > existing.last_modified_ms {
883                        *existing = entry.clone();
884                    }
885                })
886                .or_insert(entry);
887        }
888        let mut entries = by_path.into_values().collect::<Vec<_>>();
889
890        if entries.is_empty() {
891            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
892        }
893
894        entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
895        let max_entries = 20usize.min(entries.len());
896        let mut entries = entries.into_iter().take(max_entries).collect::<Vec<_>>();
897
898        let console = PiConsole::new();
899        console.render_info("Select a session to resume:");
900
901        let headers = ["#", "Timestamp", "Messages", "Name", "Path"];
902
903        let mut attempts = 0;
904        loop {
905            if entries.is_empty() {
906                console.render_warning("No resumable sessions available. Starting a new session.");
907                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
908            }
909
910            let mut rows: Vec<Vec<String>> = Vec::new();
911            for (idx, entry) in entries.iter().enumerate() {
912                rows.push(vec![
913                    format!("{}", idx + 1),
914                    entry.timestamp.clone(),
915                    entry.message_count.to_string(),
916                    entry.name.clone().unwrap_or_else(|| entry.id.clone()),
917                    entry.path.display().to_string(),
918                ]);
919            }
920            let row_refs: Vec<Vec<&str>> = rows
921                .iter()
922                .map(|row| row.iter().map(String::as_str).collect())
923                .collect();
924            console.render_table(&headers, &row_refs);
925
926            attempts += 1;
927            if attempts > 3 {
928                console.render_warning("No selection made. Starting a new session.");
929                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
930            }
931
932            print!(
933                "Enter selection (1-{}, blank to start new): ",
934                entries.len()
935            );
936            let _ = std::io::stdout().flush();
937
938            let input = if let Some(override_input) = picker_input_override.take() {
939                override_input
940            } else {
941                let mut input = String::new();
942                std::io::stdin().read_line(&mut input)?;
943                input
944            };
945            let input = input.trim();
946            if input.is_empty() {
947                console.render_info("Starting a new session.");
948                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
949            }
950
951            match input.parse::<usize>() {
952                Ok(index) if index > 0 && index <= entries.len() => {
953                    let selected = &entries[index - 1];
954                    match Self::open(selected.path.to_string_lossy().as_ref()).await {
955                        Ok(mut session) => {
956                            session.session_dir = Some(base_dir.clone());
957                            return Ok(session);
958                        }
959                        Err(err) => {
960                            tracing::warn!(
961                                path = %selected.path.display(),
962                                error = %err,
963                                "Failed to open selected session while resuming"
964                            );
965                            entries.remove(index - 1);
966
967                            if is_interactive {
968                                console.render_warning(
969                                    "Selected session could not be opened. Pick another session.",
970                                );
971                                continue;
972                            }
973
974                            console.render_warning(
975                                "Selected session could not be opened. Starting a new session.",
976                            );
977                            return Ok(Self::create_with_dir_and_store(
978                                Some(base_dir.clone()),
979                                store_kind,
980                            ));
981                        }
982                    }
983                }
984                _ => {
985                    console.render_warning("Invalid selection. Try again.");
986                }
987            }
988        }
989    }
990
991    /// Create an in-memory (ephemeral) session.
992    pub fn in_memory() -> Self {
993        Self {
994            header: SessionHeader::new(),
995            entries: Vec::new(),
996            path: None,
997            leaf_id: None,
998            session_dir: None,
999            store_kind: SessionStoreKind::Jsonl,
1000            entry_ids: HashSet::new(),
1001            is_linear: true,
1002            entry_index: HashMap::new(),
1003            cached_message_count: 0,
1004            cached_name: None,
1005            autosave_queue: AutosaveQueue::new(),
1006            autosave_durability: AutosaveDurabilityMode::from_env(),
1007            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1008            header_dirty: false,
1009            appends_since_checkpoint: 0,
1010            v2_sidecar_root: None,
1011            v2_partial_hydration: false,
1012            v2_resume_mode: None,
1013            v2_message_count_offset: 0,
1014        }
1015    }
1016
1017    /// Create a new session.
1018    pub fn create() -> Self {
1019        Self::create_with_dir(None)
1020    }
1021
1022    /// Create a new session with an optional base directory override.
1023    pub fn create_with_dir(session_dir: Option<PathBuf>) -> Self {
1024        Self::create_with_dir_and_store(session_dir, SessionStoreKind::Jsonl)
1025    }
1026
1027    pub fn create_with_dir_and_store(
1028        session_dir: Option<PathBuf>,
1029        store_kind: SessionStoreKind,
1030    ) -> Self {
1031        let header = SessionHeader::new();
1032        Self {
1033            header,
1034            entries: Vec::new(),
1035            path: None,
1036            leaf_id: None,
1037            session_dir,
1038            store_kind,
1039            entry_ids: HashSet::new(),
1040            is_linear: true,
1041            entry_index: HashMap::new(),
1042            cached_message_count: 0,
1043            cached_name: None,
1044            autosave_queue: AutosaveQueue::new(),
1045            autosave_durability: AutosaveDurabilityMode::from_env(),
1046            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1047            header_dirty: false,
1048            appends_since_checkpoint: 0,
1049            v2_sidecar_root: None,
1050            v2_partial_hydration: false,
1051            v2_resume_mode: None,
1052            v2_message_count_offset: 0,
1053        }
1054    }
1055
1056    /// Open an existing session.
1057    pub async fn open(path: &str) -> Result<Self> {
1058        let (session, diagnostics) = Self::open_with_diagnostics(path).await?;
1059        for warning in diagnostics.warning_lines() {
1060            eprintln!("{warning}");
1061        }
1062        Ok(session)
1063    }
1064
1065    /// Open an existing session and return diagnostics about any recovered corruption.
1066    pub async fn open_with_diagnostics(path: &str) -> Result<(Self, SessionOpenDiagnostics)> {
1067        let path = PathBuf::from(path);
1068        if !path.exists() {
1069            return Err(crate::Error::SessionNotFound {
1070                path: path.display().to_string(),
1071            });
1072        }
1073
1074        if path.extension().is_some_and(|ext| ext == "sqlite") {
1075            #[cfg(feature = "sqlite-sessions")]
1076            {
1077                let session = Self::open_sqlite(&path).await?;
1078                return Ok((session, SessionOpenDiagnostics::default()));
1079            }
1080
1081            #[cfg(not(feature = "sqlite-sessions"))]
1082            {
1083                return Err(Error::session(
1084                    "SQLite session files require building with `--features sqlite-sessions`",
1085                ));
1086            }
1087        }
1088
1089        // Check for V2 sidecar store — enables O(index+tail) resume.
1090        if session_store_v2::has_v2_sidecar(&path) {
1091            let is_stale = (|| -> Option<bool> {
1092                let jsonl_meta = std::fs::metadata(&path).ok()?;
1093
1094                let v2_root = session_store_v2::v2_sidecar_path(&path);
1095                let v2_index = v2_root.join("index").join("offsets.jsonl");
1096                let v2_manifest = v2_root.join("manifest.json");
1097
1098                // Check index first, fallback to manifest
1099                let v2_meta = std::fs::metadata(&v2_index)
1100                    .or_else(|_| std::fs::metadata(&v2_manifest))
1101                    .ok()?;
1102
1103                let jsonl_mtime = jsonl_meta.modified().ok()?;
1104                let v2_mtime = v2_meta.modified().ok()?;
1105                Some(jsonl_mtime > v2_mtime)
1106            })()
1107            .unwrap_or(true); // Default to true (stale) if metadata cannot be verified
1108
1109            if is_stale {
1110                tracing::warn!(
1111                    path = %path.display(),
1112                    "V2 sidecar is stale (source JSONL newer); skipping V2 resume"
1113                );
1114            } else {
1115                match Self::open_v2_with_diagnostics(&path).await {
1116                    Ok(result) => return Ok(result),
1117                    Err(e) => {
1118                        tracing::warn!(
1119                            path = %path.display(),
1120                            error = %e,
1121                            "V2 sidecar resume failed, falling back to full JSONL parse"
1122                        );
1123                    }
1124                }
1125            }
1126        }
1127
1128        Self::open_jsonl_with_diagnostics(&path).await
1129    }
1130
1131    /// Open a session from an already-open V2 store with an explicit read mode.
1132    pub fn open_from_v2(
1133        store: &SessionStoreV2,
1134        header: SessionHeader,
1135        mode: V2OpenMode,
1136    ) -> Result<(Self, SessionOpenDiagnostics)> {
1137        let frames = match mode {
1138            V2OpenMode::Full => store.read_all_entries()?,
1139            V2OpenMode::ActivePath => match store.head() {
1140                Some(head) => store.read_active_path(&head.entry_id)?,
1141                None => Vec::new(),
1142            },
1143            V2OpenMode::Tail(count) => store.read_tail_entries(count)?,
1144        };
1145
1146        let mut diagnostics = SessionOpenDiagnostics::default();
1147        let mut entries = Vec::with_capacity(frames.len());
1148        for frame in &frames {
1149            match session_store_v2::frame_to_session_entry(frame) {
1150                Ok(entry) => entries.push(entry),
1151                Err(e) => {
1152                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
1153                        line_number: usize::try_from(frame.entry_seq).unwrap_or(0),
1154                        error: e.to_string(),
1155                    });
1156                }
1157            }
1158        }
1159
1160        let finalized = finalize_loaded_entries(&mut entries);
1161        for orphan in &finalized.orphans {
1162            diagnostics
1163                .orphaned_parent_links
1164                .push(SessionOpenOrphanedParentLink {
1165                    entry_id: orphan.0.clone(),
1166                    missing_parent_id: orphan.1.clone(),
1167                });
1168        }
1169
1170        let mut v2_message_count_offset = 0;
1171        if matches!(mode, V2OpenMode::Tail(_)) {
1172            if let Ok(Some(manifest)) = store.read_manifest() {
1173                let total = manifest.counters.messages_total;
1174                let loaded = finalized.message_count;
1175                v2_message_count_offset = total.saturating_sub(loaded);
1176            }
1177        }
1178
1179        let entry_count = entries.len();
1180        Ok((
1181            Self {
1182                header,
1183                entries,
1184                path: None,
1185                leaf_id: finalized.leaf_id,
1186                session_dir: None,
1187                store_kind: SessionStoreKind::Jsonl,
1188                entry_ids: finalized.entry_ids,
1189                is_linear: finalized.is_linear,
1190                entry_index: finalized.entry_index,
1191                cached_message_count: finalized
1192                    .message_count
1193                    .saturating_add(v2_message_count_offset),
1194                cached_name: finalized.name,
1195                autosave_queue: AutosaveQueue::new(),
1196                autosave_durability: AutosaveDurabilityMode::from_env(),
1197                persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1198                header_dirty: false,
1199                appends_since_checkpoint: 0,
1200                v2_sidecar_root: None,
1201                v2_partial_hydration: !matches!(mode, V2OpenMode::Full),
1202                v2_resume_mode: Some(mode),
1203                v2_message_count_offset,
1204            },
1205            diagnostics,
1206        ))
1207    }
1208
1209    /// Open using the V2 sidecar store (async wrapper around blocking read).
1210    async fn open_v2_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1211        let path_buf = path.to_path_buf();
1212        let (tx, rx) = oneshot::channel();
1213
1214        thread::spawn(move || {
1215            let res = crate::session::open_from_v2_store_blocking(path_buf);
1216            let cx = AgentCx::for_request();
1217            let _ = tx.send(cx.cx(), res);
1218        });
1219
1220        let cx = AgentCx::for_request();
1221        rx.recv(cx.cx())
1222            .await
1223            .map_err(|_| crate::Error::session("V2 open task cancelled"))?
1224    }
1225
1226    async fn open_jsonl_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1227        let path_buf = path.to_path_buf();
1228        let (tx, rx) = oneshot::channel();
1229
1230        thread::spawn(move || {
1231            let res = open_jsonl_blocking(path_buf);
1232            let cx = AgentCx::for_request();
1233            let _ = tx.send(cx.cx(), res);
1234        });
1235
1236        let cx = AgentCx::for_request();
1237        rx.recv(cx.cx())
1238            .await
1239            .map_err(|_| crate::Error::session("Open task cancelled"))?
1240    }
1241
1242    #[cfg(feature = "sqlite-sessions")]
1243    async fn open_sqlite(path: &Path) -> Result<Self> {
1244        let (header, mut entries) = crate::session_sqlite::load_session(path).await?;
1245        let finalized = finalize_loaded_entries(&mut entries);
1246        let entry_count = entries.len();
1247
1248        Ok(Self {
1249            header,
1250            entries,
1251            path: Some(path.to_path_buf()),
1252            leaf_id: finalized.leaf_id,
1253            session_dir: None,
1254            store_kind: SessionStoreKind::Sqlite,
1255            entry_ids: finalized.entry_ids,
1256            is_linear: finalized.is_linear,
1257            entry_index: finalized.entry_index,
1258            cached_message_count: finalized.message_count,
1259            cached_name: finalized.name,
1260            autosave_queue: AutosaveQueue::new(),
1261            autosave_durability: AutosaveDurabilityMode::from_env(),
1262            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1263            header_dirty: false,
1264            appends_since_checkpoint: 0,
1265            v2_sidecar_root: None,
1266            v2_partial_hydration: false,
1267            v2_resume_mode: None,
1268            v2_message_count_offset: 0,
1269        })
1270    }
1271
1272    /// Continue the most recent session.
1273    pub async fn continue_recent_in_dir(
1274        override_dir: Option<&Path>,
1275        config: &Config,
1276    ) -> Result<Self> {
1277        let store_kind = SessionStoreKind::from_config(config);
1278        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1279        let cwd = std::env::current_dir()?;
1280        let cwd_display = cwd.display().to_string();
1281        let encoded_cwd = encode_cwd(&cwd);
1282        let project_session_dir = base_dir.join(&encoded_cwd);
1283
1284        if !project_session_dir.exists() {
1285            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1286        }
1287
1288        // Prefer the session index for fast lookup.
1289        let base_dir_clone = base_dir.clone();
1290        let cwd_display_clone = cwd_display.clone();
1291        let (tx, rx) = oneshot::channel();
1292
1293        thread::spawn(move || {
1294            let index = SessionIndex::for_sessions_root(&base_dir_clone);
1295            let mut indexed_sessions: Vec<SessionPickEntry> = index
1296                .list_sessions(Some(&cwd_display_clone))
1297                .map(|list| {
1298                    list.into_iter()
1299                        .filter_map(SessionPickEntry::from_meta)
1300                        .collect()
1301                })
1302                .unwrap_or_default();
1303
1304            if indexed_sessions.is_empty() && index.reindex_all().is_ok() {
1305                indexed_sessions = index
1306                    .list_sessions(Some(&cwd_display_clone))
1307                    .map(|list| {
1308                        list.into_iter()
1309                            .filter_map(SessionPickEntry::from_meta)
1310                            .collect()
1311                    })
1312                    .unwrap_or_default();
1313            }
1314            let cx = AgentCx::for_request();
1315            let _ = tx.send(cx.cx(), indexed_sessions);
1316        });
1317
1318        let cx = AgentCx::for_request();
1319        let indexed_sessions = rx.recv(cx.cx()).await.unwrap_or_default();
1320
1321        let scanned = scan_sessions_on_disk(&project_session_dir, indexed_sessions.clone()).await?;
1322
1323        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1324        for entry in indexed_sessions.into_iter().chain(scanned.into_iter()) {
1325            by_path
1326                .entry(entry.path.clone())
1327                .and_modify(|existing| {
1328                    if entry.last_modified_ms > existing.last_modified_ms {
1329                        *existing = entry.clone();
1330                    }
1331                })
1332                .or_insert(entry);
1333        }
1334
1335        let mut candidates = by_path.into_values().collect::<Vec<_>>();
1336        candidates.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1337
1338        for entry in &candidates {
1339            match Self::open(entry.path.to_string_lossy().as_ref()).await {
1340                Ok(mut session) => {
1341                    session.session_dir = Some(base_dir.clone());
1342                    return Ok(session);
1343                }
1344                Err(err) => {
1345                    tracing::warn!(
1346                        path = %entry.path.display(),
1347                        error = %err,
1348                        "Skipping unreadable session candidate while continuing"
1349                    );
1350                }
1351            }
1352        }
1353
1354        Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind))
1355    }
1356
1357    /// Save the session to disk.
1358    pub async fn save(&mut self) -> Result<()> {
1359        let ticket = self
1360            .autosave_queue
1361            .begin_flush(AutosaveFlushTrigger::Manual);
1362        let result = self.save_inner().await;
1363        if let Some(ticket) = ticket {
1364            self.autosave_queue.finish_flush(ticket, result.is_ok());
1365        }
1366        result
1367    }
1368
1369    /// Flush queued autosave mutations using the requested trigger.
1370    ///
1371    /// This is the write-behind entry point: no-op when there are no pending
1372    /// mutations, and one persistence operation for all coalesced mutations when
1373    /// pending work exists.
1374    pub async fn flush_autosave(&mut self, trigger: AutosaveFlushTrigger) -> Result<()> {
1375        let Some(ticket) = self.autosave_queue.begin_flush(trigger) else {
1376            return Ok(());
1377        };
1378        let result = self.save_inner().await;
1379        self.autosave_queue.finish_flush(ticket, result.is_ok());
1380        result
1381    }
1382
1383    /// Final shutdown flush respecting the configured durability mode.
1384    pub async fn flush_autosave_on_shutdown(&mut self) -> Result<()> {
1385        if !self.autosave_durability.should_flush_on_shutdown() {
1386            return Ok(());
1387        }
1388        let result = self.flush_autosave(AutosaveFlushTrigger::Shutdown).await;
1389        if result.is_err() && self.autosave_durability.best_effort_on_shutdown() {
1390            if let Err(err) = &result {
1391                tracing::warn!(error = %err, "best-effort autosave flush failed during shutdown");
1392            }
1393            return Ok(());
1394        }
1395        result
1396    }
1397
1398    /// Current autosave queue and lifecycle counters for observability.
1399    pub const fn autosave_metrics(&self) -> AutosaveQueueMetrics {
1400        self.autosave_queue.metrics()
1401    }
1402
1403    pub const fn autosave_durability_mode(&self) -> AutosaveDurabilityMode {
1404        self.autosave_durability
1405    }
1406
1407    pub const fn set_autosave_durability_mode(&mut self, mode: AutosaveDurabilityMode) {
1408        self.autosave_durability = mode;
1409    }
1410
1411    #[cfg(test)]
1412    fn set_autosave_queue_limit_for_test(&mut self, max_pending_mutations: usize) {
1413        self.autosave_queue = AutosaveQueue::with_limit(max_pending_mutations);
1414    }
1415
1416    #[cfg(test)]
1417    const fn set_autosave_durability_for_test(&mut self, mode: AutosaveDurabilityMode) {
1418        self.autosave_durability = mode;
1419    }
1420
1421    /// Ensure a lazily hydrated V2 session is fully hydrated before persisting.
1422    ///
1423    /// Partial V2 hydration intentionally loads only a subset of entries for fast
1424    /// resume. Before any save path that could trigger a full JSONL rewrite, we
1425    /// must rehydrate all V2 entries to preserve non-active branches.
1426    fn ensure_full_v2_hydration_before_save(&mut self) -> Result<()> {
1427        if !self.v2_partial_hydration {
1428            return Ok(());
1429        }
1430
1431        let Some(v2_root) = self.v2_sidecar_root.clone() else {
1432            tracing::warn!(
1433                "session marked as partially hydrated from V2 but sidecar root is unavailable; disabling partial flag"
1434            );
1435            self.v2_partial_hydration = false;
1436            return Ok(());
1437        };
1438
1439        let pending_start = self
1440            .persisted_entry_count
1441            .load(Ordering::SeqCst)
1442            .min(self.entries.len());
1443        let previous_mode = self.v2_resume_mode;
1444
1445        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
1446        let (fully_hydrated, diagnostics) =
1447            Self::open_from_v2(&store, self.header.clone(), V2OpenMode::Full)?;
1448        if !diagnostics.skipped_entries.is_empty() || !diagnostics.orphaned_parent_links.is_empty()
1449        {
1450            tracing::error!(
1451                skipped_entries = diagnostics.skipped_entries.len(),
1452                orphaned_parent_links = diagnostics.orphaned_parent_links.len(),
1453                "full V2 rehydration before save failed integrity check; aborting save to prevent data loss"
1454            );
1455            return Err(Error::session(format!(
1456                "V2 rehydration failed with {} skipped entries and {} orphaned links",
1457                diagnostics.skipped_entries.len(),
1458                diagnostics.orphaned_parent_links.len()
1459            )));
1460        }
1461
1462        // Extract pending in-memory entries by moving them out of `self.entries`
1463        // only after full hydration succeeds, preserving fail-safe behavior on
1464        // early-return errors and avoiding per-entry clone cost.
1465        let pending_entries = if pending_start >= self.entries.len() {
1466            Vec::new()
1467        } else {
1468            self.entries.split_off(pending_start)
1469        };
1470
1471        let persisted_entry_count = fully_hydrated.entries.len();
1472        let mut merged_entries = fully_hydrated.entries;
1473        merged_entries.extend(pending_entries);
1474
1475        let finalized = finalize_loaded_entries(&mut merged_entries);
1476        self.entries = merged_entries;
1477        self.leaf_id = finalized.leaf_id;
1478        self.entry_ids = finalized.entry_ids;
1479        self.is_linear = finalized.is_linear;
1480        self.entry_index = finalized.entry_index;
1481        self.cached_message_count = finalized.message_count;
1482        self.cached_name = finalized.name;
1483        self.persisted_entry_count
1484            .store(persisted_entry_count, Ordering::SeqCst);
1485        self.v2_partial_hydration = false;
1486        self.v2_resume_mode = Some(V2OpenMode::Full);
1487        self.v2_message_count_offset = 0;
1488
1489        tracing::debug!(
1490            previous_mode = ?previous_mode,
1491            persisted_entry_count,
1492            pending_entries = self.entries.len().saturating_sub(persisted_entry_count),
1493            "fully rehydrated V2 session before save"
1494        );
1495
1496        Ok(())
1497    }
1498
1499    /// Returns `true` when a full rewrite is required instead of incremental append.
1500    fn should_full_rewrite(&self) -> bool {
1501        let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
1502
1503        // First save — no file exists yet.
1504        if persisted_count == 0 {
1505            return true;
1506        }
1507        // Header was modified since last save.
1508        if self.header_dirty {
1509            return true;
1510        }
1511        // Periodic checkpoint to clean up accumulated partial writes.
1512        if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
1513            return true;
1514        }
1515        // Defensive: if persisted count somehow exceeds entries, force full rewrite.
1516        if persisted_count > self.entries.len() {
1517            return true;
1518        }
1519        false
1520    }
1521
1522    /// Save the session to disk.
1523    #[allow(clippy::too_many_lines)]
1524    async fn save_inner(&mut self) -> Result<()> {
1525        self.ensure_entry_ids();
1526
1527        let store_kind = match self
1528            .path
1529            .as_ref()
1530            .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
1531        {
1532            Some("jsonl") => SessionStoreKind::Jsonl,
1533            Some("sqlite") => {
1534                #[cfg(feature = "sqlite-sessions")]
1535                {
1536                    SessionStoreKind::Sqlite
1537                }
1538
1539                #[cfg(not(feature = "sqlite-sessions"))]
1540                {
1541                    return Err(Error::session(
1542                        "SQLite session files require building with `--features sqlite-sessions`",
1543                    ));
1544                }
1545            }
1546            _ => self.store_kind,
1547        };
1548
1549        if self.path.is_none() {
1550            // Create a new path
1551            let base_dir = self
1552                .session_dir
1553                .clone()
1554                .unwrap_or_else(Config::sessions_dir);
1555            let cwd = std::env::current_dir()?;
1556            let encoded_cwd = encode_cwd(&cwd);
1557            let project_session_dir = base_dir.join(&encoded_cwd);
1558
1559            asupersync::fs::create_dir_all(&project_session_dir).await?;
1560
1561            let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ");
1562            // Robust against malformed/legacy session ids: keep a short, filename-safe suffix.
1563            let short_id = {
1564                let prefix: String = self
1565                    .header
1566                    .id
1567                    .chars()
1568                    .take(8)
1569                    .map(|ch| {
1570                        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1571                            ch
1572                        } else {
1573                            '_'
1574                        }
1575                    })
1576                    .collect();
1577                if prefix.trim_matches('_').is_empty() {
1578                    "session".to_string()
1579                } else {
1580                    prefix
1581                }
1582            };
1583            let filename = format!("{}_{}.{}", timestamp, short_id, store_kind.extension());
1584            self.path = Some(project_session_dir.join(filename));
1585        }
1586
1587        let session_dir_clone = self.session_dir.clone();
1588        let path = self.path.clone().unwrap();
1589        let path_clone = path.clone();
1590
1591        match store_kind {
1592            SessionStoreKind::Jsonl => {
1593                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
1594
1595                if self.should_full_rewrite() {
1596                    if self.v2_partial_hydration {
1597                        self.ensure_full_v2_hydration_before_save()?;
1598                    }
1599                    // Gap C: use incrementally maintained stats instead of O(n) scan.
1600                    let message_count = self.cached_message_count;
1601
1602                    let session_name = self.cached_name.clone();
1603                    // === Full rewrite path (first save, header change, checkpoint) ===
1604                    let (tx, rx) = oneshot::channel::<JsonlSaveResult>();
1605
1606                    let header_snapshot = self.header.clone();
1607                    let entries_to_save = std::mem::take(&mut self.entries);
1608
1609                    let path_for_thread = path_clone.clone();
1610                    let handle = thread::spawn(move || {
1611                        let entries = entries_to_save;
1612                        let res = (|| -> Result<()> {
1613                            let parent = path_for_thread.parent().unwrap_or_else(|| Path::new("."));
1614                            let temp_file = tempfile::NamedTempFile::new_in(parent)?;
1615                            {
1616                                let mut writer =
1617                                    std::io::BufWriter::with_capacity(1 << 20, temp_file.as_file());
1618                                serde_json::to_writer(&mut writer, &header_snapshot)?;
1619                                writer.write_all(b"\n")?;
1620                                for entry in &entries {
1621                                    serde_json::to_writer(&mut writer, entry)?;
1622                                    writer.write_all(b"\n")?;
1623                                }
1624                                writer.flush()?;
1625                            }
1626                            temp_file
1627                                .persist(&path_for_thread)
1628                                .map_err(|e| crate::Error::Io(Box::new(e.error)))?;
1629
1630                            enqueue_session_index_snapshot_update(
1631                                &sessions_root,
1632                                &path_for_thread,
1633                                &header_snapshot,
1634                                message_count,
1635                                session_name,
1636                            );
1637                            Ok(())
1638                        })();
1639                        let cx = AgentCx::for_request();
1640                        if tx
1641                            .send(
1642                                cx.cx(),
1643                                match res {
1644                                    Ok(()) => Ok(entries),
1645                                    Err(err) => Err((err, entries)),
1646                                },
1647                            )
1648                            .is_err()
1649                        {
1650                            tracing::debug!(
1651                                "Session save task completed but receiver dropped (cancelled)"
1652                            );
1653                        }
1654                    });
1655
1656                    let cx = AgentCx::for_request();
1657                    let result = rx
1658                        .recv(cx.cx())
1659                        .await
1660                        .map_err(|_| crate::Error::session("Save task cancelled"))?;
1661
1662                    // Ensure background thread cleans up
1663                    if let Err(e) = handle.join() {
1664                        std::panic::resume_unwind(e); // Propagate panic if child panicked
1665                    }
1666
1667                    match result {
1668                        Ok(entries) => {
1669                            self.entries = entries;
1670                            // Keep derived caches as-is: save path does not mutate entry ordering/content.
1671                            self.persisted_entry_count
1672                                .store(self.entries.len(), Ordering::SeqCst);
1673                            self.header_dirty = false;
1674                            self.appends_since_checkpoint = 0;
1675                            Ok(())
1676                        }
1677                        Err((err, entries)) => {
1678                            self.entries = entries;
1679                            Err(err)
1680                        }
1681                    }?;
1682                } else {
1683                    let message_count = self.cached_message_count;
1684                    // === Incremental append path ===
1685                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
1686                    if new_start < self.entries.len() {
1687                        let session_name = self.cached_name.clone();
1688                        // Pre-serialize new entries into a single buffer (typically 1-3 entries).
1689                        let new_entries = &self.entries[new_start..];
1690                        // Scale buffer reservation from observed on-disk average entry size to
1691                        // avoid repeated growth/copy when appending large entries.
1692                        let estimated_entry_bytes = std::fs::metadata(&path_clone)
1693                            .ok()
1694                            .and_then(|meta| usize::try_from(meta.len()).ok())
1695                            .map_or(512, |file_bytes| {
1696                                let avg = file_bytes / new_start.max(1);
1697                                avg.clamp(512, 256 * 1024)
1698                            });
1699                        let mut serialized_buf = Vec::with_capacity(
1700                            new_entries
1701                                .len()
1702                                .saturating_mul(estimated_entry_bytes.saturating_add(1)),
1703                        );
1704                        for entry in new_entries {
1705                            serde_json::to_writer(&mut serialized_buf, entry)?;
1706                            serialized_buf.push(b'\n');
1707                        }
1708                        let new_count = self.entries.len();
1709
1710                        let (tx, rx) = oneshot::channel::<Result<()>>();
1711                        let header_snapshot = self.header.clone();
1712
1713                        let path_for_thread = path_clone.clone();
1714                        let handle = thread::spawn(move || {
1715                            let res = (move || -> Result<()> {
1716                                let mut file = std::fs::OpenOptions::new()
1717                                    .append(true)
1718                                    .open(&path_for_thread)
1719                                    .map_err(|e| crate::Error::Io(Box::new(e)))?;
1720
1721                                file.lock_exclusive()?;
1722                                file.write_all(&serialized_buf)?;
1723                                FileExt::unlock(&file)?;
1724
1725                                enqueue_session_index_snapshot_update(
1726                                    &sessions_root,
1727                                    &path_for_thread,
1728                                    &header_snapshot,
1729                                    message_count,
1730                                    session_name,
1731                                );
1732                                Ok(())
1733                            })();
1734                            let cx = AgentCx::for_request();
1735                            if tx.send(cx.cx(), res).is_err() {
1736                                tracing::debug!(
1737                                    "Session append task completed but receiver dropped (cancelled)"
1738                                );
1739                            }
1740                        });
1741
1742                        let cx = AgentCx::for_request();
1743                        let result = rx
1744                            .recv(cx.cx())
1745                            .await
1746                            .map_err(|_| crate::Error::session("Append task cancelled"))?;
1747
1748                        // Ensure background thread cleans up
1749                        if let Err(e) = handle.join() {
1750                            std::panic::resume_unwind(e); // Propagate panic if child panicked
1751                        }
1752
1753                        if result.is_ok() {
1754                            self.persisted_entry_count
1755                                .store(new_count, Ordering::SeqCst);
1756                            self.appends_since_checkpoint += 1;
1757                        }
1758                        result?;
1759                    }
1760                    // No new entries → no-op, nothing to write.
1761                }
1762            }
1763            #[cfg(feature = "sqlite-sessions")]
1764            SessionStoreKind::Sqlite => {
1765                let message_count = self.cached_message_count;
1766                let session_name = self.cached_name.clone();
1767
1768                if self.should_full_rewrite() {
1769                    // === Full rewrite path (first save, header change, checkpoint) ===
1770                    crate::session_sqlite::save_session(&path_clone, &self.header, &self.entries)
1771                        .await?;
1772                    self.persisted_entry_count
1773                        .store(self.entries.len(), Ordering::SeqCst);
1774                    self.header_dirty = false;
1775                    self.appends_since_checkpoint = 0;
1776                } else {
1777                    // === Incremental append path ===
1778                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
1779                    if new_start < self.entries.len() {
1780                        crate::session_sqlite::append_entries(
1781                            &path_clone,
1782                            &self.entries[new_start..],
1783                            new_start,
1784                            message_count,
1785                            session_name.as_deref(),
1786                        )
1787                        .await?;
1788                        self.persisted_entry_count
1789                            .store(self.entries.len(), Ordering::SeqCst);
1790                        self.appends_since_checkpoint += 1;
1791                    }
1792                    // No new entries → no-op, nothing to write.
1793                }
1794
1795                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
1796                enqueue_session_index_snapshot_update(
1797                    &sessions_root,
1798                    &path_clone,
1799                    &self.header,
1800                    message_count,
1801                    session_name,
1802                );
1803            }
1804        }
1805        Ok(())
1806    }
1807
1808    const fn enqueue_autosave_mutation(&mut self, kind: AutosaveMutationKind) {
1809        self.autosave_queue.enqueue_mutation(kind);
1810    }
1811
1812    /// Append a session message entry.
1813    pub fn append_message(&mut self, message: SessionMessage) -> String {
1814        let id = self.next_entry_id();
1815        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1816        let entry = SessionEntry::Message(MessageEntry { base, message });
1817        self.leaf_id = Some(id.clone());
1818        self.entries.push(entry);
1819        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1820        self.entry_ids.insert(id.clone());
1821        self.cached_message_count += 1;
1822        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
1823        id
1824    }
1825
1826    /// Append a message from the model message types.
1827    pub fn append_model_message(&mut self, message: Message) -> String {
1828        self.append_message(SessionMessage::from(message))
1829    }
1830
1831    pub fn append_model_change(&mut self, provider: String, model_id: String) -> String {
1832        let id = self.next_entry_id();
1833        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1834        let entry = SessionEntry::ModelChange(ModelChangeEntry {
1835            base,
1836            provider,
1837            model_id,
1838        });
1839        self.leaf_id = Some(id.clone());
1840        self.entries.push(entry);
1841        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1842        self.entry_ids.insert(id.clone());
1843        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1844        id
1845    }
1846
1847    pub fn append_thinking_level_change(&mut self, thinking_level: String) -> String {
1848        let id = self.next_entry_id();
1849        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1850        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
1851            base,
1852            thinking_level,
1853        });
1854        self.leaf_id = Some(id.clone());
1855        self.entries.push(entry);
1856        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1857        self.entry_ids.insert(id.clone());
1858        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1859        id
1860    }
1861
1862    pub fn append_session_info(&mut self, name: Option<String>) -> String {
1863        let id = self.next_entry_id();
1864        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1865        if name.is_some() {
1866            self.cached_name.clone_from(&name);
1867        }
1868        let entry = SessionEntry::SessionInfo(SessionInfoEntry { base, name });
1869        self.leaf_id = Some(id.clone());
1870        self.entries.push(entry);
1871        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1872        self.entry_ids.insert(id.clone());
1873        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1874        id
1875    }
1876
1877    /// Append a custom entry (extension state, etc).
1878    pub fn append_custom_entry(
1879        &mut self,
1880        custom_type: String,
1881        data: Option<serde_json::Value>,
1882    ) -> String {
1883        let id = self.next_entry_id();
1884        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1885        let entry = SessionEntry::Custom(CustomEntry {
1886            base,
1887            custom_type,
1888            data,
1889        });
1890        self.leaf_id = Some(id.clone());
1891        self.entries.push(entry);
1892        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1893        self.entry_ids.insert(id.clone());
1894        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1895        id
1896    }
1897
1898    pub fn append_bash_execution(
1899        &mut self,
1900        command: String,
1901        output: String,
1902        exit_code: i32,
1903        cancelled: bool,
1904        truncated: bool,
1905        full_output_path: Option<String>,
1906    ) -> String {
1907        let id = self.next_entry_id();
1908        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1909        let entry = SessionEntry::Message(MessageEntry {
1910            base,
1911            message: SessionMessage::BashExecution {
1912                command,
1913                output,
1914                exit_code,
1915                cancelled: Some(cancelled),
1916                truncated: Some(truncated),
1917                full_output_path,
1918                timestamp: Some(chrono::Utc::now().timestamp_millis()),
1919                extra: HashMap::new(),
1920            },
1921        });
1922        self.leaf_id = Some(id.clone());
1923        self.entries.push(entry);
1924        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1925        self.entry_ids.insert(id.clone());
1926        self.cached_message_count += 1;
1927        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
1928        id
1929    }
1930
1931    /// Get the current session name from the cached value (Gap C).
1932    pub fn get_name(&self) -> Option<String> {
1933        self.cached_name.clone()
1934    }
1935
1936    /// Set the session name by appending a `SessionInfo` entry.
1937    pub fn set_name(&mut self, name: &str) -> String {
1938        self.append_session_info(Some(name.to_string()))
1939    }
1940
1941    pub fn append_compaction(
1942        &mut self,
1943        summary: String,
1944        first_kept_entry_id: String,
1945        tokens_before: u64,
1946        details: Option<Value>,
1947        from_hook: Option<bool>,
1948    ) -> String {
1949        let id = self.next_entry_id();
1950        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1951        let entry = SessionEntry::Compaction(CompactionEntry {
1952            base,
1953            summary,
1954            first_kept_entry_id,
1955            tokens_before,
1956            details,
1957            from_hook,
1958        });
1959        self.leaf_id = Some(id.clone());
1960        self.entries.push(entry);
1961        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1962        self.entry_ids.insert(id.clone());
1963        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1964        id
1965    }
1966
1967    pub fn append_branch_summary(
1968        &mut self,
1969        from_id: String,
1970        summary: String,
1971        details: Option<Value>,
1972        from_hook: Option<bool>,
1973    ) -> String {
1974        let id = self.next_entry_id();
1975        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1976        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
1977            base,
1978            from_id,
1979            summary,
1980            details,
1981            from_hook,
1982        });
1983        self.leaf_id = Some(id.clone());
1984        self.entries.push(entry);
1985        self.entry_index.insert(id.clone(), self.entries.len() - 1);
1986        self.entry_ids.insert(id.clone());
1987        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1988        id
1989    }
1990
1991    pub fn ensure_entry_ids(&mut self) {
1992        // `rebuild_all_caches()` runs `finalize_loaded_entries()`, which already
1993        // fills missing entry IDs and rebuilds all derived caches in one pass.
1994        self.rebuild_all_caches();
1995    }
1996
1997    /// Rebuild all derived caches from `self.entries`.
1998    ///
1999    /// Called after bulk mutations (save round-trip, ensure_entry_ids) where
2000    /// incremental maintenance is impractical.
2001    fn rebuild_all_caches(&mut self) {
2002        let finalized = finalize_loaded_entries(&mut self.entries);
2003        self.entry_ids = finalized.entry_ids;
2004        self.entry_index = finalized.entry_index;
2005        self.cached_message_count = finalized
2006            .message_count
2007            .saturating_add(self.v2_message_count_offset);
2008        self.cached_name = finalized.name;
2009        // is_linear requires BOTH: no branching in the entry tree AND the
2010        // current leaf_id pointing at the last entry.  If the user navigated
2011        // to a mid-chain entry before saving, the leaf differs from the tip
2012        // and the fast path would return wrong results.
2013        self.is_linear = finalized.is_linear && self.leaf_id == finalized.leaf_id;
2014    }
2015
2016    /// Convert session entries to model messages (for provider context).
2017    pub fn to_messages(&self) -> Vec<Message> {
2018        let mut messages = Vec::new();
2019        for entry in &self.entries {
2020            if let SessionEntry::Message(msg_entry) = entry {
2021                if let Some(message) = session_message_to_model(&msg_entry.message) {
2022                    messages.push(message);
2023                }
2024            }
2025        }
2026        messages
2027    }
2028
2029    /// Render the session as a standalone HTML document.
2030    ///
2031    /// Delegates to `render_session_html()` for the actual rendering. For
2032    /// non-blocking export, prefer `export_snapshot().to_html()` which avoids
2033    /// cloning internal caches.
2034    pub fn to_html(&self) -> String {
2035        render_session_html(&self.header, &self.entries)
2036    }
2037
2038    /// Update header model info.
2039    pub fn set_model_header(
2040        &mut self,
2041        provider: Option<String>,
2042        model_id: Option<String>,
2043        thinking_level: Option<String>,
2044    ) {
2045        let changed = provider.is_some() || model_id.is_some() || thinking_level.is_some();
2046        if provider.is_some() {
2047            self.header.provider = provider;
2048        }
2049        if model_id.is_some() {
2050            self.header.model_id = model_id;
2051        }
2052        if thinking_level.is_some() {
2053            self.header.thinking_level = thinking_level;
2054        }
2055        if changed {
2056            self.header_dirty = true;
2057            self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2058        }
2059    }
2060
2061    pub fn set_branched_from(&mut self, path: Option<String>) {
2062        self.header.parent_session = path;
2063        self.header_dirty = true;
2064        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2065    }
2066
2067    /// Create a lightweight snapshot for non-blocking HTML export.
2068    ///
2069    /// Captures only the fields needed by `to_html()` (header, entries, path),
2070    /// avoiding a full `Session::clone()` which includes caches, autosave queues,
2071    /// persistence state, and other internal bookkeeping.
2072    pub fn export_snapshot(&self) -> ExportSnapshot {
2073        ExportSnapshot {
2074            header: self.header.clone(),
2075            entries: self.entries.clone(),
2076            path: self.path.clone(),
2077        }
2078    }
2079
2080    /// Plan a `/fork` from a user message entry ID.
2081    ///
2082    /// Returns the entries to copy into a new session (path to the parent of the selected
2083    /// user message), the new leaf id, and the selected user message text for editor pre-fill.
2084    pub fn plan_fork_from_user_message(&self, entry_id: &str) -> Result<ForkPlan> {
2085        let entry = self
2086            .get_entry(entry_id)
2087            .ok_or_else(|| Error::session(format!("Fork target not found: {entry_id}")))?;
2088
2089        let SessionEntry::Message(message_entry) = entry else {
2090            return Err(Error::session(format!(
2091                "Fork target is not a message entry: {entry_id}"
2092            )));
2093        };
2094
2095        let SessionMessage::User { content, .. } = &message_entry.message else {
2096            return Err(Error::session(format!(
2097                "Fork target is not a user message: {entry_id}"
2098            )));
2099        };
2100
2101        let selected_text = user_content_to_text(content);
2102        let leaf_id = message_entry.base.parent_id.clone();
2103
2104        let entries = if let Some(ref leaf_id) = leaf_id {
2105            if self.is_linear {
2106                let idx = self.entry_index.get(leaf_id).copied().ok_or_else(|| {
2107                    Error::session(format!("Failed to build fork: missing entry {leaf_id}"))
2108                })?;
2109                self.entries[..=idx].to_vec()
2110            } else {
2111                let path_ids = self.get_path_to_entry(leaf_id);
2112                let mut entries = Vec::new();
2113                for path_id in path_ids {
2114                    let entry = self.get_entry(&path_id).ok_or_else(|| {
2115                        Error::session(format!("Failed to build fork: missing entry {path_id}"))
2116                    })?;
2117                    entries.push(entry.clone());
2118                }
2119                entries
2120            }
2121        } else {
2122            Vec::new()
2123        };
2124
2125        Ok(ForkPlan {
2126            entries,
2127            leaf_id,
2128            selected_text,
2129        })
2130    }
2131
2132    fn next_entry_id(&self) -> String {
2133        let use_entry_id_cache = session_entry_id_cache_enabled();
2134
2135        if use_entry_id_cache {
2136            // Use the cached set for O(1) collision checks.
2137            // generate_entry_id handles generation + collision retry logic.
2138            generate_entry_id(&self.entry_ids)
2139        } else {
2140            // Fallback: scan entries to build the exclusion set on demand.
2141            // This is slower (O(N)) but only used if the cache feature flag is disabled.
2142            let existing = entry_id_set(&self.entries);
2143            generate_entry_id(&existing)
2144        }
2145    }
2146
2147    // ========================================================================
2148    // Tree Navigation
2149    // ========================================================================
2150
2151    /// Build a map from parent ID to children IDs.
2152    fn build_children_map(&self) -> HashMap<Option<String>, Vec<String>> {
2153        let mut children: HashMap<Option<String>, Vec<String>> =
2154            HashMap::with_capacity(self.entries.len());
2155        for entry in &self.entries {
2156            if let Some(id) = entry.base_id() {
2157                children
2158                    .entry(entry.base().parent_id.clone())
2159                    .or_default()
2160                    .push(id.clone());
2161            }
2162        }
2163        children
2164    }
2165
2166    /// Get the path from an entry back to the root (inclusive).
2167    /// Returns entry IDs in order from root to the specified entry.
2168    pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
2169        // Fast path: in linear sessions, every ancestor chain is a prefix of `entries`.
2170        if self.is_linear {
2171            if let Some(&idx) = self.entry_index.get(entry_id) {
2172                let mut path = Vec::with_capacity(idx + 1);
2173                for entry in &self.entries[..=idx] {
2174                    if let Some(id) = entry.base_id() {
2175                        path.push(id.clone());
2176                    }
2177                }
2178                return path;
2179            }
2180        }
2181
2182        let mut path = Vec::new();
2183        let mut visited = std::collections::HashSet::with_capacity(self.entries.len().min(128));
2184        let mut current = Some(entry_id.to_string());
2185
2186        while let Some(id) = current {
2187            if !visited.insert(id.clone()) {
2188                tracing::warn!(
2189                    "Cycle detected in session tree while building ancestor path at entry: {id}"
2190                );
2191                break;
2192            }
2193            path.push(id.clone());
2194            current = self
2195                .get_entry(&id)
2196                .and_then(|entry| entry.base().parent_id.clone());
2197        }
2198
2199        path.reverse();
2200        path
2201    }
2202
2203    /// Get direct children of an entry.
2204    pub fn get_children(&self, entry_id: Option<&str>) -> Vec<String> {
2205        self.entries
2206            .iter()
2207            .filter_map(|entry| {
2208                let id = entry.base_id()?;
2209                if entry.base().parent_id.as_deref() == entry_id {
2210                    Some(id.clone())
2211                } else {
2212                    None
2213                }
2214            })
2215            .collect()
2216    }
2217
2218    /// List all leaf nodes (entries with no children).
2219    pub fn list_leaves(&self) -> Vec<String> {
2220        let mut has_children: HashSet<&str> = HashSet::with_capacity(self.entries.len());
2221        for entry in &self.entries {
2222            if let Some(parent_id) = entry.base().parent_id.as_deref() {
2223                has_children.insert(parent_id);
2224            }
2225        }
2226
2227        self.entries
2228            .iter()
2229            .filter_map(|e| {
2230                let id = e.base_id()?;
2231                if has_children.contains(id.as_str()) {
2232                    None
2233                } else {
2234                    Some(id.clone())
2235                }
2236            })
2237            .collect()
2238    }
2239
2240    /// Navigate to a specific entry, making it the current leaf.
2241    /// Returns true if the entry exists.
2242    pub fn navigate_to(&mut self, entry_id: &str) -> bool {
2243        // Gap B: O(1) existence check via entry_index.
2244        let exists = self.entry_index.contains_key(entry_id);
2245        if exists {
2246            // Gap A: navigating away from the tip breaks linearity.
2247            let is_tip = self
2248                .entries
2249                .last()
2250                .and_then(|e| e.base_id())
2251                .is_some_and(|id| id == entry_id);
2252            if !is_tip {
2253                self.is_linear = false;
2254            }
2255            self.leaf_id = Some(entry_id.to_string());
2256            true
2257        } else {
2258            false
2259        }
2260    }
2261
2262    /// Reset the leaf pointer to root (before any entries).
2263    ///
2264    /// After calling this, the next appended entry will become a new root entry
2265    /// (`parent_id = None`). This is used by interactive `/tree` navigation when
2266    /// re-editing the first user message.
2267    pub fn reset_leaf(&mut self) {
2268        self.leaf_id = None;
2269        self.is_linear = false;
2270    }
2271
2272    /// Create a new branch starting from a specific entry.
2273    /// Sets the leaf_id to the specified entry so new entries branch from there.
2274    /// Returns true if the entry exists.
2275    pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
2276        self.navigate_to(entry_id)
2277    }
2278
2279    /// Get the entry at a specific ID (Gap B: O(1) via `entry_index`).
2280    pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
2281        self.entry_index
2282            .get(entry_id)
2283            .and_then(|&idx| self.entries.get(idx))
2284    }
2285
2286    /// Get the entry at a specific ID, mutable (Gap B: O(1) via `entry_index`).
2287    pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
2288        self.entry_index
2289            .get(entry_id)
2290            .copied()
2291            .and_then(|idx| self.entries.get_mut(idx))
2292    }
2293
2294    /// Entries along the current leaf path, in chronological order.
2295    ///
2296    /// Gap A: when `is_linear` is true (the 99% case — no branching has
2297    /// occurred), this returns all entries directly without building a
2298    /// parent map or tracing the path.
2299    pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
2300        let Some(leaf_id) = &self.leaf_id else {
2301            return Vec::new();
2302        };
2303
2304        // Fast path: linear session — all entries are on the current path.
2305        if self.is_linear {
2306            return self.entries.iter().collect();
2307        }
2308
2309        let mut path_indices = Vec::with_capacity(16);
2310        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
2311        let mut current = Some(leaf_id.clone());
2312
2313        while let Some(id) = current.as_ref() {
2314            if !visited.insert(id.clone()) {
2315                tracing::warn!(
2316                    "Cycle detected in session tree while collecting current path entries at: {id}"
2317                );
2318                break;
2319            }
2320            let Some(&idx) = self.entry_index.get(id.as_str()) else {
2321                break;
2322            };
2323            let Some(entry) = self.entries.get(idx) else {
2324                break;
2325            };
2326            path_indices.push(idx);
2327            current.clone_from(&entry.base().parent_id);
2328        }
2329
2330        path_indices.reverse();
2331        path_indices
2332            .into_iter()
2333            .filter_map(|idx| self.entries.get(idx))
2334            .collect()
2335    }
2336
2337    /// Convert session entries along the current path to model messages.
2338    /// This follows parent_id links from leaf_id back to root.
2339    pub fn to_messages_for_current_path(&self) -> Vec<Message> {
2340        if self.leaf_id.is_none() {
2341            return Vec::new();
2342        }
2343
2344        if self.is_linear {
2345            return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
2346        }
2347
2348        let path_entries = self.entries_for_current_path();
2349        Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
2350    }
2351
2352    fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
2353        match entry {
2354            SessionEntry::Message(msg_entry) => {
2355                if let Some(message) = session_message_to_model(&msg_entry.message) {
2356                    messages.push(message);
2357                }
2358            }
2359            SessionEntry::BranchSummary(summary) => {
2360                let summary_message = SessionMessage::BranchSummary {
2361                    summary: summary.summary.clone(),
2362                    from_id: summary.from_id.clone(),
2363                };
2364                if let Some(message) = session_message_to_model(&summary_message) {
2365                    messages.push(message);
2366                }
2367            }
2368            _ => {}
2369        }
2370    }
2371
2372    fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
2373    where
2374        F: Fn(usize) -> &'a SessionEntry,
2375    {
2376        let mut last_compaction = None;
2377        for idx in (0..path_len).rev() {
2378            if let SessionEntry::Compaction(compaction) = entry_at(idx) {
2379                last_compaction = Some((idx, compaction));
2380                break;
2381            }
2382        }
2383
2384        if let Some((compaction_idx, compaction)) = last_compaction {
2385            let mut messages = Vec::with_capacity(path_len);
2386            let summary_message = SessionMessage::CompactionSummary {
2387                summary: compaction.summary.clone(),
2388                tokens_before: compaction.tokens_before,
2389            };
2390            if let Some(message) = session_message_to_model(&summary_message) {
2391                messages.push(message);
2392            }
2393
2394            let has_kept_entry = (0..path_len).any(|idx| {
2395                entry_at(idx)
2396                    .base_id()
2397                    .is_some_and(|id| id == &compaction.first_kept_entry_id)
2398            });
2399
2400            let mut keep = false;
2401            let mut past_compaction = false;
2402            for idx in 0..path_len {
2403                let entry = entry_at(idx);
2404                if idx == compaction_idx {
2405                    past_compaction = true;
2406                }
2407                if !keep {
2408                    if has_kept_entry {
2409                        if entry
2410                            .base_id()
2411                            .is_some_and(|id| id == &compaction.first_kept_entry_id)
2412                        {
2413                            keep = true;
2414                        } else {
2415                            continue;
2416                        }
2417                    } else if past_compaction {
2418                        tracing::warn!(
2419                            first_kept_entry_id = %compaction.first_kept_entry_id,
2420                            "Compaction references missing entry; including all post-compaction entries"
2421                        );
2422                        keep = true;
2423                    } else {
2424                        continue;
2425                    }
2426                }
2427                Self::append_model_message_for_entry(&mut messages, entry);
2428            }
2429
2430            return messages;
2431        }
2432
2433        let mut messages = Vec::with_capacity(path_len);
2434        for idx in 0..path_len {
2435            Self::append_model_message_for_entry(&mut messages, entry_at(idx));
2436        }
2437        messages
2438    }
2439
2440    /// Find the nearest ancestor that is a fork point (has multiple children)
2441    /// and return its children (sibling branch roots). Each sibling is represented
2442    /// by its branch-root entry ID plus the leaf ID reachable from that root.
2443    ///
2444    /// Returns `(fork_point_id, sibling_leaves)` where each sibling leaf is
2445    /// a leaf entry ID reachable through the fork point's children. The current
2446    /// leaf is included in the list.
2447    pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
2448        let children_map = self.build_children_map();
2449        let leaf_id = self.leaf_id.as_ref()?;
2450        let path = self.get_path_to_entry(leaf_id);
2451        if path.is_empty() {
2452            return None;
2453        }
2454
2455        // Walk backwards from current leaf's path to find the nearest fork point.
2456        // A fork point is any entry whose parent has >1 children, OR None (root)
2457        // with >1 root entries.
2458        // We check each entry's parent to see if the parent has multiple children.
2459        for (idx, entry_id) in path.iter().enumerate().rev() {
2460            let parent_of_entry = self
2461                .get_entry(entry_id)
2462                .and_then(|e| e.base().parent_id.clone());
2463
2464            let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
2465                continue;
2466            };
2467
2468            if siblings_at_parent.len() > 1 {
2469                // This is a fork point. Collect all leaves reachable from each sibling.
2470                let mut branches = Vec::new();
2471                let current_branch_ids: HashSet<&str> =
2472                    path[idx..].iter().map(String::as_str).collect();
2473                for sibling_root in siblings_at_parent {
2474                    let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
2475                    let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
2476                    let is_current = current_branch_ids.contains(sibling_root.as_str());
2477                    branches.push(SiblingBranch {
2478                        root_id: sibling_root.clone(),
2479                        leaf_id: leaf,
2480                        preview,
2481                        message_count: msg_count,
2482                        is_current,
2483                    });
2484                }
2485                return Some((parent_of_entry, branches));
2486            }
2487        }
2488
2489        None
2490    }
2491
2492    /// Follow the first child chain to reach the deepest leaf from a starting entry.
2493    fn deepest_leaf_from(
2494        children_map: &HashMap<Option<String>, Vec<String>>,
2495        start_id: &str,
2496    ) -> String {
2497        let mut current = start_id.to_string();
2498        let mut visited = HashSet::new();
2499        loop {
2500            if !visited.insert(current.clone()) {
2501                tracing::warn!("Cycle detected in session tree at entry: {current}");
2502                return current;
2503            }
2504            let children = children_map.get(&Some(current.clone()));
2505            match children.and_then(|c| c.first()) {
2506                Some(child) => current.clone_from(child),
2507                None => return current,
2508            }
2509        }
2510    }
2511
2512    /// Compute a short preview (first user message on the path) and the number
2513    /// of message entries for a leaf in a single parent-chain walk.
2514    fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
2515        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
2516        let mut current = Some(leaf_id.to_string());
2517        let mut preview = None;
2518        let mut count = 0usize;
2519
2520        while let Some(id) = current.as_ref() {
2521            if !visited.insert(id.clone()) {
2522                tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
2523                break;
2524            }
2525            let Some(entry) = self.get_entry(id.as_str()) else {
2526                break;
2527            };
2528            if matches!(entry, SessionEntry::Message(_)) {
2529                count = count.saturating_add(1);
2530            }
2531            if let SessionEntry::Message(msg) = entry {
2532                if let SessionMessage::User { content, .. } = &msg.message {
2533                    let text = user_content_to_text(content);
2534                    let trimmed = text.trim();
2535                    if !trimmed.is_empty() {
2536                        preview = Some(if trimmed.chars().count() > 60 {
2537                            let truncated: String = trimmed.chars().take(57).collect();
2538                            format!("{truncated}...")
2539                        } else {
2540                            trimmed.to_string()
2541                        });
2542                    }
2543                }
2544            }
2545            current.clone_from(&entry.base().parent_id);
2546        }
2547
2548        (preview.unwrap_or_else(|| String::from("(empty)")), count)
2549    }
2550
2551    /// Get a summary of branches in this session.
2552    pub fn branch_summary(&self) -> BranchInfo {
2553        let leaves = self.list_leaves();
2554        let children_map = self.build_children_map();
2555
2556        // Find branch points (entries with multiple children)
2557        let branch_points: Vec<String> = self
2558            .entries
2559            .iter()
2560            .filter_map(|e| {
2561                let id = e.base_id()?;
2562                let children = children_map.get(&Some(id.clone()))?;
2563                if children.len() > 1 {
2564                    Some(id.clone())
2565                } else {
2566                    None
2567                }
2568            })
2569            .collect();
2570
2571        BranchInfo {
2572            total_entries: self.entries.len(),
2573            leaf_count: leaves.len(),
2574            branch_point_count: branch_points.len(),
2575            current_leaf: self.leaf_id.clone(),
2576            leaves,
2577            branch_points,
2578        }
2579    }
2580
2581    /// Add a label to an entry.
2582    pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
2583        // Verify target exists
2584        self.get_entry(target_id)?;
2585
2586        let id = self.next_entry_id();
2587        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2588        let entry = SessionEntry::Label(LabelEntry {
2589            base,
2590            target_id: target_id.to_string(),
2591            label,
2592        });
2593        self.leaf_id = Some(id.clone());
2594        self.entries.push(entry);
2595        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2596        self.entry_ids.insert(id.clone());
2597        self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
2598        Some(id)
2599    }
2600}
2601
2602/// Summary of branches in a session.
2603#[derive(Debug, Clone)]
2604pub struct BranchInfo {
2605    pub total_entries: usize,
2606    pub leaf_count: usize,
2607    pub branch_point_count: usize,
2608    pub current_leaf: Option<String>,
2609    pub leaves: Vec<String>,
2610    pub branch_points: Vec<String>,
2611}
2612
2613/// A sibling branch at a fork point.
2614#[derive(Debug, Clone)]
2615pub struct SiblingBranch {
2616    /// Entry ID of the branch root (child of the fork point).
2617    pub root_id: String,
2618    /// Leaf entry ID reachable from this branch root.
2619    pub leaf_id: String,
2620    /// Short preview of the first user message on this branch.
2621    pub preview: String,
2622    /// Number of message entries along the path.
2623    pub message_count: usize,
2624    /// Whether the current session leaf is on this branch.
2625    pub is_current: bool,
2626}
2627
2628#[derive(Debug, Clone)]
2629struct SessionPickEntry {
2630    path: PathBuf,
2631    id: String,
2632    timestamp: String,
2633    message_count: u64,
2634    name: Option<String>,
2635    last_modified_ms: i64,
2636    size_bytes: u64,
2637}
2638
2639impl SessionPickEntry {
2640    fn from_meta(meta: crate::session_index::SessionMeta) -> Option<Self> {
2641        let path = PathBuf::from(meta.path);
2642        if !path.exists() {
2643            return None;
2644        }
2645        Some(Self {
2646            path,
2647            id: meta.id,
2648            timestamp: meta.timestamp,
2649            message_count: meta.message_count,
2650            name: meta.name,
2651            last_modified_ms: meta.last_modified_ms,
2652            size_bytes: meta.size_bytes,
2653        })
2654    }
2655}
2656
2657const fn can_reuse_known_entry(
2658    known_entry: &SessionPickEntry,
2659    disk_ms: i64,
2660    disk_size: u64,
2661) -> bool {
2662    known_entry.last_modified_ms == disk_ms && known_entry.size_bytes == disk_size
2663}
2664
2665async fn scan_sessions_on_disk(
2666    project_session_dir: &Path,
2667    known: Vec<SessionPickEntry>,
2668) -> Result<Vec<SessionPickEntry>> {
2669    let path_buf = project_session_dir.to_path_buf();
2670    let (tx, rx) = oneshot::channel();
2671
2672    thread::Builder::new()
2673        .name("session-scan".to_string())
2674        .spawn(move || {
2675            let res = (|| -> Result<Vec<SessionPickEntry>> {
2676                let mut entries = Vec::new();
2677                let dir_entries = std::fs::read_dir(&path_buf)
2678                    .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
2679
2680                let known_map: HashMap<PathBuf, SessionPickEntry> =
2681                    known.into_iter().map(|e| (e.path.clone(), e)).collect();
2682
2683                for entry in dir_entries {
2684                    let entry =
2685                        entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
2686                    let path = entry.path();
2687                    if is_session_file_path(&path) {
2688                        // Optimization: if we already have this file indexed and both mtime and
2689                        // size match, reuse indexed metadata to avoid a full parse.
2690                        if let Ok(metadata) = std::fs::metadata(&path) {
2691                            let disk_size = metadata.len();
2692                            if let Ok(modified) = metadata.modified() {
2693                                #[allow(clippy::cast_possible_truncation)]
2694                                let disk_ms = modified
2695                                    .duration_since(UNIX_EPOCH)
2696                                    .unwrap_or_default()
2697                                    .as_millis()
2698                                    as i64;
2699
2700                                if let Some(known_entry) = known_map.get(&path) {
2701                                    if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
2702                                        entries.push(known_entry.clone());
2703                                        continue;
2704                                    }
2705                                }
2706                            }
2707                        }
2708
2709                        if let Ok(meta) = load_session_meta(&path) {
2710                            entries.push(meta);
2711                        }
2712                    }
2713                }
2714                Ok(entries)
2715            })();
2716            let cx = AgentCx::for_request();
2717            let _ = tx.send(cx.cx(), res);
2718        })
2719        .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
2720
2721    let cx = AgentCx::for_request();
2722    rx.recv(cx.cx())
2723        .await
2724        .map_err(|_| Error::session("Scan task cancelled"))?
2725}
2726
2727fn is_session_file_path(path: &Path) -> bool {
2728    match path.extension().and_then(|ext| ext.to_str()) {
2729        Some("jsonl") => true,
2730        #[cfg(feature = "sqlite-sessions")]
2731        Some("sqlite") => true,
2732        _ => false,
2733    }
2734}
2735
2736fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
2737    match path.extension().and_then(|ext| ext.to_str()) {
2738        Some("jsonl") => load_session_meta_jsonl(path),
2739        #[cfg(feature = "sqlite-sessions")]
2740        Some("sqlite") => load_session_meta_sqlite(path),
2741        _ => Err(Error::session(format!(
2742            "Unsupported session file extension: {}",
2743            path.display()
2744        ))),
2745    }
2746}
2747
2748#[derive(Deserialize)]
2749struct PartialEntry {
2750    #[serde(default)]
2751    r#type: String,
2752    #[serde(default)]
2753    name: Option<String>,
2754}
2755
2756fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
2757    let file = std::fs::File::open(path)
2758        .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
2759    let reader = BufReader::new(file);
2760    let mut lines = reader.lines();
2761
2762    let header_line = lines
2763        .next()
2764        .ok_or_else(|| Error::session("Empty session file"))?
2765        .map_err(|e| Error::session(format!("Failed to read header: {e}")))?;
2766
2767    let header: SessionHeader =
2768        serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
2769
2770    let mut message_count = 0u64;
2771    let mut name = None;
2772
2773    for line_content in lines.map_while(std::result::Result::ok) {
2774        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
2775            match entry.r#type.as_str() {
2776                "message" => message_count += 1,
2777                "session_info" => {
2778                    if entry.name.is_some() {
2779                        name = entry.name;
2780                    }
2781                }
2782                _ => {}
2783            }
2784        }
2785    }
2786
2787    let metadata = std::fs::metadata(path)
2788        .map_err(|e| Error::session(format!("Failed to stat session: {e}")))?;
2789    let size_bytes = metadata.len();
2790    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
2791    #[allow(clippy::cast_possible_truncation)]
2792    let last_modified_ms = modified
2793        .duration_since(UNIX_EPOCH)
2794        .unwrap_or_default()
2795        .as_millis() as i64; // i64::MAX ms = ~292 million years, so truncation is safe
2796
2797    Ok(SessionPickEntry {
2798        path: path.to_path_buf(),
2799        id: header.id,
2800        timestamp: header.timestamp,
2801        message_count,
2802        name,
2803        last_modified_ms,
2804        size_bytes,
2805    })
2806}
2807
2808#[cfg(feature = "sqlite-sessions")]
2809fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
2810    let meta = futures::executor::block_on(async {
2811        crate::session_sqlite::load_session_meta(path).await
2812    })?;
2813    let header = meta.header;
2814
2815    let metadata = std::fs::metadata(path)
2816        .map_err(|e| Error::session(format!("Failed to stat session: {e}")))?;
2817    let size_bytes = metadata.len();
2818    let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
2819    #[allow(clippy::cast_possible_truncation)]
2820    let last_modified_ms = modified
2821        .duration_since(UNIX_EPOCH)
2822        .unwrap_or_default()
2823        .as_millis() as i64; // i64::MAX ms = ~292 million years, so truncation is safe
2824
2825    Ok(SessionPickEntry {
2826        path: path.to_path_buf(),
2827        id: header.id,
2828        timestamp: header.timestamp,
2829        message_count: meta.message_count,
2830        name: meta.name,
2831        last_modified_ms,
2832        size_bytes,
2833    })
2834}
2835
2836// ============================================================================
2837// Session Header
2838// ============================================================================
2839
2840/// Session file header.
2841#[derive(Debug, Clone, Serialize, Deserialize)]
2842#[serde(rename_all = "camelCase")]
2843pub struct SessionHeader {
2844    pub r#type: String,
2845    #[serde(skip_serializing_if = "Option::is_none")]
2846    pub version: Option<u8>,
2847    pub id: String,
2848    pub timestamp: String,
2849    pub cwd: String,
2850    #[serde(skip_serializing_if = "Option::is_none")]
2851    pub provider: Option<String>,
2852    #[serde(skip_serializing_if = "Option::is_none")]
2853    pub model_id: Option<String>,
2854    #[serde(skip_serializing_if = "Option::is_none")]
2855    pub thinking_level: Option<String>,
2856    #[serde(
2857        skip_serializing_if = "Option::is_none",
2858        rename = "branchedFrom",
2859        alias = "parentSession"
2860    )]
2861    pub parent_session: Option<String>,
2862}
2863
2864impl SessionHeader {
2865    pub fn new() -> Self {
2866        let now = chrono::Utc::now();
2867        Self {
2868            r#type: "session".to_string(),
2869            version: Some(SESSION_VERSION),
2870            id: uuid::Uuid::new_v4().to_string(),
2871            timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2872            cwd: std::env::current_dir()
2873                .map(|p| p.display().to_string())
2874                .unwrap_or_default(),
2875            provider: None,
2876            model_id: None,
2877            thinking_level: None,
2878            parent_session: None,
2879        }
2880    }
2881}
2882
2883impl Default for SessionHeader {
2884    fn default() -> Self {
2885        Self::new()
2886    }
2887}
2888
2889// ============================================================================
2890// Session Entries
2891// ============================================================================
2892
2893/// A session entry.
2894#[derive(Debug, Clone, Serialize, Deserialize)]
2895#[serde(tag = "type", rename_all = "snake_case")]
2896pub enum SessionEntry {
2897    Message(MessageEntry),
2898    ModelChange(ModelChangeEntry),
2899    ThinkingLevelChange(ThinkingLevelChangeEntry),
2900    Compaction(CompactionEntry),
2901    BranchSummary(BranchSummaryEntry),
2902    Label(LabelEntry),
2903    SessionInfo(SessionInfoEntry),
2904    Custom(CustomEntry),
2905}
2906
2907impl SessionEntry {
2908    pub const fn base(&self) -> &EntryBase {
2909        match self {
2910            Self::Message(e) => &e.base,
2911            Self::ModelChange(e) => &e.base,
2912            Self::ThinkingLevelChange(e) => &e.base,
2913            Self::Compaction(e) => &e.base,
2914            Self::BranchSummary(e) => &e.base,
2915            Self::Label(e) => &e.base,
2916            Self::SessionInfo(e) => &e.base,
2917            Self::Custom(e) => &e.base,
2918        }
2919    }
2920
2921    pub const fn base_mut(&mut self) -> &mut EntryBase {
2922        match self {
2923            Self::Message(e) => &mut e.base,
2924            Self::ModelChange(e) => &mut e.base,
2925            Self::ThinkingLevelChange(e) => &mut e.base,
2926            Self::Compaction(e) => &mut e.base,
2927            Self::BranchSummary(e) => &mut e.base,
2928            Self::Label(e) => &mut e.base,
2929            Self::SessionInfo(e) => &mut e.base,
2930            Self::Custom(e) => &mut e.base,
2931        }
2932    }
2933
2934    pub const fn base_id(&self) -> Option<&String> {
2935        self.base().id.as_ref()
2936    }
2937}
2938
2939/// Base entry fields.
2940#[derive(Debug, Clone, Serialize, Deserialize)]
2941#[serde(rename_all = "camelCase")]
2942pub struct EntryBase {
2943    #[serde(skip_serializing_if = "Option::is_none")]
2944    pub id: Option<String>,
2945    #[serde(skip_serializing_if = "Option::is_none")]
2946    pub parent_id: Option<String>,
2947    pub timestamp: String,
2948}
2949
2950impl EntryBase {
2951    pub fn new(parent_id: Option<String>, id: String) -> Self {
2952        Self {
2953            id: Some(id),
2954            parent_id,
2955            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2956        }
2957    }
2958}
2959
2960/// Message entry.
2961#[derive(Debug, Clone, Serialize, Deserialize)]
2962#[serde(rename_all = "camelCase")]
2963pub struct MessageEntry {
2964    #[serde(flatten)]
2965    pub base: EntryBase,
2966    pub message: SessionMessage,
2967}
2968
2969/// Session message payload.
2970#[derive(Debug, Clone, Serialize, Deserialize)]
2971#[serde(
2972    tag = "role",
2973    rename_all = "camelCase",
2974    rename_all_fields = "camelCase"
2975)]
2976pub enum SessionMessage {
2977    User {
2978        content: UserContent,
2979        #[serde(skip_serializing_if = "Option::is_none")]
2980        timestamp: Option<i64>,
2981    },
2982    Assistant {
2983        #[serde(flatten)]
2984        message: AssistantMessage,
2985    },
2986    ToolResult {
2987        tool_call_id: String,
2988        tool_name: String,
2989        content: Vec<ContentBlock>,
2990        #[serde(skip_serializing_if = "Option::is_none")]
2991        details: Option<Value>,
2992        #[serde(default)]
2993        is_error: bool,
2994        #[serde(skip_serializing_if = "Option::is_none")]
2995        timestamp: Option<i64>,
2996    },
2997    Custom {
2998        custom_type: String,
2999        content: String,
3000        #[serde(default)]
3001        display: bool,
3002        #[serde(skip_serializing_if = "Option::is_none")]
3003        details: Option<Value>,
3004        #[serde(skip_serializing_if = "Option::is_none")]
3005        timestamp: Option<i64>,
3006    },
3007    BashExecution {
3008        command: String,
3009        output: String,
3010        exit_code: i32,
3011        #[serde(skip_serializing_if = "Option::is_none")]
3012        cancelled: Option<bool>,
3013        #[serde(skip_serializing_if = "Option::is_none")]
3014        truncated: Option<bool>,
3015        #[serde(skip_serializing_if = "Option::is_none")]
3016        full_output_path: Option<String>,
3017        #[serde(skip_serializing_if = "Option::is_none")]
3018        timestamp: Option<i64>,
3019        #[serde(flatten)]
3020        extra: HashMap<String, Value>,
3021    },
3022    BranchSummary {
3023        summary: String,
3024        from_id: String,
3025    },
3026    CompactionSummary {
3027        summary: String,
3028        tokens_before: u64,
3029    },
3030}
3031
3032impl From<Message> for SessionMessage {
3033    fn from(message: Message) -> Self {
3034        match message {
3035            Message::User(user) => Self::User {
3036                content: user.content,
3037                timestamp: Some(user.timestamp),
3038            },
3039            Message::Assistant(assistant) => Self::Assistant {
3040                message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
3041            },
3042            Message::ToolResult(result) => {
3043                let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
3044                Self::ToolResult {
3045                    tool_call_id: result.tool_call_id,
3046                    tool_name: result.tool_name,
3047                    content: result.content,
3048                    details: result.details,
3049                    is_error: result.is_error,
3050                    timestamp: Some(result.timestamp),
3051                }
3052            }
3053            Message::Custom(custom) => Self::Custom {
3054                custom_type: custom.custom_type,
3055                content: custom.content,
3056                display: custom.display,
3057                details: custom.details,
3058                timestamp: Some(custom.timestamp),
3059            },
3060        }
3061    }
3062}
3063
3064/// Model change entry.
3065#[derive(Debug, Clone, Serialize, Deserialize)]
3066#[serde(rename_all = "camelCase")]
3067pub struct ModelChangeEntry {
3068    #[serde(flatten)]
3069    pub base: EntryBase,
3070    pub provider: String,
3071    pub model_id: String,
3072}
3073
3074/// Thinking level change entry.
3075#[derive(Debug, Clone, Serialize, Deserialize)]
3076#[serde(rename_all = "camelCase")]
3077pub struct ThinkingLevelChangeEntry {
3078    #[serde(flatten)]
3079    pub base: EntryBase,
3080    pub thinking_level: String,
3081}
3082
3083/// Compaction entry.
3084#[derive(Debug, Clone, Serialize, Deserialize)]
3085#[serde(rename_all = "camelCase")]
3086pub struct CompactionEntry {
3087    #[serde(flatten)]
3088    pub base: EntryBase,
3089    pub summary: String,
3090    pub first_kept_entry_id: String,
3091    pub tokens_before: u64,
3092    #[serde(skip_serializing_if = "Option::is_none")]
3093    pub details: Option<serde_json::Value>,
3094    #[serde(skip_serializing_if = "Option::is_none")]
3095    pub from_hook: Option<bool>,
3096}
3097
3098/// Branch summary entry.
3099#[derive(Debug, Clone, Serialize, Deserialize)]
3100#[serde(rename_all = "camelCase")]
3101pub struct BranchSummaryEntry {
3102    #[serde(flatten)]
3103    pub base: EntryBase,
3104    pub from_id: String,
3105    pub summary: String,
3106    #[serde(skip_serializing_if = "Option::is_none")]
3107    pub details: Option<serde_json::Value>,
3108    #[serde(skip_serializing_if = "Option::is_none")]
3109    pub from_hook: Option<bool>,
3110}
3111
3112/// Label entry.
3113#[derive(Debug, Clone, Serialize, Deserialize)]
3114#[serde(rename_all = "camelCase")]
3115pub struct LabelEntry {
3116    #[serde(flatten)]
3117    pub base: EntryBase,
3118    pub target_id: String,
3119    #[serde(skip_serializing_if = "Option::is_none")]
3120    pub label: Option<String>,
3121}
3122
3123/// Session info entry.
3124#[derive(Debug, Clone, Serialize, Deserialize)]
3125#[serde(rename_all = "camelCase")]
3126pub struct SessionInfoEntry {
3127    #[serde(flatten)]
3128    pub base: EntryBase,
3129    #[serde(skip_serializing_if = "Option::is_none")]
3130    pub name: Option<String>,
3131}
3132
3133/// Custom entry.
3134#[derive(Debug, Clone, Serialize, Deserialize)]
3135#[serde(rename_all = "camelCase")]
3136pub struct CustomEntry {
3137    #[serde(flatten)]
3138    pub base: EntryBase,
3139    pub custom_type: String,
3140    #[serde(skip_serializing_if = "Option::is_none")]
3141    pub data: Option<serde_json::Value>,
3142}
3143
3144// ============================================================================
3145// Utilities
3146// ============================================================================
3147
3148/// Encode a working directory path for use in session directory names.
3149pub fn encode_cwd(path: &std::path::Path) -> String {
3150    let s = path.display().to_string();
3151    let s = s.trim_start_matches(['/', '\\']);
3152    let s = s.replace(['/', '\\', ':'], "-");
3153    format!("--{s}--")
3154}
3155
3156pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
3157    match message {
3158        SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
3159            content: content.clone(),
3160            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3161        })),
3162        SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
3163        SessionMessage::ToolResult {
3164            tool_call_id,
3165            tool_name,
3166            content,
3167            details,
3168            is_error,
3169            timestamp,
3170        } => Some(Message::tool_result(ToolResultMessage {
3171            tool_call_id: tool_call_id.clone(),
3172            tool_name: tool_name.clone(),
3173            content: content.clone(),
3174            details: details.clone(),
3175            is_error: *is_error,
3176            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3177        })),
3178        SessionMessage::Custom {
3179            custom_type,
3180            content,
3181            display,
3182            details,
3183            timestamp,
3184        } => Some(Message::Custom(crate::model::CustomMessage {
3185            content: content.clone(),
3186            custom_type: custom_type.clone(),
3187            display: *display,
3188            details: details.clone(),
3189            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3190        })),
3191        SessionMessage::BashExecution {
3192            command,
3193            output,
3194            exit_code,
3195            cancelled,
3196            truncated,
3197            full_output_path,
3198            timestamp,
3199            extra,
3200        } => {
3201            if extra
3202                .get("excludeFromContext")
3203                .and_then(Value::as_bool)
3204                .is_some_and(|v| v)
3205            {
3206                return None;
3207            }
3208            let text = bash_execution_to_text(
3209                command,
3210                output,
3211                *exit_code,
3212                cancelled.unwrap_or(false),
3213                truncated.unwrap_or(false),
3214                full_output_path.as_deref(),
3215            );
3216            Some(Message::User(UserMessage {
3217                content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
3218                timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3219            }))
3220        }
3221        SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
3222            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3223                "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
3224            )))]),
3225            timestamp: chrono::Utc::now().timestamp_millis(),
3226        })),
3227        SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
3228            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3229                "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
3230            )))]),
3231            timestamp: chrono::Utc::now().timestamp_millis(),
3232        })),
3233    }
3234}
3235
3236const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
3237const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
3238
3239const BRANCH_SUMMARY_PREFIX: &str =
3240    "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
3241const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
3242
3243pub(crate) fn bash_execution_to_text(
3244    command: &str,
3245    output: &str,
3246    exit_code: i32,
3247    cancelled: bool,
3248    truncated: bool,
3249    full_output_path: Option<&str>,
3250) -> String {
3251    let mut text = format!("Ran `{command}`\n");
3252    if output.is_empty() {
3253        text.push_str("(no output)");
3254    } else {
3255        text.push_str("```\n");
3256        text.push_str(output);
3257        if !output.ends_with('\n') {
3258            text.push('\n');
3259        }
3260        text.push_str("```");
3261    }
3262
3263    if cancelled {
3264        text.push_str("\n\n(command cancelled)");
3265    } else if exit_code != 0 {
3266        let _ = write!(text, "\n\nCommand exited with code {exit_code}");
3267    }
3268
3269    if truncated {
3270        if let Some(path) = full_output_path {
3271            let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
3272        }
3273    }
3274
3275    text
3276}
3277
3278/// Render session header and entries as a standalone HTML document.
3279///
3280/// Shared implementation used by both `Session::to_html()` and
3281/// `ExportSnapshot::to_html()`.
3282#[allow(clippy::too_many_lines)]
3283fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
3284    let mut html = String::new();
3285    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
3286    html.push_str("<title>Pi Session</title>");
3287    html.push_str("<style>");
3288    html.push_str(
3289        "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
3290            h1{margin:0 0 8px 0;}
3291            .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
3292            .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
3293            .msg.user{border-left:4px solid #4fc3f7;}
3294            .msg.assistant{border-left:4px solid #81c784;}
3295            .msg.tool{border-left:4px solid #ffb74d;}
3296            .msg.system{border-left:4px solid #ef9a9a;}
3297            .role{font-weight:600;margin-bottom:8px;}
3298            pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
3299            .thinking summary{cursor:pointer;}
3300            img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
3301            .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
3302            ",
3303    );
3304    html.push_str("</style></head><body>");
3305
3306    let _ = write!(
3307        html,
3308        "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
3309        escape_html(&header.id),
3310        escape_html(&header.timestamp),
3311        escape_html(&header.cwd)
3312    );
3313
3314    for entry in entries {
3315        match entry {
3316            SessionEntry::Message(message) => {
3317                html.push_str(&render_session_message(&message.message));
3318            }
3319            SessionEntry::ModelChange(change) => {
3320                let _ = write!(
3321                    html,
3322                    "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
3323                    escape_html(&change.provider),
3324                    escape_html(&change.model_id)
3325                );
3326            }
3327            SessionEntry::ThinkingLevelChange(change) => {
3328                let _ = write!(
3329                    html,
3330                    "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
3331                    escape_html(&change.thinking_level)
3332                );
3333            }
3334            SessionEntry::Compaction(compaction) => {
3335                let _ = write!(
3336                    html,
3337                    "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
3338                    escape_html(&compaction.summary)
3339                );
3340            }
3341            SessionEntry::BranchSummary(summary) => {
3342                let _ = write!(
3343                    html,
3344                    "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
3345                    escape_html(&summary.summary)
3346                );
3347            }
3348            SessionEntry::SessionInfo(info) => {
3349                if let Some(name) = &info.name {
3350                    let _ = write!(
3351                        html,
3352                        "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
3353                        escape_html(name)
3354                    );
3355                }
3356            }
3357            SessionEntry::Custom(custom) => {
3358                let _ = write!(
3359                    html,
3360                    "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
3361                    escape_html(&custom.custom_type)
3362                );
3363            }
3364            SessionEntry::Label(_) => {}
3365        }
3366    }
3367
3368    html.push_str("</body></html>");
3369    html
3370}
3371
3372fn render_session_message(message: &SessionMessage) -> String {
3373    match message {
3374        SessionMessage::User { content, .. } => {
3375            let mut html = String::new();
3376            html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
3377            html.push_str(&render_user_content(content));
3378            html.push_str("</div>");
3379            html
3380        }
3381        SessionMessage::Assistant { message } => {
3382            let mut html = String::new();
3383            html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
3384            html.push_str(&render_blocks(&message.content));
3385            html.push_str("</div>");
3386            html
3387        }
3388        SessionMessage::ToolResult {
3389            tool_name,
3390            content,
3391            is_error,
3392            details,
3393            ..
3394        } => {
3395            let mut html = String::new();
3396            let role = if *is_error { "Tool Error" } else { "Tool" };
3397            let _ = write!(
3398                html,
3399                "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
3400                role,
3401                escape_html(tool_name)
3402            );
3403            html.push_str(&render_blocks(content));
3404            if let Some(details) = details {
3405                let details_str =
3406                    serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
3407                let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
3408            }
3409            html.push_str("</div>");
3410            html
3411        }
3412        SessionMessage::Custom {
3413            custom_type,
3414            content,
3415            ..
3416        } => {
3417            let mut html = String::new();
3418            let _ = write!(
3419                html,
3420                "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
3421                escape_html(custom_type),
3422                escape_html(content)
3423            );
3424            html
3425        }
3426        SessionMessage::BashExecution {
3427            command,
3428            output,
3429            exit_code,
3430            ..
3431        } => {
3432            let mut html = String::new();
3433            let _ = write!(
3434                html,
3435                "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
3436                escape_html(command),
3437                escape_html(output)
3438            );
3439            html
3440        }
3441        SessionMessage::BranchSummary { summary, .. } => {
3442            format!(
3443                "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
3444                escape_html(summary)
3445            )
3446        }
3447        SessionMessage::CompactionSummary { summary, .. } => {
3448            format!(
3449                "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
3450                escape_html(summary)
3451            )
3452        }
3453    }
3454}
3455
3456fn render_user_content(content: &UserContent) -> String {
3457    match content {
3458        UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
3459        UserContent::Blocks(blocks) => render_blocks(blocks),
3460    }
3461}
3462
3463fn render_blocks(blocks: &[ContentBlock]) -> String {
3464    let mut html = String::new();
3465    for block in blocks {
3466        match block {
3467            ContentBlock::Text(text) => {
3468                let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
3469            }
3470            ContentBlock::Thinking(thinking) => {
3471                let _ = write!(
3472                    html,
3473                    "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
3474                    escape_html(&thinking.thinking)
3475                );
3476            }
3477            ContentBlock::Image(image) => {
3478                let _ = write!(
3479                    html,
3480                    "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
3481                    escape_html(&image.mime_type),
3482                    escape_html(&image.data)
3483                );
3484            }
3485            ContentBlock::ToolCall(tool_call) => {
3486                let args = serde_json::to_string_pretty(&tool_call.arguments)
3487                    .unwrap_or_else(|_| tool_call.arguments.to_string());
3488                let _ = write!(
3489                    html,
3490                    "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
3491                    escape_html(&tool_call.name),
3492                    escape_html(&args)
3493                );
3494            }
3495        }
3496    }
3497    html
3498}
3499
3500fn escape_html(input: &str) -> String {
3501    let mut escaped = String::with_capacity(input.len());
3502    for ch in input.chars() {
3503        match ch {
3504            '&' => escaped.push_str("&amp;"),
3505            '<' => escaped.push_str("&lt;"),
3506            '>' => escaped.push_str("&gt;"),
3507            '"' => escaped.push_str("&quot;"),
3508            '\'' => escaped.push_str("&#39;"),
3509            _ => escaped.push(ch),
3510        }
3511    }
3512    escaped
3513}
3514
3515fn user_content_to_text(content: &UserContent) -> String {
3516    match content {
3517        UserContent::Text(text) => text.clone(),
3518        UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
3519    }
3520}
3521
3522fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
3523    let mut output = String::new();
3524    for block in blocks {
3525        match block {
3526            ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
3527            ContentBlock::Image(image) => {
3528                push_line(&mut output, &format!("[image: {}]", image.mime_type));
3529            }
3530            ContentBlock::Thinking(thinking_block) => {
3531                push_line(&mut output, &thinking_block.thinking);
3532            }
3533            ContentBlock::ToolCall(call) => {
3534                push_line(&mut output, &format!("[tool call: {}]", call.name));
3535            }
3536        }
3537    }
3538    output
3539}
3540
3541fn push_line(out: &mut String, line: &str) {
3542    if !out.is_empty() {
3543        out.push('\n');
3544    }
3545    out.push_str(line);
3546}
3547
3548fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
3549    entries
3550        .iter()
3551        .filter_map(|e| e.base_id().cloned())
3552        .collect()
3553}
3554
3555fn session_entry_stats(entries: &[SessionEntry]) -> (u64, Option<String>) {
3556    let mut message_count = 0u64;
3557    let mut name = None;
3558    for entry in entries {
3559        match entry {
3560            SessionEntry::Message(_) => message_count += 1,
3561            SessionEntry::SessionInfo(info) => {
3562                if info.name.is_some() {
3563                    name.clone_from(&info.name);
3564                }
3565            }
3566            _ => {}
3567        }
3568    }
3569    (message_count, name)
3570}
3571
3572/// Minimum entry count to activate parallel deserialization (Gap E).
3573const PARALLEL_THRESHOLD: usize = 512;
3574/// Number of JSONL lines deserialized per batch in the blocking open path.
3575const JSONL_PARSE_BATCH_SIZE: usize = 8192;
3576
3577/// Parse a JSONL session file on the current (blocking) thread.
3578///
3579/// Combines Gap E (parallel deserialization) and Gap F (single-pass
3580/// finalization) for the fastest possible open path.
3581#[allow(clippy::too_many_lines)]
3582fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
3583    use std::io::BufRead;
3584
3585    let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
3586    let mut reader = std::io::BufReader::new(file);
3587
3588    let mut header_line = String::new();
3589    reader
3590        .read_line(&mut header_line)
3591        .map_err(|e| crate::Error::Io(Box::new(e)))?;
3592
3593    if header_line.trim().is_empty() {
3594        return Err(crate::Error::session("Empty session file"));
3595    }
3596
3597    // Parse header (first line)
3598    let header: SessionHeader = serde_json::from_str(&header_line)
3599        .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
3600
3601    let mut entries = Vec::new();
3602    let mut diagnostics = SessionOpenDiagnostics::default();
3603
3604    // Gap E: parallel deserialization for large sessions.
3605    // Batch processing to bound memory usage while allowing parallelism.
3606    let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
3607
3608    let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
3609    let mut current_line_num = 2; // Header is line 1
3610
3611    loop {
3612        line_batch.clear();
3613        let mut batch_eof = false;
3614
3615        for _ in 0..JSONL_PARSE_BATCH_SIZE {
3616            let mut line = String::new();
3617            match reader.read_line(&mut line) {
3618                Ok(0) => {
3619                    batch_eof = true;
3620                    break;
3621                }
3622                Ok(_) => {
3623                    if !line.trim().is_empty() {
3624                        line_batch.push((current_line_num, line));
3625                    }
3626                }
3627                Err(e) => {
3628                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
3629                        line_number: current_line_num,
3630                        error: format!("IO error reading line: {e}"),
3631                    });
3632                }
3633            }
3634            current_line_num += 1;
3635        }
3636
3637        if line_batch.is_empty() {
3638            if batch_eof {
3639                break;
3640            }
3641            continue;
3642        }
3643
3644        if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
3645            let chunk_size = (line_batch.len() / num_threads).max(64);
3646
3647            let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
3648                std::thread::scope(|s| {
3649                    line_batch
3650                        .chunks(chunk_size)
3651                        .map(|chunk| {
3652                            s.spawn(move || {
3653                                let mut ok = Vec::with_capacity(chunk.len());
3654                                let mut skip = Vec::new();
3655                                for (line_num, line) in chunk {
3656                                    match serde_json::from_str::<SessionEntry>(line) {
3657                                        Ok(entry) => ok.push(entry),
3658                                        Err(e) => {
3659                                            skip.push(SessionOpenSkippedEntry {
3660                                                line_number: *line_num,
3661                                                error: e.to_string(),
3662                                            });
3663                                        }
3664                                    }
3665                                }
3666                                (ok, skip)
3667                            })
3668                        })
3669                        .collect::<Vec<_>>()
3670                        .into_iter()
3671                        .map(|h| {
3672                            h.join().map_err(|panic_payload| {
3673                                let panic_message =
3674                                    panic_payload.downcast_ref::<String>().map_or_else(
3675                                        || {
3676                                            panic_payload.downcast_ref::<&str>().map_or_else(
3677                                                || "unknown panic payload".to_string(),
3678                                                |message| (*message).to_string(),
3679                                            )
3680                                        },
3681                                        std::clone::Clone::clone,
3682                                    );
3683                                Error::session(format!(
3684                                    "parallel session parse worker panicked: {panic_message}"
3685                                ))
3686                            })
3687                        })
3688                        .collect()
3689                });
3690            let chunk_results = chunk_results?;
3691
3692            for (chunk_entries, chunk_skipped) in chunk_results {
3693                entries.extend(chunk_entries);
3694                diagnostics.skipped_entries.extend(chunk_skipped);
3695            }
3696        } else {
3697            // Sequential path
3698            for (line_num, line) in &line_batch {
3699                match serde_json::from_str::<SessionEntry>(line) {
3700                    Ok(entry) => entries.push(entry),
3701                    Err(e) => {
3702                        diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
3703                            line_number: *line_num,
3704                            error: e.to_string(),
3705                        });
3706                    }
3707                }
3708            }
3709        }
3710
3711        if batch_eof {
3712            break;
3713        }
3714    }
3715
3716    // --- Single-pass load finalization (Gap F) ---
3717    let finalized = finalize_loaded_entries(&mut entries);
3718    for orphan in &finalized.orphans {
3719        diagnostics
3720            .orphaned_parent_links
3721            .push(SessionOpenOrphanedParentLink {
3722                entry_id: orphan.0.clone(),
3723                missing_parent_id: orphan.1.clone(),
3724            });
3725    }
3726
3727    let entry_count = entries.len();
3728
3729    Ok((
3730        Session {
3731            header,
3732            entries,
3733            path: Some(path_buf),
3734            leaf_id: finalized.leaf_id,
3735            session_dir: None,
3736            store_kind: SessionStoreKind::Jsonl,
3737            entry_ids: finalized.entry_ids,
3738            is_linear: finalized.is_linear,
3739            entry_index: finalized.entry_index,
3740            cached_message_count: finalized.message_count,
3741            cached_name: finalized.name,
3742            autosave_queue: AutosaveQueue::new(),
3743            autosave_durability: AutosaveDurabilityMode::from_env(),
3744            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
3745            header_dirty: false,
3746            appends_since_checkpoint: 0,
3747            v2_sidecar_root: None,
3748            v2_partial_hydration: false,
3749            v2_resume_mode: None,
3750            v2_message_count_offset: 0,
3751        },
3752        diagnostics,
3753    ))
3754}
3755
3756/// Open a session from its V2 sidecar store.
3757///
3758/// Reads the JSONL header (first line) for `SessionHeader`, then loads
3759/// entries from the V2 segment store via its offset index — O(index + tail)
3760/// instead of the O(n) full-file parse that `open_jsonl_blocking` performs.
3761#[allow(clippy::too_many_lines)]
3762fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
3763    // 1. Read JSONL header (first line only).
3764    let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3765    let mut reader = BufReader::new(file);
3766    let mut header_line = String::new();
3767    reader
3768        .read_line(&mut header_line)
3769        .map_err(|e| crate::Error::Io(Box::new(e)))?;
3770    let header: SessionHeader = serde_json::from_str(header_line.trim())
3771        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
3772
3773    // 2. Open V2 sidecar store.
3774    let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
3775    let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
3776
3777    // 3. Choose an explicit hydration strategy for resume:
3778    // - env override (PI_SESSION_V2_OPEN_MODE)
3779    // - auto lazy mode for large sessions
3780    let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
3781    let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
3782    if let Some(raw) = mode_override_raw.as_deref() {
3783        if parse_v2_open_mode(raw).is_none() {
3784            tracing::warn!(
3785                value = %raw,
3786                "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
3787            );
3788        }
3789    }
3790    if let Some(raw) = threshold_override_raw.as_deref() {
3791        if raw.trim().parse::<u64>().is_err() {
3792            tracing::warn!(
3793                value = %raw,
3794                "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
3795            );
3796        }
3797    }
3798
3799    let entry_count = store.entry_count();
3800    let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
3801        entry_count,
3802        mode_override_raw.as_deref(),
3803        threshold_override_raw.as_deref(),
3804    );
3805    let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
3806        && entry_count > 0
3807        && store.head().is_none()
3808    {
3809        tracing::warn!(
3810            entry_count,
3811            "active-path hydration selected but store has no head; falling back to full hydration"
3812        );
3813        V2OpenMode::Full
3814    } else {
3815        selected_mode
3816    };
3817    tracing::debug!(
3818        entry_count,
3819        lazy_threshold,
3820        selection_reason,
3821        ?mode,
3822        "selected V2 resume hydration mode"
3823    );
3824
3825    // 4. Load entries using the selected mode.
3826    let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
3827    session.path = Some(jsonl_path);
3828    session.v2_sidecar_root = Some(v2_root);
3829    session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
3830    session.v2_resume_mode = Some(mode);
3831    Ok((session, diagnostics))
3832}
3833
3834/// Create a V2 sidecar store from an existing JSONL session file.
3835///
3836/// This is the migration path: parse the full JSONL once and write each entry
3837/// into the V2 segmented store with offset index. Subsequent opens can then
3838/// use `open_from_v2_store_blocking` for O(index+tail) resume.
3839pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
3840    use std::io::BufRead;
3841
3842    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3843    let mut reader = std::io::BufReader::new(file);
3844
3845    let mut header_line = String::new();
3846    reader
3847        .read_line(&mut header_line)
3848        .map_err(|e| crate::Error::Io(Box::new(e)))?;
3849
3850    if header_line.trim().is_empty() {
3851        return Err(crate::Error::session("Empty JSONL session file"));
3852    }
3853
3854    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3855    if v2_root.exists() {
3856        std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
3857    }
3858    let mut store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
3859
3860    for line_res in reader.lines() {
3861        let line = line_res.map_err(|e| crate::Error::Io(Box::new(e)))?;
3862        if line.trim().is_empty() {
3863            continue;
3864        }
3865        let entry: SessionEntry = serde_json::from_str(&line)
3866            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
3867        let (entry_id, parent_entry_id, entry_type, payload) =
3868            session_store_v2::session_entry_to_frame_args(&entry)?;
3869        store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
3870    }
3871
3872    Ok(store)
3873}
3874
3875/// Migrate a JSONL session to V2 with full verification and event logging.
3876///
3877/// Returns the `MigrationEvent` that was recorded in the V2 store's migration
3878/// ledger. The migration is atomic: if verification fails, the sidecar is
3879/// removed and an error is returned.
3880pub fn migrate_jsonl_to_v2(
3881    jsonl_path: &Path,
3882    correlation_id: &str,
3883) -> Result<session_store_v2::MigrationEvent> {
3884    let store = create_v2_sidecar_from_jsonl(jsonl_path)?;
3885
3886    // Verify fidelity.
3887    let verification = verify_v2_against_jsonl(jsonl_path, &store)?;
3888
3889    if !(verification.entry_count_match
3890        && verification.hash_chain_match
3891        && verification.index_consistent)
3892    {
3893        // Verification failed — remove the sidecar.
3894        let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3895        if v2_root.exists() {
3896            std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
3897        }
3898        return Err(crate::Error::session(format!(
3899            "V2 migration verification failed: count={} hash={} index={}",
3900            verification.entry_count_match,
3901            verification.hash_chain_match,
3902            verification.index_consistent,
3903        )));
3904    }
3905
3906    let event = session_store_v2::MigrationEvent {
3907        schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
3908        migration_id: uuid::Uuid::new_v4().to_string(),
3909        phase: "forward".to_string(),
3910        at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3911        source_path: jsonl_path.display().to_string(),
3912        target_path: session_store_v2::v2_sidecar_path(jsonl_path)
3913            .display()
3914            .to_string(),
3915        source_format: "jsonl_v3".to_string(),
3916        target_format: "native_v2".to_string(),
3917        verification,
3918        outcome: "ok".to_string(),
3919        error_class: None,
3920        correlation_id: correlation_id.to_string(),
3921    };
3922    store.append_migration_event(event.clone())?;
3923
3924    Ok(event)
3925}
3926
3927/// Verify a V2 sidecar against its source JSONL for fidelity.
3928///
3929/// Compares entry count, entry IDs in order, and validates the V2 store's
3930/// internal integrity (checksums + hash chain).
3931pub fn verify_v2_against_jsonl(
3932    jsonl_path: &Path,
3933    store: &SessionStoreV2,
3934) -> Result<session_store_v2::MigrationVerification> {
3935    use std::io::BufRead;
3936
3937    // Parse all JSONL entries (skip header).
3938    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3939    let mut reader = std::io::BufReader::new(file);
3940
3941    let mut header_line = String::new();
3942    reader
3943        .read_line(&mut header_line)
3944        .map_err(|e| crate::Error::Io(Box::new(e)))?;
3945
3946    if header_line.trim().is_empty() {
3947        return Err(crate::Error::session("Empty JSONL session file"));
3948    }
3949
3950    let mut jsonl_ids: Vec<String> = Vec::new();
3951    for line_res in reader.lines() {
3952        let line = line_res.map_err(|e| crate::Error::Io(Box::new(e)))?;
3953        if line.trim().is_empty() {
3954            continue;
3955        }
3956        let entry: SessionEntry = serde_json::from_str(&line)
3957            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
3958        let id = entry
3959            .base_id()
3960            .cloned()
3961            .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
3962        jsonl_ids.push(id);
3963    }
3964
3965    // Read V2 store entries.
3966    let frames = store.read_all_entries()?;
3967    let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
3968
3969    let entry_count_match = jsonl_ids.len() == v2_ids.len() && jsonl_ids == v2_ids;
3970
3971    // Check hash chain via validate_integrity (which also verifies checksums).
3972    let index_consistent = store.validate_integrity().is_ok();
3973
3974    // Hash chain is validated as part of integrity validation for the store.
3975    let hash_chain_match = index_consistent;
3976
3977    Ok(session_store_v2::MigrationVerification {
3978        entry_count_match,
3979        hash_chain_match,
3980        index_consistent,
3981    })
3982}
3983
3984/// Remove a V2 sidecar, reverting to JSONL-only storage.
3985///
3986/// Logs a rollback event in the migration ledger before removing the sidecar.
3987/// Returns `Ok(())` if the sidecar was removed (or didn't exist).
3988pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
3989    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3990    if !v2_root.exists() {
3991        return Ok(());
3992    }
3993
3994    // Try to log the rollback event before deleting.
3995    if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
3996        let event = session_store_v2::MigrationEvent {
3997            schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
3998            migration_id: uuid::Uuid::new_v4().to_string(),
3999            phase: "rollback_to_jsonl".to_string(),
4000            at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4001            source_path: v2_root.display().to_string(),
4002            target_path: jsonl_path.display().to_string(),
4003            source_format: "native_v2".to_string(),
4004            target_format: "jsonl_v3".to_string(),
4005            verification: session_store_v2::MigrationVerification {
4006                entry_count_match: true,
4007                hash_chain_match: true,
4008                index_consistent: true,
4009            },
4010            outcome: "ok".to_string(),
4011            error_class: None,
4012            correlation_id: correlation_id.to_string(),
4013        };
4014        let _ = store.append_migration_event(event);
4015    }
4016
4017    std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4018    Ok(())
4019}
4020
4021/// Current migration state of a JSONL session.
4022#[derive(Debug, Clone, PartialEq, Eq)]
4023pub enum MigrationState {
4024    /// No V2 sidecar exists — pure JSONL.
4025    Unmigrated,
4026    /// V2 sidecar exists and passes integrity validation.
4027    Migrated,
4028    /// V2 sidecar exists but fails integrity validation.
4029    Corrupt { error: String },
4030    /// V2 sidecar directory exists but is missing critical files (partial write).
4031    Partial,
4032}
4033
4034/// Query the migration state of a JSONL session file.
4035pub fn migration_status(jsonl_path: &Path) -> MigrationState {
4036    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4037    if !v2_root.exists() {
4038        return MigrationState::Unmigrated;
4039    }
4040
4041    let segments_dir = v2_root.join("segments");
4042    if !segments_dir.exists() {
4043        return MigrationState::Partial;
4044    }
4045
4046    let index_path = v2_root.join("index").join("offsets.jsonl");
4047    if !index_path.exists() {
4048        match jsonl_has_entry_lines(jsonl_path) {
4049            Ok(true) => return MigrationState::Partial,
4050            Ok(false) => {}
4051            Err(e) => {
4052                return MigrationState::Corrupt {
4053                    error: e.to_string(),
4054                };
4055            }
4056        }
4057    }
4058
4059    // Try to open and validate.
4060    match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
4061        Ok(store) => match store.validate_integrity() {
4062            Ok(()) => MigrationState::Migrated,
4063            Err(e) => MigrationState::Corrupt {
4064                error: e.to_string(),
4065            },
4066        },
4067        Err(e) => MigrationState::Corrupt {
4068            error: e.to_string(),
4069        },
4070    }
4071}
4072
4073/// Dry-run a JSONL → V2 migration without persisting the sidecar.
4074///
4075/// Creates the V2 store in a temporary directory, runs verification, then
4076/// cleans up. Returns the verification result so callers can inspect
4077/// entry counts and integrity before committing.
4078pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
4079    let tmp_dir =
4080        tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
4081    let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
4082
4083    // Parse JSONL and populate a temporary V2 store.
4084    let contents =
4085        std::fs::read_to_string(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4086    let mut lines = contents.lines();
4087    let _header = lines
4088        .next()
4089        .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
4090
4091    let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
4092    for line in lines {
4093        if line.trim().is_empty() {
4094            continue;
4095        }
4096        let entry: SessionEntry = serde_json::from_str(line)
4097            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
4098        let (entry_id, parent_entry_id, entry_type, payload) =
4099            session_store_v2::session_entry_to_frame_args(&entry)?;
4100        store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
4101    }
4102
4103    // Verify against source JSONL (but using the temp store).
4104    verify_v2_against_jsonl(jsonl_path, &store)
4105    // tmp_dir drops here → auto-cleanup
4106}
4107
4108/// Recover from a partial or corrupted V2 migration.
4109///
4110/// If the sidecar is in a partial/corrupt state, removes it and optionally
4111/// re-runs the migration. Returns the final migration state.
4112pub fn recover_partial_migration(
4113    jsonl_path: &Path,
4114    correlation_id: &str,
4115    re_migrate: bool,
4116) -> Result<MigrationState> {
4117    let status = migration_status(jsonl_path);
4118    match &status {
4119        MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
4120        MigrationState::Partial | MigrationState::Corrupt { .. } => {
4121            // Remove the broken sidecar.
4122            let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4123            if v2_root.exists() {
4124                std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4125            }
4126
4127            if re_migrate {
4128                migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
4129                Ok(MigrationState::Migrated)
4130            } else {
4131                Ok(MigrationState::Unmigrated)
4132            }
4133        }
4134    }
4135}
4136
4137fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
4138    let contents =
4139        std::fs::read_to_string(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4140    let mut lines = contents.lines();
4141    if lines.next().is_none() {
4142        return Err(crate::Error::session("Empty JSONL session file"));
4143    }
4144    Ok(lines.any(|line| !line.trim().is_empty()))
4145}
4146
4147/// Result of single-pass load finalization (Gap F).
4148///
4149/// Replaces the previous multi-pass approach (`ensure_entry_ids` +
4150/// `entry_id_set` + orphan detection + stats) with a single O(n) scan
4151/// that produces all required caches at once.
4152struct LoadFinalization {
4153    leaf_id: Option<String>,
4154    entry_ids: HashSet<String>,
4155    entry_index: HashMap<String, usize>,
4156    message_count: u64,
4157    name: Option<String>,
4158    is_linear: bool,
4159    orphans: Vec<(String, String)>,
4160}
4161
4162/// Single-pass finalization of loaded entries.
4163///
4164/// 1. Assigns IDs to entries missing them (`ensure_entry_ids` work).
4165/// 2. Builds `entry_ids` set and `entry_index` map.
4166/// 3. Detects orphaned parent links.
4167/// 4. Computes `session_entry_stats` (message count + name).
4168/// 5. Determines `is_linear` (no branching, leaf == last entry).
4169fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
4170    // First pass: assign missing IDs (same logic as `ensure_entry_ids`).
4171    let mut entry_ids: HashSet<String> = entries
4172        .iter()
4173        .filter_map(|e| e.base_id().cloned())
4174        .collect();
4175    for entry in entries.iter_mut() {
4176        if entry.base().id.is_none() {
4177            let id = generate_entry_id(&entry_ids);
4178            entry.base_mut().id = Some(id.clone());
4179            entry_ids.insert(id);
4180        }
4181    }
4182
4183    // Second (main) pass: build all caches in one scan.
4184    let mut entry_index = HashMap::with_capacity(entries.len());
4185    let mut message_count = 0u64;
4186    let mut name: Option<String> = None;
4187    let mut leaf_id: Option<String> = None;
4188    let mut orphans = Vec::new();
4189    // Track parent_ids seen as children's parent to detect branching.
4190    let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
4191    let mut has_branching = false;
4192
4193    for (idx, entry) in entries.iter().enumerate() {
4194        let Some(id) = entry.base_id() else {
4195            continue;
4196        };
4197        entry_index.insert(id.clone(), idx);
4198        leaf_id = Some(id.clone());
4199
4200        // Orphan detection.
4201        if let Some(parent_id) = entry.base().parent_id.as_ref() {
4202            if !entry_ids.contains(parent_id) {
4203                orphans.push((id.clone(), parent_id.clone()));
4204            }
4205        }
4206
4207        // Branch detection: if any parent_id has >1 child, it's branched.
4208        if !has_branching {
4209            let parent_key = entry.base().parent_id.as_deref();
4210            let count = parent_id_child_count.entry(parent_key).or_insert(0);
4211            *count += 1;
4212            if *count > 1 {
4213                has_branching = true;
4214            }
4215        }
4216
4217        // Stats.
4218        match entry {
4219            SessionEntry::Message(_) => message_count += 1,
4220            SessionEntry::SessionInfo(info) => {
4221                if info.name.is_some() {
4222                    name.clone_from(&info.name);
4223                }
4224            }
4225            _ => {}
4226        }
4227    }
4228
4229    // is_linear: no branching detected in the entry set.
4230    // Note: callers (e.g. rebuild_all_caches) add the additional check that
4231    // self.leaf_id == finalized.leaf_id to confirm we're at the tip.
4232    let is_linear = !has_branching;
4233
4234    LoadFinalization {
4235        leaf_id,
4236        entry_ids,
4237        entry_index,
4238        message_count,
4239        name,
4240        is_linear,
4241        orphans,
4242    }
4243}
4244
4245fn parse_env_bool(value: &str) -> bool {
4246    matches!(
4247        value.trim().to_ascii_lowercase().as_str(),
4248        "1" | "true" | "yes" | "on"
4249    )
4250}
4251
4252fn session_entry_id_cache_enabled() -> bool {
4253    static ENABLED: OnceLock<bool> = OnceLock::new();
4254    *ENABLED.get_or_init(|| {
4255        std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
4256    })
4257}
4258
4259fn ensure_entry_ids(entries: &mut [SessionEntry]) {
4260    let mut existing = entry_id_set(entries);
4261    for entry in entries.iter_mut() {
4262        if entry.base().id.is_none() {
4263            let id = generate_entry_id(&existing);
4264            entry.base_mut().id = Some(id.clone());
4265            existing.insert(id);
4266        }
4267    }
4268}
4269
4270/// Generate a unique entry ID (8 hex characters), falling back to UUID on collision.
4271fn generate_entry_id(existing: &HashSet<String>) -> String {
4272    for _ in 0..100 {
4273        let uuid = uuid::Uuid::new_v4();
4274        let id = uuid.simple().to_string()[..8].to_string();
4275        if !existing.contains(&id) {
4276            return id;
4277        }
4278    }
4279    uuid::Uuid::new_v4().to_string()
4280}
4281
4282#[cfg(test)]
4283mod tests {
4284    use super::*;
4285    use crate::model::{Cost, StopReason, Usage};
4286    use asupersync::runtime::RuntimeBuilder;
4287    use clap::Parser;
4288    use std::future::Future;
4289
4290    fn make_test_message(text: &str) -> SessionMessage {
4291        SessionMessage::User {
4292            content: UserContent::Text(text.to_string()),
4293            timestamp: Some(0),
4294        }
4295    }
4296
4297    fn run_async<T>(future: impl Future<Output = T>) -> T {
4298        let runtime = RuntimeBuilder::current_thread()
4299            .build()
4300            .expect("build runtime");
4301        runtime.block_on(future)
4302    }
4303
4304    #[test]
4305    fn v2_open_mode_parser_supports_expected_values() {
4306        assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
4307        assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
4308        assert_eq!(
4309            parse_v2_open_mode("active_path"),
4310            Some(V2OpenMode::ActivePath)
4311        );
4312        assert_eq!(
4313            parse_v2_open_mode("active-path"),
4314            Some(V2OpenMode::ActivePath)
4315        );
4316        assert_eq!(
4317            parse_v2_open_mode("tail"),
4318            Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
4319        );
4320        assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
4321        assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
4322        assert_eq!(parse_v2_open_mode("bad-mode"), None);
4323        assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
4324    }
4325
4326    #[test]
4327    fn v2_open_mode_selection_prefers_env_override_then_threshold() {
4328        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
4329        assert_eq!(mode, V2OpenMode::Full);
4330        assert_eq!(reason, "env_override");
4331        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
4332
4333        let (mode, reason, threshold) =
4334            select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
4335        assert_eq!(
4336            mode,
4337            V2OpenMode::ActivePath,
4338            "invalid threshold falls back to default threshold"
4339        );
4340        assert_eq!(reason, "entry_count_above_lazy_threshold");
4341        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
4342
4343        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
4344        assert_eq!(mode, V2OpenMode::ActivePath);
4345        assert_eq!(reason, "entry_count_above_lazy_threshold");
4346        assert_eq!(threshold, 500);
4347
4348        let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
4349        assert_eq!(mode, V2OpenMode::Full);
4350        assert_eq!(reason, "default_full");
4351        assert_eq!(threshold, 500);
4352    }
4353
4354    #[test]
4355    fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
4356        let temp_dir = tempfile::tempdir().unwrap();
4357        let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
4358
4359        // Build a branching session:
4360        // root -> a -> b
4361        //           \-> c (active leaf)
4362        let mut seed = Session::create();
4363        seed.path = Some(path.clone());
4364        let _id_root = seed.append_message(make_test_message("root"));
4365        let id_a = seed.append_message(make_test_message("a"));
4366        let id_b = seed.append_message(make_test_message("main-branch"));
4367        assert!(seed.create_branch_from(&id_a));
4368        let id_c = seed.append_message(make_test_message("side-branch"));
4369        run_async(async { seed.save().await }).unwrap();
4370
4371        // Build sidecar and reopen in ActivePath mode.
4372        create_v2_sidecar_from_jsonl(&path).unwrap();
4373        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
4374        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
4375        let (mut loaded, _) =
4376            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
4377        loaded.path = Some(path.clone());
4378        loaded.v2_sidecar_root = Some(v2_root);
4379        loaded.v2_partial_hydration = true;
4380        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
4381
4382        let active_ids: Vec<String> = loaded
4383            .entries
4384            .iter()
4385            .filter_map(|entry| entry.base().id.clone())
4386            .collect();
4387        assert!(
4388            !active_ids.contains(&id_b),
4389            "active path intentionally excludes non-leaf sibling branch"
4390        );
4391        assert!(active_ids.contains(&id_c));
4392
4393        // Force full rewrite path (header dirty). Save must rehydrate first so b survives.
4394        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
4395        run_async(async { loaded.save().await }).unwrap();
4396
4397        let (reopened, _) =
4398            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
4399        let reopened_ids: Vec<String> = reopened
4400            .entries
4401            .iter()
4402            .filter_map(|entry| entry.base().id.clone())
4403            .collect();
4404        assert!(
4405            reopened_ids.contains(&id_b),
4406            "non-active branch entry must survive full rewrite after lazy hydration"
4407        );
4408        assert!(reopened_ids.contains(&id_c));
4409        assert_eq!(reopened_ids.len(), 4);
4410    }
4411
4412    #[test]
4413    fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
4414        let temp_dir = tempfile::tempdir().unwrap();
4415        let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
4416
4417        let mut seed = Session::create();
4418        seed.path = Some(path.clone());
4419        let _id_root = seed.append_message(make_test_message("root"));
4420        let id_a = seed.append_message(make_test_message("a"));
4421        let id_b = seed.append_message(make_test_message("main-branch"));
4422        assert!(seed.create_branch_from(&id_a));
4423        let _id_c = seed.append_message(make_test_message("side-branch"));
4424        run_async(async { seed.save().await }).unwrap();
4425
4426        create_v2_sidecar_from_jsonl(&path).unwrap();
4427        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
4428        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
4429        let (mut loaded, _) =
4430            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
4431        loaded.path = Some(path.clone());
4432        loaded.v2_sidecar_root = Some(v2_root);
4433        loaded.v2_partial_hydration = true;
4434        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
4435
4436        let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
4437        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
4438        run_async(async { loaded.save().await }).unwrap();
4439
4440        let (reopened, _) =
4441            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
4442        let reopened_ids: Vec<String> = reopened
4443            .entries
4444            .iter()
4445            .filter_map(|entry| entry.base().id.clone())
4446            .collect();
4447        assert!(
4448            reopened_ids.contains(&id_b),
4449            "non-active branch entry must survive rehydration+save"
4450        );
4451        assert!(
4452            reopened_ids.contains(&new_id),
4453            "pending entry appended on partial session must be preserved"
4454        );
4455        assert_eq!(reopened_ids.len(), 5);
4456    }
4457
4458    #[test]
4459    fn test_session_handle_mutations_defer_persistence_side_effects() {
4460        let temp_dir = tempfile::tempdir().expect("temp dir");
4461        let mut session = Session::create();
4462        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
4463        // Point at a directory path so an eager save would fail with an IO error.
4464        session.path = Some(temp_dir.path().to_path_buf());
4465        let handle = SessionHandle(Arc::new(Mutex::new(session)));
4466
4467        run_async(async { handle.set_name("deferred-save".to_string()).await })
4468            .expect("set_name should not trigger immediate save");
4469        run_async(async { handle.append_message(make_test_message("hello")).await })
4470            .expect("append_message should not trigger immediate save");
4471        run_async(async {
4472            handle
4473                .append_custom_entry(
4474                    "marker".to_string(),
4475                    Some(serde_json::json!({ "value": 42 })),
4476                )
4477                .await
4478        })
4479        .expect("append_custom_entry should not trigger immediate save");
4480        run_async(async {
4481            handle
4482                .set_model("prov".to_string(), "model".to_string())
4483                .await
4484        })
4485        .expect("set_model should not trigger immediate save");
4486        run_async(async { handle.set_thinking_level("high".to_string()).await })
4487            .expect("set_thinking_level should not trigger immediate save");
4488
4489        let branch = run_async(async { handle.get_branch().await });
4490        let message_id = branch
4491            .iter()
4492            .find_map(|entry| {
4493                if entry.get("type").and_then(Value::as_str) == Some("message") {
4494                    entry
4495                        .get("id")
4496                        .and_then(Value::as_str)
4497                        .map(ToString::to_string)
4498                } else {
4499                    None
4500                }
4501            })
4502            .expect("message entry id in branch");
4503        run_async(async {
4504            handle
4505                .set_label(message_id, Some("hot-path".to_string()))
4506                .await
4507        })
4508        .expect("set_label should not trigger immediate save");
4509
4510        let state = run_async(async { handle.get_state().await });
4511        assert_eq!(
4512            state.get("sessionName").and_then(Value::as_str),
4513            Some("deferred-save")
4514        );
4515        assert_eq!(
4516            state.get("thinkingLevel").and_then(Value::as_str),
4517            Some("high")
4518        );
4519        assert_eq!(
4520            state.get("durabilityMode").and_then(Value::as_str),
4521            Some("throughput")
4522        );
4523        assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
4524        assert_eq!(
4525            state
4526                .get("model")
4527                .and_then(|model| model.get("provider"))
4528                .and_then(Value::as_str),
4529            Some("prov")
4530        );
4531        assert_eq!(
4532            state
4533                .get("model")
4534                .and_then(|model| model.get("id"))
4535                .and_then(Value::as_str),
4536            Some("model")
4537        );
4538
4539        let (provider, model_id) = run_async(async { handle.get_model().await });
4540        assert_eq!(provider.as_deref(), Some("prov"));
4541        assert_eq!(model_id.as_deref(), Some("model"));
4542    }
4543
4544    #[test]
4545    fn test_autosave_queue_coalesces_mutations_per_flush() {
4546        let temp_dir = tempfile::tempdir().expect("temp dir");
4547        let mut session = Session::create();
4548        session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
4549
4550        session.append_message(make_test_message("one"));
4551        session.append_custom_entry("marker".to_string(), None);
4552        session.append_message(make_test_message("two"));
4553
4554        let before = session.autosave_metrics();
4555        assert_eq!(before.pending_mutations, 3);
4556        assert!(before.coalesced_mutations >= 2);
4557        assert_eq!(before.flush_succeeded, 0);
4558
4559        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
4560            .expect("periodic flush");
4561
4562        let after = session.autosave_metrics();
4563        assert_eq!(after.pending_mutations, 0);
4564        assert_eq!(after.flush_started, 1);
4565        assert_eq!(after.flush_succeeded, 1);
4566        assert_eq!(after.last_flush_batch_size, 3);
4567        assert_eq!(
4568            after.last_flush_trigger,
4569            Some(AutosaveFlushTrigger::Periodic)
4570        );
4571    }
4572
4573    #[test]
4574    fn test_autosave_queue_backpressure_is_bounded() {
4575        let mut session = Session::create();
4576        session.set_autosave_queue_limit_for_test(2);
4577
4578        for i in 0..5 {
4579            session.append_message(make_test_message(&format!("message-{i}")));
4580        }
4581
4582        let metrics = session.autosave_metrics();
4583        assert_eq!(metrics.max_pending_mutations, 2);
4584        assert_eq!(metrics.pending_mutations, 2);
4585        assert_eq!(metrics.backpressure_events, 3);
4586        assert!(metrics.coalesced_mutations >= 4);
4587    }
4588
4589    #[test]
4590    fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
4591        let temp_dir = tempfile::tempdir().expect("temp dir");
4592
4593        let mut strict = Session::create();
4594        // Point at a directory path so strict shutdown flush attempts fail.
4595        strict.path = Some(temp_dir.path().to_path_buf());
4596        strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
4597        strict.append_message(make_test_message("strict"));
4598
4599        run_async(async { strict.flush_autosave_on_shutdown().await })
4600            .expect_err("strict mode should propagate shutdown flush failure");
4601        let strict_metrics = strict.autosave_metrics();
4602        assert_eq!(strict_metrics.flush_failed, 1);
4603        assert!(strict_metrics.pending_mutations > 0);
4604
4605        let mut throughput = Session::create();
4606        throughput.path = Some(temp_dir.path().to_path_buf());
4607        throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
4608        throughput.append_message(make_test_message("throughput"));
4609
4610        run_async(async { throughput.flush_autosave_on_shutdown().await })
4611            .expect("throughput mode skips shutdown flush");
4612        let throughput_metrics = throughput.autosave_metrics();
4613        assert_eq!(throughput_metrics.flush_started, 0);
4614        assert_eq!(throughput_metrics.pending_mutations, 1);
4615    }
4616
4617    #[test]
4618    fn test_session_new_prefers_cli_durability_mode_over_config() {
4619        let cli =
4620            crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
4621        let config: Config =
4622            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
4623        let session =
4624            run_async(async { Session::new(&cli, &config).await }).expect("create session");
4625        assert_eq!(
4626            session.autosave_durability_mode(),
4627            AutosaveDurabilityMode::Strict
4628        );
4629    }
4630
4631    #[test]
4632    fn test_session_new_uses_config_durability_mode_when_cli_unset() {
4633        let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
4634        let config: Config =
4635            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
4636        let session =
4637            run_async(async { Session::new(&cli, &config).await }).expect("create session");
4638        assert_eq!(
4639            session.autosave_durability_mode(),
4640            AutosaveDurabilityMode::Throughput
4641        );
4642    }
4643
4644    #[test]
4645    fn test_resolve_autosave_durability_mode_precedence() {
4646        assert_eq!(
4647            resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
4648            AutosaveDurabilityMode::Strict
4649        );
4650        assert_eq!(
4651            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
4652            AutosaveDurabilityMode::Throughput
4653        );
4654        assert_eq!(
4655            resolve_autosave_durability_mode(None, None, Some("strict")),
4656            AutosaveDurabilityMode::Strict
4657        );
4658        assert_eq!(
4659            resolve_autosave_durability_mode(None, None, None),
4660            AutosaveDurabilityMode::Balanced
4661        );
4662    }
4663
4664    #[test]
4665    fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
4666        assert_eq!(
4667            resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
4668            AutosaveDurabilityMode::Throughput
4669        );
4670        assert_eq!(
4671            resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
4672            AutosaveDurabilityMode::Strict
4673        );
4674        assert_eq!(
4675            resolve_autosave_durability_mode(None, None, Some("bad")),
4676            AutosaveDurabilityMode::Balanced
4677        );
4678    }
4679
4680    #[test]
4681    fn test_get_share_viewer_url_matches_legacy() {
4682        assert_eq!(
4683            build_share_viewer_url(None, "gist-123"),
4684            "https://buildwithpi.ai/session/#gist-123"
4685        );
4686        assert_eq!(
4687            build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
4688            "https://example.com/session/#gist-123"
4689        );
4690        assert_eq!(
4691            build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
4692            "https://example.com/session#gist-123"
4693        );
4694        // Legacy JS uses `process.env.PI_SHARE_VIEWER_URL || DEFAULT`, so empty-string should
4695        // fall back to default.
4696        assert_eq!(
4697            build_share_viewer_url(Some(""), "gist-123"),
4698            "https://buildwithpi.ai/session/#gist-123"
4699        );
4700    }
4701
4702    #[test]
4703    fn test_session_linear_history() {
4704        let mut session = Session::in_memory();
4705
4706        let id1 = session.append_message(make_test_message("Hello"));
4707        let id2 = session.append_message(make_test_message("World"));
4708        let id3 = session.append_message(make_test_message("Test"));
4709
4710        // Check leaf is the last entry
4711        assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
4712
4713        // Check path from last entry
4714        let path = session.get_path_to_entry(&id3);
4715        assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
4716
4717        // Check only one leaf
4718        let leaves = session.list_leaves();
4719        assert_eq!(leaves.len(), 1);
4720        assert_eq!(leaves[0], id3);
4721    }
4722
4723    #[test]
4724    fn test_session_branching() {
4725        let mut session = Session::in_memory();
4726
4727        // Create linear history: A -> B -> C
4728        let id_a = session.append_message(make_test_message("A"));
4729        let id_b = session.append_message(make_test_message("B"));
4730        let id_c = session.append_message(make_test_message("C"));
4731
4732        // Now branch from B: A -> B -> D
4733        assert!(session.create_branch_from(&id_b));
4734        let id_d = session.append_message(make_test_message("D"));
4735
4736        // Should have 2 leaves: C and D
4737        let leaves = session.list_leaves();
4738        assert_eq!(leaves.len(), 2);
4739        assert!(leaves.contains(&id_c));
4740        assert!(leaves.contains(&id_d));
4741
4742        // Path to D should be A -> B -> D
4743        let path_to_d = session.get_path_to_entry(&id_d);
4744        assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
4745
4746        // Path to C should be A -> B -> C
4747        let path_to_c = session.get_path_to_entry(&id_c);
4748        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
4749    }
4750
4751    #[test]
4752    fn test_session_navigation() {
4753        let mut session = Session::in_memory();
4754
4755        let id1 = session.append_message(make_test_message("First"));
4756        let id2 = session.append_message(make_test_message("Second"));
4757
4758        // Navigate to first entry
4759        assert!(session.navigate_to(&id1));
4760        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
4761
4762        // Navigate to non-existent entry
4763        assert!(!session.navigate_to("nonexistent"));
4764        // leaf_id unchanged
4765        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
4766
4767        // Navigate back to second
4768        assert!(session.navigate_to(&id2));
4769        assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
4770    }
4771
4772    #[test]
4773    fn test_session_get_children() {
4774        let mut session = Session::in_memory();
4775
4776        // A -> B -> C
4777        //   -> D
4778        let id_a = session.append_message(make_test_message("A"));
4779        let id_b = session.append_message(make_test_message("B"));
4780        let _id_c = session.append_message(make_test_message("C"));
4781
4782        // Branch from A
4783        session.create_branch_from(&id_a);
4784        let id_d = session.append_message(make_test_message("D"));
4785
4786        // A should have 2 children: B and D
4787        let children_a = session.get_children(Some(&id_a));
4788        assert_eq!(children_a.len(), 2);
4789        assert!(children_a.contains(&id_b));
4790        assert!(children_a.contains(&id_d));
4791
4792        // Root (None) should have 1 child: A
4793        let root_children = session.get_children(None);
4794        assert_eq!(root_children.len(), 1);
4795        assert_eq!(root_children[0], id_a);
4796    }
4797
4798    #[test]
4799    fn test_branch_summary() {
4800        let mut session = Session::in_memory();
4801
4802        // Linear: A -> B
4803        let id_a = session.append_message(make_test_message("A"));
4804        let id_b = session.append_message(make_test_message("B"));
4805
4806        let info = session.branch_summary();
4807        assert_eq!(info.total_entries, 2);
4808        assert_eq!(info.leaf_count, 1);
4809        assert_eq!(info.branch_point_count, 0);
4810
4811        // Create branch: A -> B, A -> C
4812        session.create_branch_from(&id_a);
4813        let _id_c = session.append_message(make_test_message("C"));
4814
4815        let info = session.branch_summary();
4816        assert_eq!(info.total_entries, 3);
4817        assert_eq!(info.leaf_count, 2);
4818        assert_eq!(info.branch_point_count, 1);
4819        assert!(info.branch_points.contains(&id_a));
4820        assert!(info.leaves.contains(&id_b));
4821    }
4822
4823    #[test]
4824    fn test_session_jsonl_serialization() {
4825        let temp = tempfile::tempdir().unwrap();
4826        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4827        session.header.provider = Some("anthropic".to_string());
4828        session.header.model_id = Some("claude-test".to_string());
4829        session.header.thinking_level = Some("medium".to_string());
4830
4831        let user_id = session.append_message(make_test_message("Hello"));
4832        let assistant = AssistantMessage {
4833            content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
4834            api: "anthropic".to_string(),
4835            provider: "anthropic".to_string(),
4836            model: "claude-test".to_string(),
4837            usage: Usage::default(),
4838            stop_reason: StopReason::Stop,
4839            error_message: None,
4840            timestamp: 0,
4841        };
4842        session.append_message(SessionMessage::Assistant { message: assistant });
4843        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
4844        session.append_thinking_level_change("high".to_string());
4845        session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
4846        session.append_branch_summary(user_id, "branch".to_string(), None, None);
4847        session.append_session_info(Some("my-session".to_string()));
4848
4849        run_async(async { session.save().await }).unwrap();
4850
4851        let path = session.path.clone().unwrap();
4852        let contents = std::fs::read_to_string(path).unwrap();
4853        let mut lines = contents.lines();
4854
4855        let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
4856        assert_eq!(header["type"], "session");
4857        assert_eq!(header["version"], SESSION_VERSION);
4858
4859        let mut types = Vec::new();
4860        for line in lines {
4861            let value: serde_json::Value = serde_json::from_str(line).unwrap();
4862            let entry_type = value["type"].as_str().unwrap_or_default().to_string();
4863            types.push(entry_type);
4864        }
4865
4866        assert!(types.contains(&"message".to_string()));
4867        assert!(types.contains(&"model_change".to_string()));
4868        assert!(types.contains(&"thinking_level_change".to_string()));
4869        assert!(types.contains(&"compaction".to_string()));
4870        assert!(types.contains(&"branch_summary".to_string()));
4871        assert!(types.contains(&"session_info".to_string()));
4872    }
4873
4874    #[test]
4875    fn test_save_handles_short_or_empty_session_id() {
4876        let temp = tempfile::tempdir().unwrap();
4877
4878        let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4879        short_id_session.header.id = "x".to_string();
4880        run_async(async { short_id_session.save().await }).expect("save with short id");
4881        let short_name = short_id_session
4882            .path
4883            .as_ref()
4884            .and_then(|p| p.file_name())
4885            .and_then(|n| n.to_str())
4886            .expect("short id filename");
4887        assert!(short_name.contains("_x."));
4888
4889        let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4890        empty_id_session.header.id.clear();
4891        run_async(async { empty_id_session.save().await }).expect("save with empty id");
4892        let empty_name = empty_id_session
4893            .path
4894            .as_ref()
4895            .and_then(|p| p.file_name())
4896            .and_then(|n| n.to_str())
4897            .expect("empty id filename");
4898        assert!(empty_name.contains("_session."));
4899
4900        let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4901        unsafe_id_session.header.id = "../etc/passwd".to_string();
4902        run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
4903        let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
4904        let unsafe_name = unsafe_path
4905            .file_name()
4906            .and_then(|n| n.to_str())
4907            .expect("unsafe id filename");
4908        assert!(unsafe_name.contains("____etc_p."));
4909        let expected_dir = temp
4910            .path()
4911            .join(encode_cwd(&std::env::current_dir().unwrap()));
4912        assert_eq!(
4913            unsafe_path.parent().expect("unsafe id parent"),
4914            expected_dir.as_path()
4915        );
4916    }
4917
4918    #[test]
4919    fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
4920        let temp = tempfile::tempdir().unwrap();
4921        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4922
4923        let first_id = session.append_message(make_test_message("Hello"));
4924        let second_id = session.append_message(make_test_message("World"));
4925        assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
4926
4927        run_async(async { session.save().await }).unwrap();
4928        let path = session.path.clone().expect("session path set");
4929
4930        let mut lines = std::fs::read_to_string(&path)
4931            .expect("read session")
4932            .lines()
4933            .map(str::to_string)
4934            .collect::<Vec<_>>();
4935        assert!(lines.len() >= 3, "expected header + 2 entries");
4936
4937        let corrupted_line_number = lines.len(); // 1-based
4938        let last_index = lines.len() - 1;
4939        lines[last_index] = "{ this is not json }".to_string();
4940
4941        let corrupted_path = temp.path().join("corrupted.jsonl");
4942        std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
4943            .expect("write corrupted session");
4944
4945        let (loaded, diagnostics) = run_async(async {
4946            Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
4947        })
4948        .expect("open corrupted session");
4949
4950        assert_eq!(diagnostics.skipped_entries.len(), 1);
4951        assert_eq!(
4952            diagnostics.skipped_entries[0].line_number,
4953            corrupted_line_number
4954        );
4955
4956        let warnings = diagnostics.warning_lines();
4957        assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
4958        assert!(
4959            warnings[0].starts_with(&format!(
4960                "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
4961            )),
4962            "unexpected warning: {}",
4963            warnings[0]
4964        );
4965        assert_eq!(
4966            warnings[1],
4967            "Warning: Skipped 1 corrupted entries while loading session"
4968        );
4969
4970        assert_eq!(
4971            loaded.entries.len(),
4972            session.entries.len() - 1,
4973            "expected last entry to be dropped"
4974        );
4975        assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
4976    }
4977
4978    #[test]
4979    fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
4980        let temp = tempfile::tempdir().unwrap();
4981        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4982
4983        let root_id = session.append_message(make_test_message("Hello"));
4984        session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
4985        session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
4986
4987        run_async(async { session.save().await }).unwrap();
4988        let path = session.path.clone().expect("session path set");
4989
4990        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
4991            .expect("reopen session");
4992
4993        assert!(loaded.entries.iter().any(|entry| {
4994            matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary == "compacted" && compaction.tokens_before == 123)
4995        }));
4996        assert!(loaded.entries.iter().any(|entry| {
4997            matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary == "branch summary")
4998        }));
4999
5000        let html = loaded.to_html();
5001        assert!(html.contains("compacted"));
5002        assert!(html.contains("branch summary"));
5003    }
5004
5005    #[test]
5006    fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
5007        let temp = tempfile::tempdir().unwrap();
5008        let base_dir = temp.path().join("sessions");
5009
5010        let mut session = Session::create_with_dir(Some(base_dir));
5011        session.append_message(make_test_message("Hello"));
5012
5013        run_async(async { session.save().await }).expect("initial save");
5014        let path = session.path.clone().expect("session path set");
5015
5016        let path1 = path.clone();
5017        let path2 = path.clone();
5018
5019        let t1 = std::thread::spawn(move || {
5020            let runtime = RuntimeBuilder::current_thread()
5021                .build()
5022                .expect("build runtime");
5023            runtime.block_on(async move {
5024                let mut s = Session::open(path1.to_string_lossy().as_ref())
5025                    .await
5026                    .expect("open session");
5027                s.append_message(make_test_message("From thread 1"));
5028                s.save().await
5029            })
5030        });
5031
5032        let t2 = std::thread::spawn(move || {
5033            let runtime = RuntimeBuilder::current_thread()
5034                .build()
5035                .expect("build runtime");
5036            runtime.block_on(async move {
5037                let mut s = Session::open(path2.to_string_lossy().as_ref())
5038                    .await
5039                    .expect("open session");
5040                s.append_message(make_test_message("From thread 2"));
5041                s.save().await
5042            })
5043        });
5044
5045        let r1 = t1.join().expect("thread 1 join");
5046        let r2 = t2.join().expect("thread 2 join");
5047        assert!(
5048            r1.is_ok() || r2.is_ok(),
5049            "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
5050        );
5051
5052        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
5053            .expect("open after concurrent saves");
5054        assert!(!loaded.entries.is_empty());
5055    }
5056
5057    #[test]
5058    fn test_to_messages_for_current_path() {
5059        let mut session = Session::in_memory();
5060
5061        // Tree structure:
5062        // A -> B -> C
5063        //       \-> D  (D branches from B)
5064        let _id_a = session.append_message(make_test_message("A"));
5065        let id_b = session.append_message(make_test_message("B"));
5066        let _id_c = session.append_message(make_test_message("C"));
5067
5068        // Navigate to B and add D
5069        session.create_branch_from(&id_b);
5070        let id_d = session.append_message(make_test_message("D"));
5071
5072        // Current path should be A -> B -> D
5073        session.navigate_to(&id_d);
5074        let messages = session.to_messages_for_current_path();
5075        assert_eq!(messages.len(), 3);
5076
5077        // Verify content
5078        if let Message::User(user) = &messages[0] {
5079            if let UserContent::Text(text) = &user.content {
5080                assert_eq!(text, "A");
5081            }
5082        }
5083        if let Message::User(user) = &messages[2] {
5084            if let UserContent::Text(text) = &user.content {
5085                assert_eq!(text, "D");
5086            }
5087        }
5088    }
5089
5090    #[test]
5091    fn test_reset_leaf_produces_empty_current_path() {
5092        let mut session = Session::in_memory();
5093
5094        let _id_a = session.append_message(make_test_message("A"));
5095        let _id_b = session.append_message(make_test_message("B"));
5096
5097        session.reset_leaf();
5098        assert!(session.entries_for_current_path().is_empty());
5099        assert!(session.to_messages_for_current_path().is_empty());
5100
5101        // After reset, the next entry becomes a new root.
5102        let id_root = session.append_message(make_test_message("Root"));
5103        let entry = session.get_entry(&id_root).expect("entry");
5104        assert!(entry.base().parent_id.is_none());
5105    }
5106
5107    #[test]
5108    fn test_encode_cwd() {
5109        let path = std::path::Path::new("/home/user/project");
5110        let encoded = encode_cwd(path);
5111        assert!(encoded.starts_with("--"));
5112        assert!(encoded.ends_with("--"));
5113        assert!(encoded.contains("home-user-project"));
5114    }
5115
5116    // ======================================================================
5117    // Session creation and header validation
5118    // ======================================================================
5119
5120    #[test]
5121    fn test_session_header_defaults() {
5122        let header = SessionHeader::new();
5123        assert_eq!(header.r#type, "session");
5124        assert_eq!(header.version, Some(SESSION_VERSION));
5125        assert!(!header.id.is_empty());
5126        assert!(!header.timestamp.is_empty());
5127        assert!(header.provider.is_none());
5128        assert!(header.model_id.is_none());
5129        assert!(header.thinking_level.is_none());
5130        assert!(header.parent_session.is_none());
5131    }
5132
5133    #[test]
5134    fn test_session_create_produces_unique_ids() {
5135        let s1 = Session::create();
5136        let s2 = Session::create();
5137        assert_ne!(s1.header.id, s2.header.id);
5138    }
5139
5140    #[test]
5141    fn test_in_memory_session_has_no_path() {
5142        let session = Session::in_memory();
5143        assert!(session.path.is_none());
5144        assert!(session.leaf_id.is_none());
5145        assert!(session.entries.is_empty());
5146    }
5147
5148    #[test]
5149    fn test_create_with_dir_stores_session_dir() {
5150        let temp = tempfile::tempdir().unwrap();
5151        let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5152        assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
5153    }
5154
5155    // ======================================================================
5156    // Message types: tool result, bash execution, custom
5157    // ======================================================================
5158
5159    #[test]
5160    fn test_append_tool_result_message() {
5161        let mut session = Session::in_memory();
5162        let user_id = session.append_message(make_test_message("Hello"));
5163
5164        let tool_msg = SessionMessage::ToolResult {
5165            tool_call_id: "call_123".to_string(),
5166            tool_name: "read".to_string(),
5167            content: vec![ContentBlock::Text(TextContent::new("file contents"))],
5168            details: None,
5169            is_error: false,
5170            timestamp: Some(1000),
5171        };
5172        let tool_id = session.append_message(tool_msg);
5173
5174        // Verify parent linking
5175        let entry = session.get_entry(&tool_id).unwrap();
5176        assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
5177
5178        // Verify it converts to model message
5179        let messages = session.to_messages();
5180        assert_eq!(messages.len(), 2);
5181        assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id == "call_123"));
5182    }
5183
5184    #[test]
5185    fn test_append_tool_result_error() {
5186        let mut session = Session::in_memory();
5187        session.append_message(make_test_message("Hello"));
5188
5189        let tool_msg = SessionMessage::ToolResult {
5190            tool_call_id: "call_err".to_string(),
5191            tool_name: "bash".to_string(),
5192            content: vec![ContentBlock::Text(TextContent::new("command not found"))],
5193            details: None,
5194            is_error: true,
5195            timestamp: Some(2000),
5196        };
5197        let tool_id = session.append_message(tool_msg);
5198
5199        let entry = session.get_entry(&tool_id).unwrap();
5200        if let SessionEntry::Message(msg) = entry {
5201            if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
5202                assert!(is_error);
5203            } else {
5204                panic!("expected ToolResult");
5205            }
5206        }
5207    }
5208
5209    #[test]
5210    fn test_append_bash_execution() {
5211        let mut session = Session::in_memory();
5212        session.append_message(make_test_message("run something"));
5213
5214        let bash_id = session.append_bash_execution(
5215            "echo hello".to_string(),
5216            "hello\n".to_string(),
5217            0,
5218            false,
5219            false,
5220            None,
5221        );
5222
5223        let entry = session.get_entry(&bash_id).unwrap();
5224        if let SessionEntry::Message(msg) = entry {
5225            if let SessionMessage::BashExecution {
5226                command, exit_code, ..
5227            } = &msg.message
5228            {
5229                assert_eq!(command, "echo hello");
5230                assert_eq!(*exit_code, 0);
5231            } else {
5232                panic!("expected BashExecution");
5233            }
5234        }
5235
5236        // BashExecution converts to User message for model context
5237        let messages = session.to_messages();
5238        assert_eq!(messages.len(), 2);
5239        assert!(matches!(&messages[1], Message::User(_)));
5240    }
5241
5242    #[test]
5243    fn test_bash_execution_exclude_from_context() {
5244        let mut session = Session::in_memory();
5245        session.append_message(make_test_message("run something"));
5246
5247        let id = session.next_entry_id();
5248        let base = EntryBase::new(session.leaf_id.clone(), id.clone());
5249        let mut extra = HashMap::new();
5250        extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
5251        let entry = SessionEntry::Message(MessageEntry {
5252            base,
5253            message: SessionMessage::BashExecution {
5254                command: "secret".to_string(),
5255                output: "hidden".to_string(),
5256                exit_code: 0,
5257                cancelled: None,
5258                truncated: None,
5259                full_output_path: None,
5260                timestamp: Some(0),
5261                extra,
5262            },
5263        });
5264        session.leaf_id = Some(id);
5265        session.entries.push(entry);
5266        session.entry_ids = entry_id_set(&session.entries);
5267
5268        // The excluded bash execution should not appear in model messages
5269        let messages = session.to_messages();
5270        assert_eq!(messages.len(), 1); // only the user message
5271    }
5272
5273    #[test]
5274    fn test_append_custom_message() {
5275        let mut session = Session::in_memory();
5276        session.append_message(make_test_message("Hello"));
5277
5278        let custom_msg = SessionMessage::Custom {
5279            custom_type: "extension_state".to_string(),
5280            content: "some state".to_string(),
5281            display: false,
5282            details: Some(serde_json::json!({"key": "value"})),
5283            timestamp: Some(0),
5284        };
5285        let custom_id = session.append_message(custom_msg);
5286
5287        let entry = session.get_entry(&custom_id).unwrap();
5288        if let SessionEntry::Message(msg) = entry {
5289            if let SessionMessage::Custom {
5290                custom_type,
5291                display,
5292                ..
5293            } = &msg.message
5294            {
5295                assert_eq!(custom_type, "extension_state");
5296                assert!(!display);
5297            } else {
5298                panic!("expected Custom");
5299            }
5300        }
5301    }
5302
5303    #[test]
5304    fn test_append_custom_entry() {
5305        let mut session = Session::in_memory();
5306        let root_id = session.append_message(make_test_message("Hello"));
5307
5308        let custom_id =
5309            session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
5310
5311        let entry = session.get_entry(&custom_id).unwrap();
5312        if let SessionEntry::Custom(custom) = entry {
5313            assert_eq!(custom.custom_type, "my_type");
5314            assert_eq!(custom.data, Some(serde_json::json!(42)));
5315            assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
5316        } else {
5317            panic!("expected Custom entry");
5318        }
5319    }
5320
5321    // ======================================================================
5322    // Parent linking / tree structure
5323    // ======================================================================
5324
5325    #[test]
5326    fn test_parent_linking_chain() {
5327        let mut session = Session::in_memory();
5328
5329        let id1 = session.append_message(make_test_message("A"));
5330        let id2 = session.append_message(make_test_message("B"));
5331        let id3 = session.append_message(make_test_message("C"));
5332
5333        // First entry has no parent
5334        let e1 = session.get_entry(&id1).unwrap();
5335        assert!(e1.base().parent_id.is_none());
5336
5337        // Second entry's parent is first
5338        let e2 = session.get_entry(&id2).unwrap();
5339        assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
5340
5341        // Third entry's parent is second
5342        let e3 = session.get_entry(&id3).unwrap();
5343        assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
5344    }
5345
5346    #[test]
5347    fn test_model_change_updates_leaf() {
5348        let mut session = Session::in_memory();
5349
5350        let msg_id = session.append_message(make_test_message("Hello"));
5351        let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
5352
5353        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
5354
5355        let entry = session.get_entry(&change_id).unwrap();
5356        assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
5357
5358        if let SessionEntry::ModelChange(mc) = entry {
5359            assert_eq!(mc.provider, "openai");
5360            assert_eq!(mc.model_id, "gpt-4");
5361        } else {
5362            panic!("expected ModelChange");
5363        }
5364    }
5365
5366    #[test]
5367    fn test_thinking_level_change_updates_leaf() {
5368        let mut session = Session::in_memory();
5369        session.append_message(make_test_message("Hello"));
5370
5371        let change_id = session.append_thinking_level_change("high".to_string());
5372        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
5373
5374        let entry = session.get_entry(&change_id).unwrap();
5375        if let SessionEntry::ThinkingLevelChange(tlc) = entry {
5376            assert_eq!(tlc.thinking_level, "high");
5377        } else {
5378            panic!("expected ThinkingLevelChange");
5379        }
5380    }
5381
5382    // ======================================================================
5383    // Session name get/set
5384    // ======================================================================
5385
5386    #[test]
5387    fn test_get_name_returns_latest() {
5388        let mut session = Session::in_memory();
5389
5390        assert!(session.get_name().is_none());
5391
5392        session.set_name("first");
5393        assert_eq!(session.get_name().as_deref(), Some("first"));
5394
5395        session.set_name("second");
5396        assert_eq!(session.get_name().as_deref(), Some("second"));
5397    }
5398
5399    #[test]
5400    fn test_set_name_returns_entry_id() {
5401        let mut session = Session::in_memory();
5402        let id = session.set_name("test-name");
5403        assert!(!id.is_empty());
5404        let entry = session.get_entry(&id).unwrap();
5405        assert!(matches!(entry, SessionEntry::SessionInfo(_)));
5406    }
5407
5408    // ======================================================================
5409    // Label
5410    // ======================================================================
5411
5412    #[test]
5413    fn test_add_label_to_existing_entry() {
5414        let mut session = Session::in_memory();
5415        let msg_id = session.append_message(make_test_message("Hello"));
5416
5417        let label_id = session.add_label(&msg_id, Some("important".to_string()));
5418        assert!(label_id.is_some());
5419
5420        let entry = session.get_entry(&label_id.unwrap()).unwrap();
5421        if let SessionEntry::Label(label) = entry {
5422            assert_eq!(label.target_id, msg_id);
5423            assert_eq!(label.label.as_deref(), Some("important"));
5424        } else {
5425            panic!("expected Label entry");
5426        }
5427    }
5428
5429    #[test]
5430    fn test_add_label_to_nonexistent_entry_returns_none() {
5431        let mut session = Session::in_memory();
5432        let result = session.add_label("nonexistent", Some("label".to_string()));
5433        assert!(result.is_none());
5434    }
5435
5436    // ======================================================================
5437    // JSONL round-trip (save + reload)
5438    // ======================================================================
5439
5440    #[test]
5441    fn test_round_trip_preserves_all_message_types() {
5442        let temp = tempfile::tempdir().unwrap();
5443        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5444
5445        // Append diverse message types
5446        session.append_message(make_test_message("user text"));
5447
5448        let assistant = AssistantMessage {
5449            content: vec![ContentBlock::Text(TextContent::new("response"))],
5450            api: "anthropic".to_string(),
5451            provider: "anthropic".to_string(),
5452            model: "claude-test".to_string(),
5453            usage: Usage::default(),
5454            stop_reason: StopReason::Stop,
5455            error_message: None,
5456            timestamp: 0,
5457        };
5458        session.append_message(SessionMessage::Assistant { message: assistant });
5459
5460        session.append_message(SessionMessage::ToolResult {
5461            tool_call_id: "call_1".to_string(),
5462            tool_name: "read".to_string(),
5463            content: vec![ContentBlock::Text(TextContent::new("result"))],
5464            details: None,
5465            is_error: false,
5466            timestamp: Some(100),
5467        });
5468
5469        session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
5470
5471        session.append_custom_entry(
5472            "ext_data".to_string(),
5473            Some(serde_json::json!({"foo": "bar"})),
5474        );
5475
5476        run_async(async { session.save().await }).unwrap();
5477        let path = session.path.clone().unwrap();
5478
5479        let loaded =
5480            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5481
5482        assert_eq!(loaded.entries.len(), session.entries.len());
5483        assert_eq!(loaded.header.id, session.header.id);
5484        assert_eq!(loaded.header.version, Some(SESSION_VERSION));
5485
5486        // Verify specific entry types survived the round-trip
5487        let has_tool_result = loaded.entries.iter().any(|e| {
5488            matches!(
5489                e,
5490                SessionEntry::Message(m) if matches!(
5491                    &m.message,
5492                    SessionMessage::ToolResult { tool_name, .. } if tool_name == "read"
5493                )
5494            )
5495        });
5496        assert!(has_tool_result, "tool result should survive round-trip");
5497
5498        let has_bash = loaded.entries.iter().any(|e| {
5499            matches!(
5500                e,
5501                SessionEntry::Message(m) if matches!(
5502                    &m.message,
5503                    SessionMessage::BashExecution { command, .. } if command == "ls"
5504                )
5505            )
5506        });
5507        assert!(has_bash, "bash execution should survive round-trip");
5508
5509        let has_custom = loaded.entries.iter().any(|e| {
5510            matches!(
5511                e,
5512                SessionEntry::Custom(c) if c.custom_type == "ext_data"
5513            )
5514        });
5515        assert!(has_custom, "custom entry should survive round-trip");
5516    }
5517
5518    #[test]
5519    fn test_round_trip_preserves_leaf_id() {
5520        let temp = tempfile::tempdir().unwrap();
5521        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5522
5523        let _id1 = session.append_message(make_test_message("A"));
5524        let id2 = session.append_message(make_test_message("B"));
5525
5526        run_async(async { session.save().await }).unwrap();
5527        let path = session.path.clone().unwrap();
5528
5529        let loaded =
5530            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5531
5532        assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
5533    }
5534
5535    #[test]
5536    fn test_round_trip_preserves_header_fields() {
5537        let temp = tempfile::tempdir().unwrap();
5538        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5539        session.header.provider = Some("anthropic".to_string());
5540        session.header.model_id = Some("claude-opus".to_string());
5541        session.header.thinking_level = Some("high".to_string());
5542        session.header.parent_session = Some("/old/session.jsonl".to_string());
5543
5544        session.append_message(make_test_message("Hello"));
5545        run_async(async { session.save().await }).unwrap();
5546        let path = session.path.clone().unwrap();
5547
5548        let loaded =
5549            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5550
5551        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
5552        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
5553        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
5554        assert_eq!(
5555            loaded.header.parent_session.as_deref(),
5556            Some("/old/session.jsonl")
5557        );
5558    }
5559
5560    #[test]
5561    fn test_empty_session_save_and_reload() {
5562        let temp = tempfile::tempdir().unwrap();
5563        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5564
5565        run_async(async { session.save().await }).unwrap();
5566        let path = session.path.clone().unwrap();
5567
5568        let loaded =
5569            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5570
5571        assert!(loaded.entries.is_empty());
5572        assert!(loaded.leaf_id.is_none());
5573        assert_eq!(loaded.header.id, session.header.id);
5574    }
5575
5576    // ======================================================================
5577    // Corrupted JSONL recovery
5578    // ======================================================================
5579
5580    #[test]
5581    fn test_corrupted_middle_entry_preserves_surrounding_entries() {
5582        let temp = tempfile::tempdir().unwrap();
5583        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5584
5585        let id1 = session.append_message(make_test_message("First"));
5586        let id2 = session.append_message(make_test_message("Second"));
5587        let id3 = session.append_message(make_test_message("Third"));
5588
5589        run_async(async { session.save().await }).unwrap();
5590        let path = session.path.clone().unwrap();
5591
5592        // Corrupt the middle entry (line 3, 1-indexed: header=1, first=2, second=3)
5593        let mut lines: Vec<String> = std::fs::read_to_string(&path)
5594            .unwrap()
5595            .lines()
5596            .map(str::to_string)
5597            .collect();
5598        assert!(lines.len() >= 4);
5599        lines[2] = "GARBAGE JSON".to_string();
5600        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
5601
5602        let (loaded, diagnostics) = run_async(async {
5603            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5604        })
5605        .unwrap();
5606
5607        let diag = serde_json::json!({
5608            "fixture_id": "session-corrupted-middle-entry-replay-integrity",
5609            "path": path.display().to_string(),
5610            "seed": "deterministic-static",
5611            "env": {
5612                "os": std::env::consts::OS,
5613                "arch": std::env::consts::ARCH,
5614            },
5615            "expected": {
5616                "skipped_entries": 1,
5617                "orphaned_parent_links": 1,
5618            },
5619            "actual": {
5620                "skipped_entries": diagnostics.skipped_entries.len(),
5621                "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
5622                "leaf_id": loaded.leaf_id,
5623            },
5624        })
5625        .to_string();
5626
5627        assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
5628        assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
5629        assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
5630        assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
5631        assert_eq!(
5632            diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
5633            "{diag}"
5634        );
5635        assert!(
5636            diagnostics.warning_lines().iter().any(|line| {
5637                line.contains("references missing parent")
5638                    && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
5639            }),
5640            "{diag}"
5641        );
5642
5643        // First and third entries should survive
5644        assert_eq!(loaded.entries.len(), 2, "{diag}");
5645        assert!(loaded.get_entry(&id1).is_some(), "{diag}");
5646        assert!(loaded.get_entry(&id3).is_some(), "{diag}");
5647    }
5648
5649    #[test]
5650    fn test_multiple_corrupted_entries_recovery() {
5651        let temp = tempfile::tempdir().unwrap();
5652        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5653
5654        session.append_message(make_test_message("A"));
5655        session.append_message(make_test_message("B"));
5656        session.append_message(make_test_message("C"));
5657        session.append_message(make_test_message("D"));
5658
5659        run_async(async { session.save().await }).unwrap();
5660        let path = session.path.clone().unwrap();
5661
5662        let mut lines: Vec<String> = std::fs::read_to_string(&path)
5663            .unwrap()
5664            .lines()
5665            .map(str::to_string)
5666            .collect();
5667        // Corrupt entries B (line 3) and D (line 5)
5668        lines[2] = "BAD".to_string();
5669        lines[4] = "ALSO BAD".to_string();
5670        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
5671
5672        let (loaded, diagnostics) = run_async(async {
5673            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5674        })
5675        .unwrap();
5676
5677        assert_eq!(diagnostics.skipped_entries.len(), 2);
5678        assert_eq!(loaded.entries.len(), 2); // A and C survive
5679    }
5680
5681    #[test]
5682    fn test_corrupted_header_fails_to_open() {
5683        let temp = tempfile::tempdir().unwrap();
5684        let path = temp.path().join("bad_header.jsonl");
5685        std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
5686
5687        let result = run_async(async {
5688            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5689        });
5690        assert!(
5691            result.is_err(),
5692            "corrupted header should cause open failure"
5693        );
5694    }
5695
5696    // ======================================================================
5697    // Branching and navigation
5698    // ======================================================================
5699
5700    #[test]
5701    fn test_create_branch_from_nonexistent_returns_false() {
5702        let mut session = Session::in_memory();
5703        session.append_message(make_test_message("A"));
5704        assert!(!session.create_branch_from("nonexistent"));
5705    }
5706
5707    #[test]
5708    fn test_deep_branching() {
5709        let mut session = Session::in_memory();
5710
5711        // Create A -> B -> C
5712        let id_a = session.append_message(make_test_message("A"));
5713        let id_b = session.append_message(make_test_message("B"));
5714        let _id_c = session.append_message(make_test_message("C"));
5715
5716        // Branch from A: A -> D
5717        session.create_branch_from(&id_a);
5718        let _id_d = session.append_message(make_test_message("D"));
5719
5720        // Branch from B: A -> B -> E
5721        session.create_branch_from(&id_b);
5722        let id_e = session.append_message(make_test_message("E"));
5723
5724        // Should have 3 leaves: C, D, E
5725        let leaves = session.list_leaves();
5726        assert_eq!(leaves.len(), 3);
5727
5728        // Path to E is A -> B -> E
5729        let path = session.get_path_to_entry(&id_e);
5730        assert_eq!(path.len(), 3);
5731        assert_eq!(path[0], id_a);
5732        assert_eq!(path[1], id_b);
5733        assert_eq!(path[2], id_e);
5734    }
5735
5736    #[test]
5737    fn test_sibling_branches_at_fork() {
5738        let mut session = Session::in_memory();
5739
5740        // Create A -> B -> C
5741        let id_a = session.append_message(make_test_message("A"));
5742        let _id_b = session.append_message(make_test_message("B"));
5743        let _id_c = session.append_message(make_test_message("C"));
5744
5745        // Branch from A: A -> D
5746        session.create_branch_from(&id_a);
5747        let id_d = session.append_message(make_test_message("D"));
5748
5749        // Navigate to D to make it current
5750        session.navigate_to(&id_d);
5751
5752        let siblings = session.sibling_branches();
5753        assert!(siblings.is_some());
5754        let (fork_point, branches) = siblings.unwrap();
5755        assert!(fork_point.is_none() || fork_point.as_deref() == Some(id_a.as_str()));
5756        assert_eq!(branches.len(), 2);
5757
5758        // One should be current, one not
5759        let current_count = branches.iter().filter(|b| b.is_current).count();
5760        assert_eq!(current_count, 1);
5761    }
5762
5763    #[test]
5764    fn test_sibling_branches_no_fork() {
5765        let mut session = Session::in_memory();
5766        session.append_message(make_test_message("A"));
5767        session.append_message(make_test_message("B"));
5768
5769        // No fork points, so sibling_branches returns None
5770        assert!(session.sibling_branches().is_none());
5771    }
5772
5773    // ======================================================================
5774    // Plan fork
5775    // ======================================================================
5776
5777    #[test]
5778    fn test_plan_fork_from_user_message() {
5779        let mut session = Session::in_memory();
5780
5781        let _id_a = session.append_message(make_test_message("First question"));
5782        let assistant = AssistantMessage {
5783            content: vec![ContentBlock::Text(TextContent::new("Answer"))],
5784            api: "anthropic".to_string(),
5785            provider: "anthropic".to_string(),
5786            model: "test".to_string(),
5787            usage: Usage::default(),
5788            stop_reason: StopReason::Stop,
5789            error_message: None,
5790            timestamp: 0,
5791        };
5792        let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
5793        let id_c = session.append_message(make_test_message("Second question"));
5794
5795        // Fork from the second user message
5796        let plan = session.plan_fork_from_user_message(&id_c).unwrap();
5797        assert_eq!(plan.selected_text, "Second question");
5798        // Entries should be the path up to (but not including) the forked message
5799        assert_eq!(plan.entries.len(), 2); // A and B
5800    }
5801
5802    #[test]
5803    fn test_plan_fork_from_root_message() {
5804        let mut session = Session::in_memory();
5805        let id_a = session.append_message(make_test_message("Root question"));
5806
5807        let plan = session.plan_fork_from_user_message(&id_a).unwrap();
5808        assert_eq!(plan.selected_text, "Root question");
5809        assert!(plan.entries.is_empty()); // No entries before root
5810        assert!(plan.leaf_id.is_none());
5811    }
5812
5813    #[test]
5814    fn test_plan_fork_from_nonexistent_fails() {
5815        let session = Session::in_memory();
5816        assert!(session.plan_fork_from_user_message("nonexistent").is_err());
5817    }
5818
5819    #[test]
5820    fn test_plan_fork_from_assistant_message_fails() {
5821        let mut session = Session::in_memory();
5822        session.append_message(make_test_message("Q"));
5823        let assistant = AssistantMessage {
5824            content: vec![ContentBlock::Text(TextContent::new("A"))],
5825            api: "anthropic".to_string(),
5826            provider: "anthropic".to_string(),
5827            model: "test".to_string(),
5828            usage: Usage::default(),
5829            stop_reason: StopReason::Stop,
5830            error_message: None,
5831            timestamp: 0,
5832        };
5833        let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
5834
5835        assert!(session.plan_fork_from_user_message(&asst_id).is_err());
5836    }
5837
5838    // ======================================================================
5839    // Compaction in message context
5840    // ======================================================================
5841
5842    #[test]
5843    fn test_compaction_truncates_model_context() {
5844        let mut session = Session::in_memory();
5845
5846        let _id_a = session.append_message(make_test_message("old message A"));
5847        let _id_b = session.append_message(make_test_message("old message B"));
5848        let id_c = session.append_message(make_test_message("kept message C"));
5849
5850        // Compact: keep from id_c onwards
5851        session.append_compaction(
5852            "Summary of old messages".to_string(),
5853            id_c,
5854            5000,
5855            None,
5856            None,
5857        );
5858
5859        let id_d = session.append_message(make_test_message("new message D"));
5860
5861        // Ensure we're at the right leaf
5862        session.navigate_to(&id_d);
5863
5864        let messages = session.to_messages_for_current_path();
5865        // Should have: compaction summary + kept message C + new message D
5866        // (old messages A and B should be omitted)
5867        assert!(messages.len() <= 4); // compaction summary + C + compaction entry + D
5868
5869        // Verify old messages are not in context
5870        let all_text: String = messages
5871            .iter()
5872            .filter_map(|m| match m {
5873                Message::User(u) => match &u.content {
5874                    UserContent::Text(t) => Some(t.clone()),
5875                    UserContent::Blocks(blocks) => {
5876                        let texts: Vec<String> = blocks
5877                            .iter()
5878                            .filter_map(|b| {
5879                                if let ContentBlock::Text(t) = b {
5880                                    Some(t.text.clone())
5881                                } else {
5882                                    None
5883                                }
5884                            })
5885                            .collect();
5886                        Some(texts.join(" "))
5887                    }
5888                },
5889                _ => None,
5890            })
5891            .collect::<Vec<_>>()
5892            .join(" ");
5893
5894        assert!(
5895            !all_text.contains("old message A"),
5896            "compacted message A should not appear in context"
5897        );
5898        assert!(
5899            !all_text.contains("old message B"),
5900            "compacted message B should not appear in context"
5901        );
5902        assert!(
5903            all_text.contains("kept message C") || all_text.contains("new message D"),
5904            "kept messages should appear in context"
5905        );
5906    }
5907
5908    // ======================================================================
5909    // Large session handling
5910    // ======================================================================
5911
5912    #[test]
5913    fn test_large_session_append_and_path() {
5914        let mut session = Session::in_memory();
5915
5916        let mut last_id = String::new();
5917        for i in 0..500 {
5918            last_id = session.append_message(make_test_message(&format!("msg-{i}")));
5919        }
5920
5921        assert_eq!(session.entries.len(), 500);
5922        assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
5923
5924        // Path from root to leaf should include all 500 entries
5925        let path = session.get_path_to_entry(&last_id);
5926        assert_eq!(path.len(), 500);
5927
5928        // Entries for current path should also be 500
5929        let current = session.entries_for_current_path();
5930        assert_eq!(current.len(), 500);
5931    }
5932
5933    #[test]
5934    fn test_large_session_save_and_reload() {
5935        let temp = tempfile::tempdir().unwrap();
5936        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5937
5938        for i in 0..200 {
5939            session.append_message(make_test_message(&format!("message {i}")));
5940        }
5941
5942        run_async(async { session.save().await }).unwrap();
5943        let path = session.path.clone().unwrap();
5944
5945        let loaded =
5946            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5947
5948        assert_eq!(loaded.entries.len(), 200);
5949        assert_eq!(loaded.header.id, session.header.id);
5950    }
5951
5952    // ======================================================================
5953    // Entry ID generation
5954    // ======================================================================
5955
5956    #[test]
5957    fn test_ensure_entry_ids_fills_missing() {
5958        let mut entries = vec![
5959            SessionEntry::Message(MessageEntry {
5960                base: EntryBase {
5961                    id: None,
5962                    parent_id: None,
5963                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
5964                },
5965                message: SessionMessage::User {
5966                    content: UserContent::Text("test".to_string()),
5967                    timestamp: Some(0),
5968                },
5969            }),
5970            SessionEntry::Message(MessageEntry {
5971                base: EntryBase {
5972                    id: Some("existing".to_string()),
5973                    parent_id: None,
5974                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
5975                },
5976                message: SessionMessage::User {
5977                    content: UserContent::Text("test2".to_string()),
5978                    timestamp: Some(0),
5979                },
5980            }),
5981        ];
5982
5983        ensure_entry_ids(&mut entries);
5984
5985        // First entry should now have an ID
5986        assert!(entries[0].base().id.is_some());
5987        // Second entry should keep its existing ID
5988        assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
5989        // IDs should be unique
5990        assert_ne!(entries[0].base().id, entries[1].base().id);
5991    }
5992
5993    #[test]
5994    fn test_generate_entry_id_produces_8_char_hex() {
5995        let existing = HashSet::new();
5996        let id = generate_entry_id(&existing);
5997        assert_eq!(id.len(), 8);
5998        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
5999    }
6000
6001    // ======================================================================
6002    // set_model_header / set_branched_from
6003    // ======================================================================
6004
6005    #[test]
6006    fn test_set_model_header() {
6007        let mut session = Session::in_memory();
6008        session.set_model_header(
6009            Some("anthropic".to_string()),
6010            Some("claude-opus".to_string()),
6011            Some("high".to_string()),
6012        );
6013        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6014        assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
6015        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6016    }
6017
6018    #[test]
6019    fn test_set_branched_from() {
6020        let mut session = Session::in_memory();
6021        assert!(session.header.parent_session.is_none());
6022
6023        session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
6024        assert_eq!(
6025            session.header.parent_session.as_deref(),
6026            Some("/path/to/parent.jsonl")
6027        );
6028    }
6029
6030    // ======================================================================
6031    // to_html rendering
6032    // ======================================================================
6033
6034    #[test]
6035    fn test_to_html_contains_all_message_types() {
6036        let mut session = Session::in_memory();
6037
6038        session.append_message(make_test_message("user question"));
6039
6040        let assistant = AssistantMessage {
6041            content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
6042            api: "anthropic".to_string(),
6043            provider: "anthropic".to_string(),
6044            model: "test".to_string(),
6045            usage: Usage::default(),
6046            stop_reason: StopReason::Stop,
6047            error_message: None,
6048            timestamp: 0,
6049        };
6050        session.append_message(SessionMessage::Assistant { message: assistant });
6051        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
6052        session.set_name("test-session-html");
6053
6054        let html = session.to_html();
6055        assert!(html.contains("<!doctype html>"));
6056        assert!(html.contains("user question"));
6057        assert!(html.contains("assistant answer"));
6058        assert!(html.contains("anthropic"));
6059        assert!(html.contains("test-session-html"));
6060    }
6061
6062    // ======================================================================
6063    // to_messages conversion
6064    // ======================================================================
6065
6066    #[test]
6067    fn test_to_messages_includes_all_message_entries() {
6068        let mut session = Session::in_memory();
6069
6070        session.append_message(make_test_message("Q1"));
6071        let assistant = AssistantMessage {
6072            content: vec![ContentBlock::Text(TextContent::new("A1"))],
6073            api: "anthropic".to_string(),
6074            provider: "anthropic".to_string(),
6075            model: "test".to_string(),
6076            usage: Usage::default(),
6077            stop_reason: StopReason::Stop,
6078            error_message: None,
6079            timestamp: 0,
6080        };
6081        session.append_message(SessionMessage::Assistant { message: assistant });
6082        session.append_message(SessionMessage::ToolResult {
6083            tool_call_id: "c1".to_string(),
6084            tool_name: "edit".to_string(),
6085            content: vec![ContentBlock::Text(TextContent::new("edited"))],
6086            details: None,
6087            is_error: false,
6088            timestamp: Some(0),
6089        });
6090
6091        // Non-message entries should NOT appear in to_messages()
6092        session.append_model_change("openai".to_string(), "gpt-4".to_string());
6093        session.append_session_info(Some("name".to_string()));
6094
6095        let messages = session.to_messages();
6096        assert_eq!(messages.len(), 3); // user + assistant + tool_result
6097    }
6098
6099    // ======================================================================
6100    // JSONL format validation
6101    // ======================================================================
6102
6103    #[test]
6104    fn test_jsonl_header_is_first_line() {
6105        let temp = tempfile::tempdir().unwrap();
6106        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6107        session.append_message(make_test_message("test"));
6108
6109        run_async(async { session.save().await }).unwrap();
6110        let path = session.path.clone().unwrap();
6111
6112        let contents = std::fs::read_to_string(path).unwrap();
6113        let first_line = contents.lines().next().unwrap();
6114        let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
6115
6116        assert_eq!(header["type"], "session");
6117        assert_eq!(header["version"], SESSION_VERSION);
6118        assert!(!header["id"].as_str().unwrap().is_empty());
6119        assert!(!header["timestamp"].as_str().unwrap().is_empty());
6120    }
6121
6122    #[test]
6123    fn test_jsonl_entries_have_camelcase_fields() {
6124        let temp = tempfile::tempdir().unwrap();
6125        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6126
6127        session.append_message(make_test_message("test"));
6128        session.append_model_change("provider".to_string(), "model".to_string());
6129
6130        run_async(async { session.save().await }).unwrap();
6131        let path = session.path.clone().unwrap();
6132
6133        let contents = std::fs::read_to_string(path).unwrap();
6134        let lines: Vec<&str> = contents.lines().collect();
6135
6136        // Check message entry (line 2)
6137        let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
6138        assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
6139
6140        // Check model change entry (line 3)
6141        let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
6142        assert!(mc_value.get("modelId").is_some());
6143    }
6144
6145    // ======================================================================
6146    // Session open errors
6147    // ======================================================================
6148
6149    #[test]
6150    fn test_open_nonexistent_file_returns_error() {
6151        let result =
6152            run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
6153        assert!(result.is_err());
6154    }
6155
6156    #[test]
6157    fn test_open_empty_file_returns_error() {
6158        let temp = tempfile::tempdir().unwrap();
6159        let path = temp.path().join("empty.jsonl");
6160        std::fs::write(&path, "").unwrap();
6161
6162        let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
6163        assert!(result.is_err());
6164    }
6165
6166    // ======================================================================
6167    // get_entry / get_entry_mut
6168    // ======================================================================
6169
6170    #[test]
6171    fn test_get_entry_returns_correct_entry() {
6172        let mut session = Session::in_memory();
6173        let id = session.append_message(make_test_message("Hello"));
6174
6175        let entry = session.get_entry(&id);
6176        assert!(entry.is_some());
6177        assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
6178    }
6179
6180    #[test]
6181    fn test_get_entry_mut_allows_modification() {
6182        let mut session = Session::in_memory();
6183        let id = session.append_message(make_test_message("Original"));
6184
6185        let entry = session.get_entry_mut(&id).unwrap();
6186        if let SessionEntry::Message(msg) = entry {
6187            msg.message = SessionMessage::User {
6188                content: UserContent::Text("Modified".to_string()),
6189                timestamp: Some(0),
6190            };
6191        }
6192
6193        // Verify modification persisted
6194        let entry = session.get_entry(&id).unwrap();
6195        if let SessionEntry::Message(msg) = entry {
6196            if let SessionMessage::User { content, .. } = &msg.message {
6197                match content {
6198                    UserContent::Text(t) => assert_eq!(t, "Modified"),
6199                    UserContent::Blocks(_) => panic!("expected Text content"),
6200                }
6201            } else {
6202                panic!("expected user message");
6203            }
6204        }
6205    }
6206
6207    #[test]
6208    fn test_get_entry_nonexistent_returns_none() {
6209        let session = Session::in_memory();
6210        assert!(session.get_entry("nonexistent").is_none());
6211    }
6212
6213    // ======================================================================
6214    // Branching round-trip (save with branches, reload, verify)
6215    // ======================================================================
6216
6217    #[test]
6218    fn test_branching_round_trip_preserves_tree_structure() {
6219        let temp = tempfile::tempdir().unwrap();
6220        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6221
6222        // Create: A -> B -> C, then branch from A: A -> D
6223        let id_a = session.append_message(make_test_message("A"));
6224        let id_b = session.append_message(make_test_message("B"));
6225        let id_c = session.append_message(make_test_message("C"));
6226
6227        session.create_branch_from(&id_a);
6228        let id_d = session.append_message(make_test_message("D"));
6229
6230        // Verify pre-save state
6231        let leaves = session.list_leaves();
6232        assert_eq!(leaves.len(), 2);
6233
6234        run_async(async { session.save().await }).unwrap();
6235        let path = session.path.clone().unwrap();
6236
6237        let loaded =
6238            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6239
6240        // Verify tree structure survived round-trip
6241        assert_eq!(loaded.entries.len(), 4);
6242        let loaded_leaves = loaded.list_leaves();
6243        assert_eq!(loaded_leaves.len(), 2);
6244        assert!(loaded_leaves.contains(&id_c));
6245        assert!(loaded_leaves.contains(&id_d));
6246
6247        // Verify parent linking
6248        let path_to_c = loaded.get_path_to_entry(&id_c);
6249        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6250
6251        let path_to_d = loaded.get_path_to_entry(&id_d);
6252        assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
6253    }
6254
6255    // ======================================================================
6256    // Session directory resolution from CWD
6257    // ======================================================================
6258
6259    #[test]
6260    fn test_encode_cwd_strips_leading_separators() {
6261        let path = std::path::Path::new("/home/user/my-project");
6262        let encoded = encode_cwd(path);
6263        assert_eq!(encoded, "--home-user-my-project--");
6264        assert!(!encoded.contains('/'));
6265    }
6266
6267    #[test]
6268    fn test_encode_cwd_handles_deeply_nested_path() {
6269        let path = std::path::Path::new("/a/b/c/d/e/f");
6270        let encoded = encode_cwd(path);
6271        assert_eq!(encoded, "--a-b-c-d-e-f--");
6272    }
6273
6274    #[test]
6275    fn test_save_creates_project_session_dir_from_cwd() {
6276        let temp = tempfile::tempdir().unwrap();
6277        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6278        session.append_message(make_test_message("test"));
6279
6280        run_async(async { session.save().await }).unwrap();
6281        let path = session.path.clone().unwrap();
6282
6283        // The saved path should be inside a CWD-encoded subdirectory
6284        let parent = path.parent().unwrap();
6285        let dir_name = parent.file_name().unwrap().to_string_lossy();
6286        assert!(
6287            dir_name.starts_with("--"),
6288            "session dir should start with --"
6289        );
6290        assert!(dir_name.ends_with("--"), "session dir should end with --");
6291
6292        // The file should have .jsonl extension
6293        assert_eq!(path.extension().unwrap(), "jsonl");
6294    }
6295
6296    #[test]
6297    fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
6298        let known_entry = SessionPickEntry {
6299            path: PathBuf::from("session.jsonl"),
6300            id: "session-id".to_string(),
6301            timestamp: "2026-01-01T00:00:00.000Z".to_string(),
6302            message_count: 4,
6303            name: Some("cached".to_string()),
6304            last_modified_ms: 1234,
6305            size_bytes: 4096,
6306        };
6307
6308        assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
6309        assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
6310        assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
6311    }
6312
6313    #[test]
6314    fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
6315        let temp = tempfile::tempdir().unwrap();
6316        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6317        session.append_message(make_test_message("first"));
6318        session.append_message(make_test_message("second"));
6319
6320        run_async(async { session.save().await }).unwrap();
6321        let path = session.path.clone().expect("session path");
6322        let metadata = std::fs::metadata(&path).expect("session metadata");
6323        let disk_size = metadata.len();
6324        #[allow(clippy::cast_possible_truncation)]
6325        let disk_ms = metadata
6326            .modified()
6327            .unwrap_or(SystemTime::UNIX_EPOCH)
6328            .duration_since(UNIX_EPOCH)
6329            .unwrap_or_default()
6330            .as_millis() as i64;
6331
6332        let stale_known_entry = SessionPickEntry {
6333            path: path.clone(),
6334            id: session.header.id.clone(),
6335            timestamp: session.header.timestamp.clone(),
6336            message_count: 999,
6337            name: Some("stale".to_string()),
6338            last_modified_ms: disk_ms,
6339            size_bytes: disk_size.saturating_add(1),
6340        };
6341
6342        let session_dir = path.parent().expect("session parent").to_path_buf();
6343        let scanned =
6344            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
6345                .expect("scan sessions");
6346        assert_eq!(scanned.len(), 1);
6347        assert_eq!(scanned[0].path, path);
6348        assert_eq!(scanned[0].message_count, 2);
6349        assert_eq!(scanned[0].size_bytes, disk_size);
6350    }
6351
6352    // ======================================================================
6353    // All entries corrupted (only header valid)
6354    // ======================================================================
6355
6356    #[test]
6357    fn test_all_entries_corrupted_produces_empty_session() {
6358        let temp = tempfile::tempdir().unwrap();
6359        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6360        session.append_message(make_test_message("A"));
6361        session.append_message(make_test_message("B"));
6362
6363        run_async(async { session.save().await }).unwrap();
6364        let path = session.path.clone().unwrap();
6365
6366        let mut lines: Vec<String> = std::fs::read_to_string(&path)
6367            .unwrap()
6368            .lines()
6369            .map(str::to_string)
6370            .collect();
6371        // Corrupt all entry lines (keep header at index 0)
6372        for (i, line) in lines.iter_mut().enumerate().skip(1) {
6373            *line = format!("GARBAGE_{i}");
6374        }
6375        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
6376
6377        let (loaded, diagnostics) = run_async(async {
6378            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
6379        })
6380        .unwrap();
6381
6382        assert_eq!(diagnostics.skipped_entries.len(), 2);
6383        assert!(loaded.entries.is_empty());
6384        assert!(loaded.leaf_id.is_none());
6385        // Header should still be valid
6386        assert_eq!(loaded.header.id, session.header.id);
6387    }
6388
6389    // ======================================================================
6390    // Unicode and special character content
6391    // ======================================================================
6392
6393    #[test]
6394    fn test_unicode_content_round_trip() {
6395        let temp = tempfile::tempdir().unwrap();
6396        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6397
6398        let unicode_texts = [
6399            "Hello \u{1F600} World",    // emoji
6400            "\u{4F60}\u{597D}",         // Chinese
6401            "\u{0410}\u{0411}\u{0412}", // Cyrillic
6402            "caf\u{00E9}",              // accented
6403            "tab\there\nnewline",       // control chars
6404            "\"quoted\" and \\escaped", // JSON special chars
6405        ];
6406
6407        for text in &unicode_texts {
6408            session.append_message(make_test_message(text));
6409        }
6410
6411        run_async(async { session.save().await }).unwrap();
6412        let path = session.path.clone().unwrap();
6413
6414        let loaded =
6415            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6416
6417        assert_eq!(loaded.entries.len(), unicode_texts.len());
6418
6419        for (i, entry) in loaded.entries.iter().enumerate() {
6420            if let SessionEntry::Message(msg) = entry {
6421                if let SessionMessage::User { content, .. } = &msg.message {
6422                    match content {
6423                        UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
6424                        UserContent::Blocks(_) => panic!("expected Text content at index {i}"),
6425                    }
6426                }
6427            }
6428        }
6429    }
6430
6431    // ======================================================================
6432    // Multiple compactions
6433    // ======================================================================
6434
6435    #[test]
6436    fn test_multiple_compactions_latest_wins() {
6437        let mut session = Session::in_memory();
6438
6439        let _id_a = session.append_message(make_test_message("old A"));
6440        let _id_b = session.append_message(make_test_message("old B"));
6441        let id_c = session.append_message(make_test_message("kept C"));
6442
6443        // First compaction: keep from C
6444        session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
6445
6446        let _id_d = session.append_message(make_test_message("new D"));
6447        let id_e = session.append_message(make_test_message("new E"));
6448
6449        // Second compaction: keep from E
6450        session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
6451
6452        let id_f = session.append_message(make_test_message("newest F"));
6453
6454        session.navigate_to(&id_f);
6455        let messages = session.to_messages_for_current_path();
6456
6457        // Old messages A, B should definitely not appear
6458        let all_text: String = messages
6459            .iter()
6460            .filter_map(|m| match m {
6461                Message::User(u) => match &u.content {
6462                    UserContent::Text(t) => Some(t.clone()),
6463                    UserContent::Blocks(_) => None,
6464                },
6465                _ => None,
6466            })
6467            .collect::<Vec<_>>()
6468            .join(" ");
6469
6470        assert!(!all_text.contains("old A"), "A should be compacted away");
6471        assert!(!all_text.contains("old B"), "B should be compacted away");
6472    }
6473
6474    // ======================================================================
6475    // Session with only metadata entries (no messages)
6476    // ======================================================================
6477
6478    #[test]
6479    fn test_session_with_only_metadata_entries() {
6480        let mut session = Session::in_memory();
6481
6482        session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
6483        session.append_thinking_level_change("high".to_string());
6484        session.set_name("metadata-only");
6485
6486        // to_messages should return empty (no actual messages)
6487        let messages = session.to_messages();
6488        assert!(messages.is_empty());
6489
6490        // entries_for_current_path should still return the metadata entries
6491        let entries = session.entries_for_current_path();
6492        assert_eq!(entries.len(), 3);
6493    }
6494
6495    #[test]
6496    fn test_metadata_only_session_round_trip() {
6497        let temp = tempfile::tempdir().unwrap();
6498        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6499
6500        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6501        session.append_thinking_level_change("medium".to_string());
6502
6503        run_async(async { session.save().await }).unwrap();
6504        let path = session.path.clone().unwrap();
6505
6506        let loaded =
6507            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6508
6509        assert_eq!(loaded.entries.len(), 2);
6510        assert!(
6511            loaded
6512                .entries
6513                .iter()
6514                .any(|e| matches!(e, SessionEntry::ModelChange(_)))
6515        );
6516        assert!(
6517            loaded
6518                .entries
6519                .iter()
6520                .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
6521        );
6522    }
6523
6524    // ======================================================================
6525    // Session name round-trip persistence
6526    // ======================================================================
6527
6528    #[test]
6529    fn test_session_name_survives_round_trip() {
6530        let temp = tempfile::tempdir().unwrap();
6531        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6532
6533        session.append_message(make_test_message("Hello"));
6534        session.set_name("my-important-session");
6535
6536        run_async(async { session.save().await }).unwrap();
6537        let path = session.path.clone().unwrap();
6538
6539        let loaded =
6540            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6541
6542        assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
6543    }
6544
6545    // ======================================================================
6546    // Trailing newline / whitespace in JSONL
6547    // ======================================================================
6548
6549    #[test]
6550    fn test_trailing_whitespace_in_jsonl_ignored() {
6551        let temp = tempfile::tempdir().unwrap();
6552        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6553        session.append_message(make_test_message("test"));
6554
6555        run_async(async { session.save().await }).unwrap();
6556        let path = session.path.clone().unwrap();
6557
6558        // Append extra blank lines at the end
6559        let mut contents = std::fs::read_to_string(&path).unwrap();
6560        contents.push_str("\n\n\n");
6561        std::fs::write(&path, contents).unwrap();
6562
6563        let loaded =
6564            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6565
6566        assert_eq!(loaded.entries.len(), 1);
6567    }
6568
6569    // ======================================================================
6570    // Branching after compaction
6571    // ======================================================================
6572
6573    #[test]
6574    fn test_branching_after_compaction() {
6575        let mut session = Session::in_memory();
6576
6577        let _id_a = session.append_message(make_test_message("old A"));
6578        let id_b = session.append_message(make_test_message("kept B"));
6579
6580        session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
6581
6582        let id_c = session.append_message(make_test_message("C after compaction"));
6583
6584        // Branch from B (the compaction keep-point)
6585        session.create_branch_from(&id_b);
6586        let id_d = session.append_message(make_test_message("D branch after compaction"));
6587
6588        let leaves = session.list_leaves();
6589        assert_eq!(leaves.len(), 2);
6590        assert!(leaves.contains(&id_c));
6591        assert!(leaves.contains(&id_d));
6592    }
6593
6594    // ======================================================================
6595    // Assistant message with tool calls round-trip
6596    // ======================================================================
6597
6598    #[test]
6599    fn test_assistant_with_tool_calls_round_trip() {
6600        let temp = tempfile::tempdir().unwrap();
6601        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6602
6603        session.append_message(make_test_message("read my file"));
6604
6605        let assistant = AssistantMessage {
6606            content: vec![
6607                ContentBlock::Text(TextContent::new("Let me read that for you.")),
6608                ContentBlock::ToolCall(crate::model::ToolCall {
6609                    id: "call_abc".to_string(),
6610                    name: "read".to_string(),
6611                    arguments: serde_json::json!({"path": "src/main.rs"}),
6612                    thought_signature: None,
6613                }),
6614            ],
6615            api: "anthropic".to_string(),
6616            provider: "anthropic".to_string(),
6617            model: "claude-test".to_string(),
6618            usage: Usage {
6619                input: 100,
6620                output: 50,
6621                cache_read: 0,
6622                cache_write: 0,
6623                total_tokens: 150,
6624                cost: Cost::default(),
6625            },
6626            stop_reason: StopReason::ToolUse,
6627            error_message: None,
6628            timestamp: 12345,
6629        };
6630        session.append_message(SessionMessage::Assistant { message: assistant });
6631
6632        session.append_message(SessionMessage::ToolResult {
6633            tool_call_id: "call_abc".to_string(),
6634            tool_name: "read".to_string(),
6635            content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
6636            details: Some(serde_json::json!({"lines": 1, "truncated": false})),
6637            is_error: false,
6638            timestamp: Some(12346),
6639        });
6640
6641        run_async(async { session.save().await }).unwrap();
6642        let path = session.path.clone().unwrap();
6643
6644        let loaded =
6645            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6646
6647        assert_eq!(loaded.entries.len(), 3);
6648
6649        // Verify tool call content survived
6650        let has_tool_call = loaded.entries.iter().any(|e| {
6651            if let SessionEntry::Message(msg) = e {
6652                if let SessionMessage::Assistant { message } = &msg.message {
6653                    return message
6654                        .content
6655                        .iter()
6656                        .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id == "call_abc"));
6657                }
6658            }
6659            false
6660        });
6661        assert!(has_tool_call, "tool call should survive round-trip");
6662
6663        // Verify tool result details survived
6664        let has_details = loaded.entries.iter().any(|e| {
6665            if let SessionEntry::Message(msg) = e {
6666                if let SessionMessage::ToolResult { details, .. } = &msg.message {
6667                    return details.is_some();
6668                }
6669            }
6670            false
6671        });
6672        assert!(has_details, "tool result details should survive round-trip");
6673    }
6674
6675    // ======================================================================
6676    // FUZZ-P1.4: Proptest coverage for Session JSONL parsing
6677    // ======================================================================
6678
6679    mod proptest_session {
6680        use super::*;
6681        use proptest::prelude::*;
6682        use serde_json::json;
6683
6684        /// Generate a random valid timestamp string.
6685        fn timestamp_strategy() -> impl Strategy<Value = String> {
6686            (
6687                2020u32..2030,
6688                1u32..13,
6689                1u32..29,
6690                0u32..24,
6691                0u32..60,
6692                0u32..60,
6693            )
6694                .prop_map(|(y, mo, d, h, mi, s)| {
6695                    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
6696                })
6697        }
6698
6699        /// Generate a random entry ID (8 hex chars).
6700        fn entry_id_strategy() -> impl Strategy<Value = String> {
6701            "[0-9a-f]{8}"
6702        }
6703
6704        /// Generate an arbitrary JSON value of bounded depth/size.
6705        fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
6706            if max_depth == 0 {
6707                prop_oneof![
6708                    Just(json!(null)),
6709                    any::<bool>().prop_map(|b| json!(b)),
6710                    any::<i64>().prop_map(|n| json!(n)),
6711                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
6712                ]
6713                .boxed()
6714            } else {
6715                prop_oneof![
6716                    Just(json!(null)),
6717                    any::<bool>().prop_map(|b| json!(b)),
6718                    any::<i64>().prop_map(|n| json!(n)),
6719                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
6720                    prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
6721                        .prop_map(serde_json::Value::Array),
6722                ]
6723                .boxed()
6724            }
6725        }
6726
6727        /// Generate a valid `SessionEntry` JSON object for one of the known types.
6728        #[allow(clippy::too_many_lines)]
6729        fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
6730            let ts = timestamp_strategy();
6731            let eid = entry_id_strategy();
6732            let parent = prop::option::of(entry_id_strategy());
6733
6734            (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
6735                let base = json!({
6736                    "id": eid,
6737                    "parentId": parent,
6738                    "timestamp": ts,
6739                });
6740
6741                match variant {
6742                    0 => {
6743                        // Message - User
6744                        "[a-zA-Z0-9 ]{1,64}"
6745                            .prop_map(move |text| {
6746                                let mut v = base.clone();
6747                                v["type"] = json!("message");
6748                                v["message"] = json!({
6749                                    "role": "user",
6750                                    "content": text,
6751                                });
6752                                v
6753                            })
6754                            .boxed()
6755                    }
6756                    1 => {
6757                        // Message - Assistant
6758                        "[a-zA-Z0-9 ]{1,64}"
6759                            .prop_map(move |text| {
6760                                let mut v = base.clone();
6761                                v["type"] = json!("message");
6762                                v["message"] = json!({
6763                                    "role": "assistant",
6764                                    "content": [{"type": "text", "text": text}],
6765                                    "api": "anthropic",
6766                                    "provider": "anthropic",
6767                                    "model": "test-model",
6768                                    "usage": {
6769                                        "input": 10,
6770                                        "output": 5,
6771                                        "cacheRead": 0,
6772                                        "cacheWrite": 0,
6773                                        "totalTokens": 15,
6774                                        "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
6775                                    },
6776                                    "stopReason": "end_turn",
6777                                    "timestamp": 12345,
6778                                });
6779                                v
6780                            })
6781                            .boxed()
6782                    }
6783                    2 => {
6784                        // ModelChange
6785                        ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
6786                            .prop_map(move |(provider, model)| {
6787                                let mut v = base.clone();
6788                                v["type"] = json!("model_change");
6789                                v["provider"] = json!(provider);
6790                                v["modelId"] = json!(model);
6791                                v
6792                            })
6793                            .boxed()
6794                    }
6795                    3 => {
6796                        // ThinkingLevelChange
6797                        prop_oneof![
6798                            Just("off".to_string()),
6799                            Just("low".to_string()),
6800                            Just("medium".to_string()),
6801                            Just("high".to_string()),
6802                        ]
6803                        .prop_map(move |level| {
6804                            let mut v = base.clone();
6805                            v["type"] = json!("thinking_level_change");
6806                            v["thinkingLevel"] = json!(level);
6807                            v
6808                        })
6809                        .boxed()
6810                    }
6811                    4 => {
6812                        // Compaction
6813                        ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
6814                            .prop_map(move |(summary, kept_id, tokens)| {
6815                                let mut v = base.clone();
6816                                v["type"] = json!("compaction");
6817                                v["summary"] = json!(summary);
6818                                v["firstKeptEntryId"] = json!(kept_id);
6819                                v["tokensBefore"] = json!(tokens);
6820                                v
6821                            })
6822                            .boxed()
6823                    }
6824                    5 => {
6825                        // Label
6826                        (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
6827                            .prop_map(move |(target, label)| {
6828                                let mut v = base.clone();
6829                                v["type"] = json!("label");
6830                                v["targetId"] = json!(target);
6831                                if let Some(l) = label {
6832                                    v["label"] = json!(l);
6833                                }
6834                                v
6835                            })
6836                            .boxed()
6837                    }
6838                    6 => {
6839                        // SessionInfo
6840                        prop::option::of("[a-zA-Z0-9 ]{1,32}")
6841                            .prop_map(move |name| {
6842                                let mut v = base.clone();
6843                                v["type"] = json!("session_info");
6844                                if let Some(n) = name {
6845                                    v["name"] = json!(n);
6846                                }
6847                                v
6848                            })
6849                            .boxed()
6850                    }
6851                    _ => {
6852                        // Custom
6853                        ("[a-z_]{3,12}", bounded_json_value(2))
6854                            .prop_map(move |(custom_type, data)| {
6855                                let mut v = base.clone();
6856                                v["type"] = json!("custom");
6857                                v["customType"] = json!(custom_type);
6858                                v["data"] = data;
6859                                v
6860                            })
6861                            .boxed()
6862                    }
6863                }
6864            })
6865        }
6866
6867        /// Generate a corrupted JSON line (valid JSON but wrong shape for `SessionEntry`).
6868        fn corrupted_entry_json() -> impl Strategy<Value = String> {
6869            prop_oneof![
6870                // Missing "type" field
6871                Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6872                // Unknown type
6873                Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6874                // Empty object
6875                Just(r"{}".to_string()),
6876                // Array instead of object
6877                Just(r"[1,2,3]".to_string()),
6878                // Scalar values
6879                Just(r"42".to_string()),
6880                Just(r#""just a string""#.to_string()),
6881                Just(r"null".to_string()),
6882                Just(r"true".to_string()),
6883                // Truncated JSON (simulating crash)
6884                Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
6885                // Valid JSON with wrong field types
6886                Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6887            ]
6888        }
6889
6890        /// Build a complete JSONL file string from header + entries.
6891        fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
6892            let mut lines = vec![header.to_string()];
6893            lines.extend(entry_lines.iter().cloned());
6894            lines.join("\n")
6895        }
6896
6897        // ------------------------------------------------------------------
6898        // Proptest 1: SessionEntry deserialization never panics
6899        // ------------------------------------------------------------------
6900        proptest! {
6901            #![proptest_config(ProptestConfig {
6902                cases: 256,
6903                max_shrink_iters: 200,
6904                .. ProptestConfig::default()
6905            })]
6906
6907            #[test]
6908            fn session_entry_deser_never_panics(
6909                entry_json in valid_session_entry_json()
6910            ) {
6911                let json_str = entry_json.to_string();
6912                // Must not panic — Ok or Err is fine
6913                let _ = serde_json::from_str::<SessionEntry>(&json_str);
6914            }
6915        }
6916
6917        // ------------------------------------------------------------------
6918        // Proptest 2: Corrupted/malformed input never panics
6919        // ------------------------------------------------------------------
6920        proptest! {
6921            #![proptest_config(ProptestConfig {
6922                cases: 256,
6923                max_shrink_iters: 200,
6924                .. ProptestConfig::default()
6925            })]
6926
6927            #[test]
6928            fn corrupted_entry_deser_never_panics(
6929                line in corrupted_entry_json()
6930            ) {
6931                let _ = serde_json::from_str::<SessionEntry>(&line);
6932            }
6933
6934            #[test]
6935            fn arbitrary_bytes_deser_never_panics(
6936                raw in prop::collection::vec(any::<u8>(), 0..512)
6937            ) {
6938                // Even random bytes must not panic serde
6939                if let Ok(s) = String::from_utf8(raw) {
6940                    let _ = serde_json::from_str::<SessionEntry>(&s);
6941                }
6942            }
6943        }
6944
6945        // ------------------------------------------------------------------
6946        // Proptest 3: Valid entries round-trip through serialization
6947        // ------------------------------------------------------------------
6948        proptest! {
6949            #![proptest_config(ProptestConfig {
6950                cases: 256,
6951                max_shrink_iters: 200,
6952                .. ProptestConfig::default()
6953            })]
6954
6955            #[test]
6956            fn valid_entry_round_trip(
6957                entry_json in valid_session_entry_json()
6958            ) {
6959                let json_str = entry_json.to_string();
6960                if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
6961                    // Serialize back
6962                    let reserialized = serde_json::to_string(&entry).unwrap();
6963                    // Deserialize again
6964                    let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
6965                    // Both should have the same entry ID
6966                    assert_eq!(entry.base_id(), re_entry.base_id());
6967                    // Both should have the same type tag
6968                    assert_eq!(
6969                        std::mem::discriminant(&entry),
6970                        std::mem::discriminant(&re_entry)
6971                    );
6972                }
6973            }
6974        }
6975
6976        // ------------------------------------------------------------------
6977        // Proptest 4: Full JSONL load with mixed valid/invalid lines
6978        //             recovers valid entries and reports diagnostics
6979        // ------------------------------------------------------------------
6980        proptest! {
6981            #![proptest_config(ProptestConfig {
6982                cases: 128,
6983                max_shrink_iters: 100,
6984                .. ProptestConfig::default()
6985            })]
6986
6987            #[test]
6988            fn jsonl_corrupted_recovery(
6989                valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
6990                corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
6991                interleave_seed in any::<u64>(),
6992            ) {
6993                let header_json = json!({
6994                    "type": "session",
6995                    "version": 3,
6996                    "id": "testid01",
6997                    "timestamp": "2024-01-01T00:00:00.000Z",
6998                    "cwd": "/tmp/test"
6999                }).to_string();
7000
7001                // Interleave valid and corrupted lines deterministically
7002                let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
7003                let total = valid_strs.len() + corrupted_lines.len();
7004                let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
7005                for s in &valid_strs {
7006                    all_lines.push((true, s.clone()));
7007                }
7008                for s in &corrupted_lines {
7009                    all_lines.push((false, s.clone()));
7010                }
7011
7012                // Deterministic shuffle based on seed
7013                let mut seed = interleave_seed;
7014                for i in (1..all_lines.len()).rev() {
7015                    seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
7016                    let j = (seed >> 33) as usize % (i + 1);
7017                    all_lines.swap(i, j);
7018                }
7019
7020                let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
7021                let content = build_jsonl(&header_json, &entry_lines);
7022
7023                // Write to temp file and load
7024                let temp_dir = tempfile::tempdir().unwrap();
7025                let file_path = temp_dir.path().join("test_session.jsonl");
7026                std::fs::write(&file_path, &content).unwrap();
7027
7028                let (session, diagnostics) = run_async(async {
7029                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7030                }).unwrap();
7031
7032                // Invariant: parsed + skipped == total lines (all non-empty)
7033                let total_parsed = session.entries.len();
7034                assert_eq!(
7035                    total_parsed + diagnostics.skipped_entries.len(),
7036                    total,
7037                    "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
7038                    diagnostics.skipped_entries.len()
7039                );
7040            }
7041        }
7042
7043        // ------------------------------------------------------------------
7044        // Proptest 5: Orphaned parent links are detected
7045        // ------------------------------------------------------------------
7046        proptest! {
7047            #![proptest_config(ProptestConfig {
7048                cases: 128,
7049                max_shrink_iters: 100,
7050                .. ProptestConfig::default()
7051            })]
7052
7053            #[test]
7054            fn orphaned_parent_links_detected(
7055                n_entries in 2usize..10,
7056                orphan_idx in 0usize..8,
7057            ) {
7058                let orphan_idx = orphan_idx % n_entries;
7059                let header_json = json!({
7060                    "type": "session",
7061                    "version": 3,
7062                    "id": "testid01",
7063                    "timestamp": "2024-01-01T00:00:00.000Z",
7064                    "cwd": "/tmp/test"
7065                }).to_string();
7066
7067                let mut entry_lines = Vec::new();
7068                let mut prev_id: Option<String> = None;
7069
7070                for i in 0..n_entries {
7071                    let eid = format!("{i:08x}");
7072                    let parent = if i == orphan_idx {
7073                        // Point to a nonexistent parent
7074                        Some("deadbeef".to_string())
7075                    } else {
7076                        prev_id.clone()
7077                    };
7078
7079                    let entry = json!({
7080                        "type": "message",
7081                        "id": eid,
7082                        "parentId": parent,
7083                        "timestamp": "2024-01-01T00:00:00.000Z",
7084                        "message": {
7085                            "role": "user",
7086                            "content": format!("msg {i}"),
7087                        }
7088                    });
7089                    entry_lines.push(entry.to_string());
7090                    prev_id = Some(eid);
7091                }
7092
7093                let content = build_jsonl(&header_json, &entry_lines);
7094                let temp_dir = tempfile::tempdir().unwrap();
7095                let file_path = temp_dir.path().join("orphan_test.jsonl");
7096                std::fs::write(&file_path, &content).unwrap();
7097
7098                let (_session, diagnostics) = run_async(async {
7099                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7100                }).unwrap();
7101
7102                // The orphaned entry should be detected
7103                let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
7104                    o.missing_parent_id == "deadbeef"
7105                });
7106                assert!(
7107                    has_orphan,
7108                    "orphaned parent link to 'deadbeef' should be detected"
7109                );
7110            }
7111        }
7112
7113        // ------------------------------------------------------------------
7114        // Proptest 6: ensure_entry_ids assigns IDs to entries without them
7115        // ------------------------------------------------------------------
7116        proptest! {
7117            #![proptest_config(ProptestConfig {
7118                cases: 128,
7119                max_shrink_iters: 100,
7120                .. ProptestConfig::default()
7121            })]
7122
7123            #[test]
7124            fn ensure_entry_ids_fills_gaps(
7125                n_total in 1usize..20,
7126                missing_mask in prop::collection::vec(any::<bool>(), 1..20),
7127            ) {
7128                let n = n_total.min(missing_mask.len());
7129                let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
7130                    let id = if missing_mask[i] {
7131                        None
7132                    } else {
7133                        Some(format!("{i:08x}"))
7134                    };
7135                    SessionEntry::Message(MessageEntry {
7136                        base: EntryBase {
7137                            id,
7138                            parent_id: None,
7139                            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
7140                        },
7141                        message: SessionMessage::User {
7142                            content: UserContent::Text(format!("msg {i}")),
7143                            timestamp: Some(0),
7144                        },
7145                    })
7146                }).collect();
7147
7148                ensure_entry_ids(&mut entries);
7149
7150                // All entries must have IDs after the call
7151                for entry in &entries {
7152                    assert!(
7153                        entry.base_id().is_some(),
7154                        "all entries must have IDs after ensure_entry_ids"
7155                    );
7156                }
7157
7158                // All IDs must be unique
7159                let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
7160                let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
7161                assert_eq!(
7162                    ids.len(),
7163                    unique.len(),
7164                    "all entry IDs must be unique"
7165                );
7166            }
7167        }
7168
7169        // ------------------------------------------------------------------
7170        // Proptest 7: SessionHeader deserialization with boundary values
7171        // ------------------------------------------------------------------
7172        proptest! {
7173            #![proptest_config(ProptestConfig {
7174                cases: 256,
7175                max_shrink_iters: 200,
7176                .. ProptestConfig::default()
7177            })]
7178
7179            #[test]
7180            fn session_header_deser_never_panics(
7181                version in prop::option::of(0u8..255),
7182                id in "[a-zA-Z0-9-]{0,64}",
7183                ts in timestamp_strategy(),
7184                cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
7185                provider in prop::option::of("[a-z]{2,10}"),
7186                model_id in prop::option::of("[a-z0-9-]{2,20}"),
7187                thinking_level in prop::option::of("[a-z]{2,8}"),
7188            ) {
7189                let mut obj = json!({
7190                    "type": "session",
7191                    "id": id,
7192                    "timestamp": ts,
7193                    "cwd": cwd,
7194                });
7195                if let Some(v) = version {
7196                    obj["version"] = json!(v);
7197                }
7198                if let Some(p) = &provider {
7199                    obj["provider"] = json!(p);
7200                }
7201                if let Some(m) = &model_id {
7202                    obj["modelId"] = json!(m);
7203                }
7204                if let Some(t) = &thinking_level {
7205                    obj["thinkingLevel"] = json!(t);
7206                }
7207                let json_str = obj.to_string();
7208                let _ = serde_json::from_str::<SessionHeader>(&json_str);
7209            }
7210        }
7211
7212        // ------------------------------------------------------------------
7213        // Proptest 8: Edge-case JSONL files
7214        // ------------------------------------------------------------------
7215
7216        #[test]
7217        fn empty_file_returns_error() {
7218            let temp_dir = tempfile::tempdir().unwrap();
7219            let file_path = temp_dir.path().join("empty.jsonl");
7220            std::fs::write(&file_path, "").unwrap();
7221
7222            let result = run_async(async {
7223                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7224            });
7225            assert!(result.is_err(), "empty file should return error");
7226        }
7227
7228        #[test]
7229        fn header_only_file_produces_empty_session() {
7230            let header = json!({
7231                "type": "session",
7232                "version": 3,
7233                "id": "testid01",
7234                "timestamp": "2024-01-01T00:00:00.000Z",
7235                "cwd": "/tmp/test"
7236            })
7237            .to_string();
7238
7239            let temp_dir = tempfile::tempdir().unwrap();
7240            let file_path = temp_dir.path().join("header_only.jsonl");
7241            std::fs::write(&file_path, &header).unwrap();
7242
7243            let (session, diagnostics) = run_async(async {
7244                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7245            })
7246            .unwrap();
7247
7248            assert!(
7249                session.entries.is_empty(),
7250                "header-only file should have no entries"
7251            );
7252            assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
7253        }
7254
7255        #[test]
7256        fn file_with_only_invalid_lines_has_diagnostics() {
7257            let header = json!({
7258                "type": "session",
7259                "version": 3,
7260                "id": "testid01",
7261                "timestamp": "2024-01-01T00:00:00.000Z",
7262                "cwd": "/tmp/test"
7263            })
7264            .to_string();
7265
7266            let content = format!(
7267                "{}\n{}\n{}\n{}",
7268                header,
7269                r#"{"bad":"json","no":"type"}"#,
7270                r"not json at all",
7271                r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
7272            );
7273
7274            let temp_dir = tempfile::tempdir().unwrap();
7275            let file_path = temp_dir.path().join("all_invalid.jsonl");
7276            std::fs::write(&file_path, &content).unwrap();
7277
7278            let (session, diagnostics) = run_async(async {
7279                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7280            })
7281            .unwrap();
7282
7283            assert!(
7284                session.entries.is_empty(),
7285                "all-invalid file should have no entries"
7286            );
7287            assert_eq!(
7288                diagnostics.skipped_entries.len(),
7289                3,
7290                "should have 3 skipped entries"
7291            );
7292        }
7293
7294        #[test]
7295        fn duplicate_entry_ids_are_loaded_without_panic() {
7296            let header = json!({
7297                "type": "session",
7298                "version": 3,
7299                "id": "testid01",
7300                "timestamp": "2024-01-01T00:00:00.000Z",
7301                "cwd": "/tmp/test"
7302            })
7303            .to_string();
7304
7305            let entry1 = json!({
7306                "type": "message",
7307                "id": "deadbeef",
7308                "timestamp": "2024-01-01T00:00:00.000Z",
7309                "message": {"role": "user", "content": "first"}
7310            })
7311            .to_string();
7312
7313            let entry2 = json!({
7314                "type": "message",
7315                "id": "deadbeef",
7316                "timestamp": "2024-01-01T00:00:01.000Z",
7317                "message": {"role": "user", "content": "second (duplicate id)"}
7318            })
7319            .to_string();
7320
7321            let content = format!("{header}\n{entry1}\n{entry2}");
7322
7323            let temp_dir = tempfile::tempdir().unwrap();
7324            let file_path = temp_dir.path().join("dup_ids.jsonl");
7325            std::fs::write(&file_path, &content).unwrap();
7326
7327            // Must not panic
7328            let (session, _diagnostics) = run_async(async {
7329                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7330            })
7331            .unwrap();
7332
7333            assert_eq!(session.entries.len(), 2, "both entries should be loaded");
7334        }
7335    }
7336
7337    // ------------------------------------------------------------------
7338    // Incremental append tests
7339    // ------------------------------------------------------------------
7340
7341    #[test]
7342    fn test_incremental_append_writes_only_new_entries() {
7343        let temp_dir = tempfile::tempdir().expect("temp dir");
7344        let mut session = Session::create();
7345        session.session_dir = Some(temp_dir.path().to_path_buf());
7346
7347        // First save: full rewrite (persisted_entry_count == 0).
7348        session.append_message(make_test_message("msg A"));
7349        session.append_message(make_test_message("msg B"));
7350        run_async(async { session.save().await }).unwrap();
7351
7352        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7353        assert_eq!(session.appends_since_checkpoint, 0);
7354
7355        let path = session.path.clone().unwrap();
7356        let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
7357        // 1 header + 2 entries = 3 lines
7358        assert_eq!(lines_after_first, 3);
7359
7360        // Add more entries and save again (incremental append).
7361        session.append_message(make_test_message("msg C"));
7362        run_async(async { session.save().await }).unwrap();
7363
7364        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
7365        assert_eq!(session.appends_since_checkpoint, 1);
7366
7367        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
7368        // 1 header + 3 entries = 4 lines
7369        assert_eq!(lines_after_second, 4);
7370    }
7371
7372    #[test]
7373    fn test_header_change_forces_full_rewrite() {
7374        let temp_dir = tempfile::tempdir().expect("temp dir");
7375        let mut session = Session::create();
7376        session.session_dir = Some(temp_dir.path().to_path_buf());
7377
7378        session.append_message(make_test_message("msg A"));
7379        run_async(async { session.save().await }).unwrap();
7380        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7381        assert!(!session.header_dirty);
7382
7383        // Modify header.
7384        session.set_model_header(Some("new-provider".to_string()), None, None);
7385        assert!(session.header_dirty);
7386
7387        session.append_message(make_test_message("msg B"));
7388        run_async(async { session.save().await }).unwrap();
7389
7390        // Full rewrite resets all counters.
7391        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7392        assert!(!session.header_dirty);
7393        assert_eq!(session.appends_since_checkpoint, 0);
7394
7395        // Verify header on disk has the new provider.
7396        let path = session.path.clone().unwrap();
7397        let first_line = std::fs::read_to_string(&path)
7398            .unwrap()
7399            .lines()
7400            .next()
7401            .unwrap()
7402            .to_string();
7403        let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
7404        assert_eq!(header["provider"], "new-provider");
7405    }
7406
7407    #[test]
7408    fn test_compaction_entry_uses_incremental_append() {
7409        let temp_dir = tempfile::tempdir().expect("temp dir");
7410        let mut session = Session::create();
7411        session.session_dir = Some(temp_dir.path().to_path_buf());
7412
7413        let id_a = session.append_message(make_test_message("msg A"));
7414        run_async(async { session.save().await }).unwrap();
7415        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7416
7417        // Append a compaction entry. This should still be eligible for
7418        // incremental append; checkpoint rewrite cadence handles periodic
7419        // full rewrites for cleanup/corruption recovery.
7420        session.append_compaction("summary".to_string(), id_a, 100, None, None);
7421        session.append_message(make_test_message("msg B"));
7422
7423        run_async(async { session.save().await }).unwrap();
7424
7425        // Incremental append: persisted count advances and checkpoint counter increments.
7426        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
7427        assert_eq!(session.appends_since_checkpoint, 1);
7428
7429        let path = session.path.clone().unwrap();
7430        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
7431        // 1 header + 3 entries = 4 lines
7432        assert_eq!(lines_after_second, 4);
7433    }
7434
7435    #[test]
7436    fn test_checkpoint_interval_forces_full_rewrite() {
7437        let temp_dir = tempfile::tempdir().expect("temp dir");
7438        let mut session = Session::create();
7439        session.session_dir = Some(temp_dir.path().to_path_buf());
7440
7441        // First save (full rewrite).
7442        session.append_message(make_test_message("initial"));
7443        run_async(async { session.save().await }).unwrap();
7444
7445        // Simulate many incremental appends by setting the counter near threshold.
7446        let interval = compaction_checkpoint_interval();
7447        session.appends_since_checkpoint = interval;
7448
7449        // Next save should trigger full rewrite due to checkpoint.
7450        session.append_message(make_test_message("triggers checkpoint"));
7451        run_async(async { session.save().await }).unwrap();
7452
7453        // Full rewrite resets counters.
7454        assert_eq!(session.appends_since_checkpoint, 0);
7455        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7456    }
7457
7458    #[test]
7459    fn test_incremental_append_load_round_trip() {
7460        let temp_dir = tempfile::tempdir().expect("temp dir");
7461        let mut session = Session::create();
7462        session.session_dir = Some(temp_dir.path().to_path_buf());
7463
7464        // First save.
7465        session.append_message(make_test_message("msg A"));
7466        session.append_message(make_test_message("msg B"));
7467        run_async(async { session.save().await }).unwrap();
7468
7469        // Incremental append.
7470        session.append_message(make_test_message("msg C"));
7471        run_async(async { session.save().await }).unwrap();
7472
7473        let path = session.path.clone().unwrap();
7474
7475        // Reload and verify all entries present.
7476        let loaded =
7477            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7478
7479        assert_eq!(loaded.entries.len(), 3);
7480        // Verify the entry content by checking that we have messages A, B, C.
7481        let texts: Vec<&str> = loaded
7482            .entries
7483            .iter()
7484            .filter_map(|e| match e {
7485                SessionEntry::Message(m) => match &m.message {
7486                    SessionMessage::User {
7487                        content: UserContent::Text(t),
7488                        ..
7489                    } => Some(t.as_str()),
7490                    _ => None,
7491                },
7492                _ => None,
7493            })
7494            .collect();
7495        assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
7496    }
7497
7498    #[test]
7499    fn test_persisted_entry_count_set_on_open() {
7500        let temp_dir = tempfile::tempdir().expect("temp dir");
7501        let mut session = Session::create();
7502        session.session_dir = Some(temp_dir.path().to_path_buf());
7503
7504        session.append_message(make_test_message("msg A"));
7505        session.append_message(make_test_message("msg B"));
7506        session.append_message(make_test_message("msg C"));
7507        run_async(async { session.save().await }).unwrap();
7508
7509        let path = session.path.clone().unwrap();
7510        let loaded =
7511            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7512
7513        assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
7514        assert!(!loaded.header_dirty);
7515        assert_eq!(loaded.appends_since_checkpoint, 0);
7516    }
7517
7518    #[test]
7519    fn test_no_new_entries_is_noop() {
7520        let temp_dir = tempfile::tempdir().expect("temp dir");
7521        let mut session = Session::create();
7522        session.session_dir = Some(temp_dir.path().to_path_buf());
7523
7524        session.append_message(make_test_message("msg A"));
7525        run_async(async { session.save().await }).unwrap();
7526
7527        let path = session.path.clone().unwrap();
7528        let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
7529
7530        // Sleep briefly to ensure mtime would change if file was written.
7531        std::thread::sleep(std::time::Duration::from_millis(50));
7532
7533        // Save again with no changes.
7534        run_async(async { session.save().await }).unwrap();
7535
7536        let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
7537        assert_eq!(
7538            mtime_before, mtime_after,
7539            "file should not be modified on no-op save"
7540        );
7541        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7542    }
7543
7544    #[test]
7545    fn test_incremental_append_caches_stay_valid() {
7546        let temp_dir = tempfile::tempdir().expect("temp dir");
7547        let mut session = Session::create();
7548        session.session_dir = Some(temp_dir.path().to_path_buf());
7549
7550        session.append_message(make_test_message("msg A"));
7551        run_async(async { session.save().await }).unwrap();
7552
7553        // After full rewrite, caches rebuilt.
7554        assert_eq!(session.entry_index.len(), 1);
7555
7556        // Incremental append: add more entries.
7557        let id_b = session.append_message(make_test_message("msg B"));
7558        let id_c = session.append_message(make_test_message("msg C"));
7559        run_async(async { session.save().await }).unwrap();
7560
7561        // Caches should still be valid (not rebuilt, but maintained incrementally).
7562        assert_eq!(session.entry_index.len(), 3);
7563        assert!(session.entry_index.contains_key(&id_b));
7564        assert!(session.entry_index.contains_key(&id_c));
7565        assert_eq!(session.cached_message_count, 3);
7566    }
7567
7568    #[test]
7569    fn test_set_branched_from_marks_header_dirty() {
7570        let mut session = Session::create();
7571        assert!(!session.header_dirty);
7572
7573        session.set_branched_from(Some("/some/path".to_string()));
7574        assert!(session.header_dirty);
7575    }
7576
7577    // ====================================================================
7578    // Crash-consistency and recovery tests (bd-3ar8v.2.7)
7579    // ====================================================================
7580
7581    /// Helper: build a valid JSONL session file string with header + N entries.
7582    fn build_crash_test_session_file(num_entries: usize) -> String {
7583        let header = serde_json::json!({
7584            "type": "session",
7585            "version": 3,
7586            "id": "crash-test",
7587            "timestamp": "2024-06-01T00:00:00.000Z",
7588            "cwd": "/tmp/test"
7589        });
7590        let mut lines = vec![serde_json::to_string(&header).unwrap()];
7591        for i in 0..num_entries {
7592            let entry = serde_json::json!({
7593                "type": "message",
7594                "id": format!("entry-{i}"),
7595                "timestamp": "2024-06-01T00:00:00.000Z",
7596                "message": {"role": "user", "content": format!("message {i}")}
7597            });
7598            lines.push(serde_json::to_string(&entry).unwrap());
7599        }
7600        lines.join("\n")
7601    }
7602
7603    #[test]
7604    fn crash_empty_file_returns_error() {
7605        let temp_dir = tempfile::tempdir().unwrap();
7606        let file_path = temp_dir.path().join("empty.jsonl");
7607        std::fs::write(&file_path, "").unwrap();
7608
7609        let result = run_async(async {
7610            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7611        });
7612        assert!(result.is_err(), "empty file should fail to open");
7613    }
7614
7615    #[test]
7616    fn crash_corrupted_header_returns_error() {
7617        let temp_dir = tempfile::tempdir().unwrap();
7618        let file_path = temp_dir.path().join("bad_header.jsonl");
7619        std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
7620
7621        let result = run_async(async {
7622            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7623        });
7624        assert!(result.is_err(), "corrupted header should fail");
7625    }
7626
7627    #[test]
7628    fn crash_header_only_loads_empty() {
7629        let temp_dir = tempfile::tempdir().unwrap();
7630        let file_path = temp_dir.path().join("header_only.jsonl");
7631        let header = serde_json::json!({
7632            "type": "session",
7633            "version": 3,
7634            "id": "hdr-only",
7635            "timestamp": "2024-06-01T00:00:00.000Z",
7636            "cwd": "/tmp/test"
7637        });
7638        std::fs::write(
7639            &file_path,
7640            format!("{}\n", serde_json::to_string(&header).unwrap()),
7641        )
7642        .unwrap();
7643
7644        let (session, diagnostics) = run_async(async {
7645            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7646        })
7647        .unwrap();
7648
7649        assert!(session.entries.is_empty());
7650        assert!(diagnostics.skipped_entries.is_empty());
7651    }
7652
7653    #[test]
7654    fn crash_truncated_last_entry_recovers_preceding() {
7655        let temp_dir = tempfile::tempdir().unwrap();
7656        let file_path = temp_dir.path().join("truncated.jsonl");
7657
7658        let mut content = build_crash_test_session_file(3);
7659        let truncation_point = content.rfind('\n').unwrap();
7660        content.truncate(truncation_point);
7661        content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
7662
7663        std::fs::write(&file_path, &content).unwrap();
7664
7665        let (session, diagnostics) = run_async(async {
7666            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7667        })
7668        .unwrap();
7669
7670        assert_eq!(session.entries.len(), 2);
7671        assert_eq!(diagnostics.skipped_entries.len(), 1);
7672    }
7673
7674    #[test]
7675    fn crash_multiple_corrupted_entries_recovers_valid() {
7676        let temp_dir = tempfile::tempdir().unwrap();
7677        let file_path = temp_dir.path().join("multi_corrupt.jsonl");
7678
7679        let header = serde_json::json!({
7680            "type": "session",
7681            "version": 3,
7682            "id": "multi-corrupt",
7683            "timestamp": "2024-06-01T00:00:00.000Z",
7684            "cwd": "/tmp/test"
7685        });
7686
7687        let valid_entry = |id: &str, text: &str| {
7688            serde_json::json!({
7689                "type": "message",
7690                "id": id,
7691                "timestamp": "2024-06-01T00:00:00.000Z",
7692                "message": {"role": "user", "content": text}
7693            })
7694            .to_string()
7695        };
7696
7697        let lines = [
7698            serde_json::to_string(&header).unwrap(),
7699            valid_entry("v1", "first"),
7700            "GARBAGE LINE 1".to_string(),
7701            valid_entry("v2", "second"),
7702            "{incomplete json".to_string(),
7703            valid_entry("v3", "third"),
7704        ];
7705
7706        std::fs::write(&file_path, lines.join("\n")).unwrap();
7707
7708        let (session, diagnostics) = run_async(async {
7709            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7710        })
7711        .unwrap();
7712
7713        assert_eq!(session.entries.len(), 3, "3 valid entries survive");
7714        assert_eq!(diagnostics.skipped_entries.len(), 2);
7715    }
7716
7717    #[test]
7718    fn crash_incremental_append_survives_partial_write() {
7719        use std::io::Write;
7720
7721        let temp_dir = tempfile::tempdir().unwrap();
7722        let mut session = Session::create();
7723        session.session_dir = Some(temp_dir.path().to_path_buf());
7724
7725        session.append_message(make_test_message("msg A"));
7726        session.append_message(make_test_message("msg B"));
7727        run_async(async { session.save().await }).unwrap();
7728        let path = session.path.clone().unwrap();
7729
7730        // Simulate crash during append: write truncated entry.
7731        let mut file = std::fs::OpenOptions::new()
7732            .append(true)
7733            .open(&path)
7734            .unwrap();
7735        write!(
7736            file,
7737            "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
7738        )
7739        .unwrap();
7740        drop(file);
7741
7742        let (loaded, diagnostics) = run_async(async {
7743            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7744        })
7745        .unwrap();
7746
7747        assert_eq!(loaded.entries.len(), 2, "original entries recovered");
7748        assert_eq!(diagnostics.skipped_entries.len(), 1);
7749    }
7750
7751    #[test]
7752    fn crash_full_rewrite_atomic_persist() {
7753        let temp_dir = tempfile::tempdir().unwrap();
7754        let mut session = Session::create();
7755        session.session_dir = Some(temp_dir.path().to_path_buf());
7756
7757        session.append_message(make_test_message("original"));
7758        run_async(async { session.save().await }).unwrap();
7759        let path = session.path.clone().unwrap();
7760
7761        let original_content = std::fs::read_to_string(&path).unwrap();
7762
7763        session.set_model_header(Some("new-provider".to_string()), None, None);
7764        session.append_message(make_test_message("second"));
7765        run_async(async { session.save().await }).unwrap();
7766
7767        let new_content = std::fs::read_to_string(&path).unwrap();
7768        assert_ne!(original_content, new_content);
7769
7770        let loaded =
7771            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7772        assert_eq!(loaded.entries.len(), 2);
7773    }
7774
7775    #[test]
7776    fn crash_flush_failure_restores_pending_mutations() {
7777        let mut queue = AutosaveQueue::with_limit(10);
7778
7779        queue.enqueue_mutation(AutosaveMutationKind::Message);
7780        queue.enqueue_mutation(AutosaveMutationKind::Message);
7781        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
7782        assert_eq!(queue.pending_mutations, 3);
7783
7784        let ticket = queue
7785            .begin_flush(AutosaveFlushTrigger::Periodic)
7786            .expect("should have ticket");
7787        assert_eq!(queue.pending_mutations, 0);
7788
7789        queue.finish_flush(ticket, false);
7790        assert_eq!(queue.pending_mutations, 3, "mutations restored");
7791        assert_eq!(queue.flush_failed, 1);
7792    }
7793
7794    #[test]
7795    fn crash_flush_failure_respects_queue_capacity() {
7796        let mut queue = AutosaveQueue::with_limit(3);
7797
7798        for _ in 0..3 {
7799            queue.enqueue_mutation(AutosaveMutationKind::Message);
7800        }
7801        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
7802
7803        queue.enqueue_mutation(AutosaveMutationKind::Message);
7804        queue.enqueue_mutation(AutosaveMutationKind::Message);
7805        assert_eq!(queue.pending_mutations, 2);
7806
7807        queue.finish_flush(ticket, false);
7808        assert_eq!(queue.pending_mutations, 3, "capped at max");
7809        assert!(queue.backpressure_events >= 2);
7810    }
7811
7812    #[test]
7813    fn crash_shutdown_strict_propagates_error() {
7814        let temp_dir = tempfile::tempdir().unwrap();
7815        let mut session = Session::create();
7816        session.path = Some(
7817            temp_dir
7818                .path()
7819                .join("nonexistent_dir")
7820                .join("session.jsonl"),
7821        );
7822        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
7823        session.append_message(make_test_message("must save"));
7824        session
7825            .autosave_queue
7826            .enqueue_mutation(AutosaveMutationKind::Message);
7827
7828        let result = run_async(async { session.flush_autosave_on_shutdown().await });
7829        assert!(result.is_err(), "strict mode propagates errors");
7830    }
7831
7832    #[test]
7833    fn crash_shutdown_balanced_swallows_error() {
7834        let temp_dir = tempfile::tempdir().unwrap();
7835        let mut session = Session::create();
7836        session.path = Some(
7837            temp_dir
7838                .path()
7839                .join("nonexistent_dir")
7840                .join("session.jsonl"),
7841        );
7842        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
7843        session.append_message(make_test_message("best effort"));
7844        session
7845            .autosave_queue
7846            .enqueue_mutation(AutosaveMutationKind::Message);
7847
7848        let result = run_async(async { session.flush_autosave_on_shutdown().await });
7849        assert!(result.is_ok(), "balanced mode swallows errors");
7850    }
7851
7852    #[test]
7853    fn crash_shutdown_throughput_skips_flush() {
7854        let temp_dir = tempfile::tempdir().unwrap();
7855        let mut session = Session::create();
7856        session.path = Some(
7857            temp_dir
7858                .path()
7859                .join("nonexistent_dir")
7860                .join("session.jsonl"),
7861        );
7862        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
7863        session.append_message(make_test_message("no flush"));
7864        session
7865            .autosave_queue
7866            .enqueue_mutation(AutosaveMutationKind::Message);
7867
7868        let result = run_async(async { session.flush_autosave_on_shutdown().await });
7869        assert!(result.is_ok());
7870        assert!(session.autosave_queue.pending_mutations > 0);
7871    }
7872
7873    #[test]
7874    fn crash_save_reload_preserves_all_entry_types() {
7875        let temp_dir = tempfile::tempdir().unwrap();
7876        let mut session = Session::create();
7877        session.session_dir = Some(temp_dir.path().to_path_buf());
7878
7879        let id_a = session.append_message(make_test_message("msg A"));
7880        session.append_model_change("provider-x".to_string(), "model-y".to_string());
7881        session.append_thinking_level_change("high".to_string());
7882        session.append_compaction("summary".to_string(), id_a, 500, None, None);
7883        session.append_message(make_test_message("msg B"));
7884
7885        run_async(async { session.save().await }).unwrap();
7886        let path = session.path.clone().unwrap();
7887
7888        let loaded =
7889            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7890        assert_eq!(loaded.entries.len(), session.entries.len());
7891    }
7892
7893    #[test]
7894    fn crash_checkpoint_rewrite_cleans_corruption() {
7895        let temp_dir = tempfile::tempdir().unwrap();
7896        let mut session = Session::create();
7897        session.session_dir = Some(temp_dir.path().to_path_buf());
7898
7899        session.append_message(make_test_message("initial"));
7900        run_async(async { session.save().await }).unwrap();
7901        let path = session.path.clone().unwrap();
7902
7903        for i in 0..5 {
7904            session.append_message(make_test_message(&format!("msg {i}")));
7905            run_async(async { session.save().await }).unwrap();
7906        }
7907
7908        // Corrupt an appended entry on disk.
7909        let content = std::fs::read_to_string(&path).unwrap();
7910        let mut lines: Vec<String> = content.lines().map(String::from).collect();
7911        lines[3] = "CORRUPTED_ENTRY".to_string();
7912        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7913
7914        // Force checkpoint: full rewrite replaces corrupted file with clean data.
7915        session.appends_since_checkpoint = compaction_checkpoint_interval();
7916        session.append_message(make_test_message("post checkpoint"));
7917        run_async(async { session.save().await }).unwrap();
7918        assert_eq!(session.appends_since_checkpoint, 0);
7919
7920        let (reloaded, diagnostics) = run_async(async {
7921            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7922        })
7923        .unwrap();
7924        assert!(diagnostics.skipped_entries.is_empty());
7925        assert_eq!(reloaded.entries.len(), 7);
7926    }
7927
7928    #[test]
7929    fn crash_trailing_newlines_loads_cleanly() {
7930        let temp_dir = tempfile::tempdir().unwrap();
7931        let file_path = temp_dir.path().join("trailing_nl.jsonl");
7932
7933        let mut content = build_crash_test_session_file(2);
7934        content.push_str("\n\n\n");
7935        std::fs::write(&file_path, &content).unwrap();
7936
7937        let (session, diagnostics) = run_async(async {
7938            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7939        })
7940        .unwrap();
7941
7942        assert_eq!(session.entries.len(), 2);
7943        assert!(diagnostics.skipped_entries.is_empty());
7944    }
7945
7946    #[test]
7947    fn crash_noop_save_after_reload_is_idempotent() {
7948        let temp_dir = tempfile::tempdir().unwrap();
7949        let mut session = Session::create();
7950        session.session_dir = Some(temp_dir.path().to_path_buf());
7951
7952        session.append_message(make_test_message("hello"));
7953        run_async(async { session.save().await }).unwrap();
7954        let path = session.path.clone().unwrap();
7955        let content_before = std::fs::read_to_string(&path).unwrap();
7956
7957        let mut loaded =
7958            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7959        run_async(async { loaded.save().await }).unwrap();
7960
7961        let content_after = std::fs::read_to_string(&path).unwrap();
7962        assert_eq!(content_before, content_after);
7963    }
7964
7965    #[test]
7966    fn crash_corrupt_then_continue_operation() {
7967        let temp_dir = tempfile::tempdir().unwrap();
7968        let mut session = Session::create();
7969        session.session_dir = Some(temp_dir.path().to_path_buf());
7970
7971        session.append_message(make_test_message("msg A"));
7972        session.append_message(make_test_message("msg B"));
7973        run_async(async { session.save().await }).unwrap();
7974        let path = session.path.clone().unwrap();
7975
7976        // Corrupt last entry.
7977        let content = std::fs::read_to_string(&path).unwrap();
7978        let mut lines: Vec<String> = content.lines().map(String::from).collect();
7979        *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
7980        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7981
7982        let (mut recovered, diagnostics) = run_async(async {
7983            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7984        })
7985        .unwrap();
7986        assert_eq!(diagnostics.skipped_entries.len(), 1);
7987        assert_eq!(recovered.entries.len(), 1);
7988
7989        // Continue: add and save.
7990        recovered.path = Some(path.clone());
7991        recovered.session_dir = Some(temp_dir.path().to_path_buf());
7992        recovered.append_message(make_test_message("msg C"));
7993        run_async(async { recovered.save().await }).unwrap();
7994
7995        let reloaded =
7996            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7997        assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
7998    }
7999
8000    #[test]
8001    fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
8002        let temp_dir = tempfile::tempdir().unwrap();
8003        let mut session = Session::create();
8004        session.session_dir = Some(temp_dir.path().to_path_buf());
8005
8006        session.append_message(make_test_message("msg A"));
8007        run_async(async { session.save().await }).unwrap();
8008
8009        session.persisted_entry_count.store(999, Ordering::SeqCst);
8010        assert!(session.should_full_rewrite());
8011
8012        session.append_message(make_test_message("msg B"));
8013        run_async(async { session.save().await }).unwrap();
8014        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8015        assert_eq!(session.appends_since_checkpoint, 0);
8016    }
8017
8018    #[test]
8019    fn crash_persisted_count_unchanged_on_append_failure() {
8020        let temp_dir = tempfile::tempdir().unwrap();
8021        let mut session = Session::create();
8022        session.session_dir = Some(temp_dir.path().to_path_buf());
8023
8024        session.append_message(make_test_message("msg A"));
8025        run_async(async { session.save().await }).unwrap();
8026        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8027
8028        let path = session.path.clone().unwrap();
8029        session.append_message(make_test_message("msg B"));
8030
8031        #[cfg(unix)]
8032        {
8033            use std::os::unix::fs::PermissionsExt;
8034            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8035        }
8036        #[cfg(not(unix))]
8037        {
8038            return;
8039        }
8040
8041        let result = run_async(async { session.save().await });
8042
8043        #[cfg(unix)]
8044        {
8045            use std::os::unix::fs::PermissionsExt;
8046            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8047        }
8048
8049        assert!(result.is_err());
8050        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8051
8052        run_async(async { session.save().await }).unwrap();
8053        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8054    }
8055
8056    #[test]
8057    fn crash_queue_backpressure_at_limit() {
8058        let mut queue = AutosaveQueue::with_limit(3);
8059
8060        queue.enqueue_mutation(AutosaveMutationKind::Message);
8061        queue.enqueue_mutation(AutosaveMutationKind::Message);
8062        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8063        assert_eq!(queue.pending_mutations, 3);
8064
8065        queue.enqueue_mutation(AutosaveMutationKind::Label);
8066        assert_eq!(queue.pending_mutations, 3, "capped");
8067        assert_eq!(queue.backpressure_events, 1);
8068    }
8069
8070    #[test]
8071    fn crash_flush_failure_with_intervening_mutations() {
8072        let mut queue = AutosaveQueue::with_limit(8);
8073
8074        for _ in 0..4 {
8075            queue.enqueue_mutation(AutosaveMutationKind::Message);
8076        }
8077        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8078
8079        queue.enqueue_mutation(AutosaveMutationKind::Message);
8080        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8081        assert_eq!(queue.pending_mutations, 2);
8082
8083        // restore_budget = 8 - 2 = 6, restored = min(4, 6) = 4
8084        queue.finish_flush(ticket, false);
8085        assert_eq!(queue.pending_mutations, 6);
8086        assert_eq!(queue.flush_failed, 1);
8087    }
8088
8089    #[test]
8090    fn crash_queue_metrics_snapshot() {
8091        let mut queue = AutosaveQueue::with_limit(5);
8092        queue.enqueue_mutation(AutosaveMutationKind::Message);
8093        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8094        queue.enqueue_mutation(AutosaveMutationKind::Label);
8095
8096        let metrics = queue.metrics();
8097        assert_eq!(metrics.pending_mutations, 3);
8098        assert_eq!(metrics.max_pending_mutations, 5);
8099        assert_eq!(metrics.coalesced_mutations, 2);
8100        assert_eq!(metrics.flush_started, 0);
8101        assert!(metrics.last_flush_duration_ms.is_none());
8102    }
8103
8104    #[test]
8105    fn crash_double_flush_is_noop() {
8106        let mut queue = AutosaveQueue::with_limit(10);
8107        queue.enqueue_mutation(AutosaveMutationKind::Message);
8108        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8109        queue.finish_flush(ticket, true);
8110
8111        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
8112    }
8113
8114    #[test]
8115    fn crash_entries_survive_failed_full_rewrite() {
8116        // std::mem::take moves entries out during full rewrite.
8117        // On error they must be restored.
8118        let temp_dir = tempfile::tempdir().unwrap();
8119        let mut session = Session::create();
8120        session.session_dir = Some(temp_dir.path().to_path_buf());
8121
8122        session.append_message(make_test_message("msg A"));
8123        run_async(async { session.save().await }).unwrap();
8124        let path = session.path.clone().unwrap();
8125
8126        session.set_model_header(Some("new-provider".to_string()), None, None);
8127        session.append_message(make_test_message("msg B"));
8128
8129        #[cfg(unix)]
8130        {
8131            use std::os::unix::fs::PermissionsExt;
8132            let parent = path.parent().unwrap();
8133            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
8134        }
8135        #[cfg(not(unix))]
8136        {
8137            return;
8138        }
8139
8140        let result = run_async(async { session.save().await });
8141        assert!(result.is_err());
8142
8143        assert_eq!(session.entries.len(), 2, "entries restored");
8144        assert_eq!(session.entry_index.len(), 2);
8145        assert!(session.header_dirty);
8146
8147        #[cfg(unix)]
8148        {
8149            use std::os::unix::fs::PermissionsExt;
8150            let parent = path.parent().unwrap();
8151            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
8152        }
8153
8154        run_async(async { session.save().await }).unwrap();
8155        assert!(!session.header_dirty);
8156    }
8157
8158    #[test]
8159    fn crash_metrics_accumulate_across_failure_recovery() {
8160        let temp_dir = tempfile::tempdir().unwrap();
8161        let mut session = Session::create();
8162        session.session_dir = Some(temp_dir.path().to_path_buf());
8163
8164        session.append_message(make_test_message("msg A"));
8165        run_async(async { session.save().await }).unwrap();
8166        let path = session.path.clone().unwrap();
8167
8168        let m = session.autosave_metrics();
8169        assert_eq!(m.flush_succeeded, 1);
8170        assert_eq!(m.flush_failed, 0);
8171
8172        #[cfg(unix)]
8173        {
8174            use std::os::unix::fs::PermissionsExt;
8175            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8176        }
8177        #[cfg(not(unix))]
8178        {
8179            return;
8180        }
8181
8182        session.append_message(make_test_message("msg B"));
8183        let _ = run_async(async { session.save().await });
8184
8185        let m = session.autosave_metrics();
8186        assert_eq!(m.flush_failed, 1);
8187        assert!(m.pending_mutations > 0);
8188
8189        #[cfg(unix)]
8190        {
8191            use std::os::unix::fs::PermissionsExt;
8192            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8193        }
8194        run_async(async { session.save().await }).unwrap();
8195
8196        let m = session.autosave_metrics();
8197        assert_eq!(m.flush_succeeded, 2);
8198        assert_eq!(m.flush_failed, 1);
8199        assert_eq!(m.pending_mutations, 0);
8200        assert_eq!(m.flush_started, 3);
8201    }
8202
8203    #[test]
8204    fn crash_many_sequential_appends_accumulate() {
8205        let temp_dir = tempfile::tempdir().unwrap();
8206        let mut session = Session::create();
8207        session.session_dir = Some(temp_dir.path().to_path_buf());
8208
8209        session.append_message(make_test_message("initial"));
8210        run_async(async { session.save().await }).unwrap();
8211
8212        for i in 0..10 {
8213            session.append_message(make_test_message(&format!("append-{i}")));
8214            run_async(async { session.save().await }).unwrap();
8215        }
8216
8217        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
8218        assert_eq!(session.appends_since_checkpoint, 10);
8219
8220        let path = session.path.clone().unwrap();
8221        let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
8222        assert_eq!(line_count, 12, "1 header + 11 entries");
8223
8224        let loaded =
8225            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8226        assert_eq!(loaded.entries.len(), 11);
8227    }
8228
8229    #[test]
8230    fn crash_load_unsaved_entry_absent() {
8231        let temp_dir = tempfile::tempdir().unwrap();
8232        let mut session = Session::create();
8233        session.session_dir = Some(temp_dir.path().to_path_buf());
8234
8235        session.append_message(make_test_message("saved A"));
8236        session.append_message(make_test_message("saved B"));
8237        run_async(async { session.save().await }).unwrap();
8238        let path = session.path.clone().unwrap();
8239
8240        session.append_message(make_test_message("unsaved C"));
8241        assert_eq!(session.entries.len(), 3);
8242
8243        let loaded =
8244            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8245        assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
8246    }
8247
8248    #[test]
8249    fn test_clone_has_independent_persisted_entry_count() {
8250        let session = Session::create();
8251        // Set initial count
8252        session.persisted_entry_count.store(10, Ordering::SeqCst);
8253
8254        // Clone the session
8255        let clone = session.clone();
8256
8257        // Verify clone sees initial value
8258        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
8259
8260        // Update original
8261        session.persisted_entry_count.store(20, Ordering::SeqCst);
8262
8263        // Verify clone is UNCHANGED (independent atomic)
8264        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
8265
8266        // Update clone
8267        clone.persisted_entry_count.store(30, Ordering::SeqCst);
8268
8269        // Verify original is UNCHANGED
8270        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
8271    }
8272
8273    #[test]
8274    fn crash_append_retry_after_transient_failure() {
8275        let temp_dir = tempfile::tempdir().unwrap();
8276        let mut session = Session::create();
8277        session.session_dir = Some(temp_dir.path().to_path_buf());
8278
8279        session.append_message(make_test_message("msg A"));
8280        run_async(async { session.save().await }).unwrap();
8281        let path = session.path.clone().unwrap();
8282
8283        session.append_message(make_test_message("msg B"));
8284
8285        #[cfg(unix)]
8286        {
8287            use std::os::unix::fs::PermissionsExt;
8288            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8289        }
8290        #[cfg(not(unix))]
8291        {
8292            return;
8293        }
8294
8295        let result = run_async(async { session.save().await });
8296        assert!(result.is_err());
8297        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8298
8299        #[cfg(unix)]
8300        {
8301            use std::os::unix::fs::PermissionsExt;
8302            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8303        }
8304
8305        run_async(async { session.save().await }).unwrap();
8306        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8307
8308        let loaded =
8309            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8310        assert_eq!(loaded.entries.len(), 2);
8311    }
8312
8313    #[test]
8314    fn crash_durability_mode_parsing() {
8315        assert_eq!(
8316            AutosaveDurabilityMode::parse("strict"),
8317            Some(AutosaveDurabilityMode::Strict)
8318        );
8319        assert_eq!(
8320            AutosaveDurabilityMode::parse("BALANCED"),
8321            Some(AutosaveDurabilityMode::Balanced)
8322        );
8323        assert_eq!(
8324            AutosaveDurabilityMode::parse("  Throughput  "),
8325            Some(AutosaveDurabilityMode::Throughput)
8326        );
8327        assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
8328        assert_eq!(AutosaveDurabilityMode::parse(""), None);
8329    }
8330
8331    #[test]
8332    fn crash_durability_resolve_precedence() {
8333        assert_eq!(
8334            resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
8335            AutosaveDurabilityMode::Strict
8336        );
8337        assert_eq!(
8338            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
8339            AutosaveDurabilityMode::Throughput
8340        );
8341        assert_eq!(
8342            resolve_autosave_durability_mode(None, None, Some("strict")),
8343            AutosaveDurabilityMode::Strict
8344        );
8345        assert_eq!(
8346            resolve_autosave_durability_mode(None, None, None),
8347            AutosaveDurabilityMode::Balanced
8348        );
8349    }
8350
8351    // =========================================================================
8352    // bd-3ar8v.2.9: Comprehensive autosave queue and durability state machine
8353    // =========================================================================
8354
8355    // --- Queue boundary: minimum capacity (limit=1) ---
8356
8357    #[test]
8358    fn autosave_queue_limit_one_accepts_single_mutation() {
8359        let mut queue = AutosaveQueue::with_limit(1);
8360        queue.enqueue_mutation(AutosaveMutationKind::Message);
8361        assert_eq!(queue.pending_mutations, 1);
8362        assert_eq!(queue.coalesced_mutations, 0);
8363        assert_eq!(queue.backpressure_events, 0);
8364    }
8365
8366    #[test]
8367    fn autosave_queue_limit_one_backpressures_second_mutation() {
8368        let mut queue = AutosaveQueue::with_limit(1);
8369        queue.enqueue_mutation(AutosaveMutationKind::Message);
8370        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8371        assert_eq!(queue.pending_mutations, 1, "capped at 1");
8372        assert_eq!(queue.backpressure_events, 1);
8373        assert_eq!(queue.coalesced_mutations, 1);
8374    }
8375
8376    #[test]
8377    fn autosave_queue_limit_one_flush_and_refill() {
8378        let mut queue = AutosaveQueue::with_limit(1);
8379        queue.enqueue_mutation(AutosaveMutationKind::Message);
8380
8381        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8382        assert_eq!(queue.pending_mutations, 0);
8383        assert_eq!(ticket.batch_size, 1);
8384        queue.finish_flush(ticket, true);
8385
8386        // Refill works after flush.
8387        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8388        assert_eq!(queue.pending_mutations, 1);
8389        assert_eq!(queue.flush_succeeded, 1);
8390    }
8391
8392    // --- Queue boundary: with_limit enforces minimum of 1 ---
8393
8394    #[test]
8395    fn autosave_queue_with_limit_zero_clamps_to_one() {
8396        let queue = AutosaveQueue::with_limit(0);
8397        assert_eq!(queue.max_pending_mutations, 1);
8398    }
8399
8400    // --- Empty queue operations ---
8401
8402    #[test]
8403    fn autosave_queue_begin_flush_on_empty_returns_none() {
8404        let mut queue = AutosaveQueue::with_limit(10);
8405        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
8406        assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
8407    }
8408
8409    #[test]
8410    fn autosave_queue_metrics_on_fresh_queue() {
8411        let queue = AutosaveQueue::with_limit(256);
8412        let m = queue.metrics();
8413        assert_eq!(m.pending_mutations, 0);
8414        assert_eq!(m.max_pending_mutations, 256);
8415        assert_eq!(m.coalesced_mutations, 0);
8416        assert_eq!(m.backpressure_events, 0);
8417        assert_eq!(m.flush_started, 0);
8418        assert_eq!(m.flush_succeeded, 0);
8419        assert_eq!(m.flush_failed, 0);
8420        assert_eq!(m.last_flush_batch_size, 0);
8421        assert!(m.last_flush_duration_ms.is_none());
8422        assert!(m.last_flush_trigger.is_none());
8423    }
8424
8425    // --- All three mutation kinds ---
8426
8427    #[test]
8428    fn autosave_queue_all_mutation_kinds() {
8429        let mut queue = AutosaveQueue::with_limit(10);
8430        queue.enqueue_mutation(AutosaveMutationKind::Message);
8431        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8432        queue.enqueue_mutation(AutosaveMutationKind::Label);
8433        assert_eq!(queue.pending_mutations, 3);
8434        // First mutation has no coalescing; subsequent two do.
8435        assert_eq!(queue.coalesced_mutations, 2);
8436    }
8437
8438    // --- Multiple consecutive flushes with mixed outcomes ---
8439
8440    #[test]
8441    fn autosave_queue_consecutive_success_flushes() {
8442        let mut queue = AutosaveQueue::with_limit(5);
8443
8444        for round in 1..=3_u64 {
8445            queue.enqueue_mutation(AutosaveMutationKind::Message);
8446            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8447            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8448            queue.finish_flush(ticket, true);
8449            assert_eq!(queue.pending_mutations, 0);
8450            assert_eq!(queue.flush_succeeded, round);
8451            assert_eq!(queue.flush_started, round);
8452            assert_eq!(queue.last_flush_batch_size, 2);
8453        }
8454        assert_eq!(queue.flush_failed, 0);
8455    }
8456
8457    #[test]
8458    fn autosave_queue_alternating_success_failure() {
8459        let mut queue = AutosaveQueue::with_limit(10);
8460
8461        // Round 1: success
8462        queue.enqueue_mutation(AutosaveMutationKind::Message);
8463        let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8464        queue.finish_flush(t1, true);
8465        assert_eq!(queue.flush_succeeded, 1);
8466        assert_eq!(queue.flush_failed, 0);
8467        assert_eq!(queue.pending_mutations, 0);
8468
8469        // Round 2: failure (mutations restored)
8470        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8471        queue.enqueue_mutation(AutosaveMutationKind::Label);
8472        let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8473        queue.finish_flush(t2, false);
8474        assert_eq!(queue.flush_succeeded, 1);
8475        assert_eq!(queue.flush_failed, 1);
8476        assert_eq!(queue.pending_mutations, 2, "restored from failure");
8477
8478        // Round 3: success (clears the restored mutations)
8479        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
8480        assert_eq!(t3.batch_size, 2);
8481        queue.finish_flush(t3, true);
8482        assert_eq!(queue.flush_succeeded, 2);
8483        assert_eq!(queue.flush_failed, 1);
8484        assert_eq!(queue.pending_mutations, 0);
8485        assert_eq!(queue.flush_started, 3);
8486    }
8487
8488    // --- Failure when queue is completely full (zero capacity to restore) ---
8489
8490    #[test]
8491    fn autosave_queue_failure_drops_all_when_full() {
8492        let mut queue = AutosaveQueue::with_limit(3);
8493
8494        // Fill to capacity and flush.
8495        for _ in 0..3 {
8496            queue.enqueue_mutation(AutosaveMutationKind::Message);
8497        }
8498        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8499        assert_eq!(ticket.batch_size, 3);
8500        assert_eq!(queue.pending_mutations, 0);
8501
8502        // Fill queue completely while flush is in flight.
8503        for _ in 0..3 {
8504            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8505        }
8506        assert_eq!(queue.pending_mutations, 3);
8507
8508        // Flush fails — no capacity to restore, all 3 batch mutations are dropped.
8509        let bp_before = queue.backpressure_events;
8510        queue.finish_flush(ticket, false);
8511        assert_eq!(queue.pending_mutations, 3, "capped at max");
8512        assert_eq!(queue.flush_failed, 1);
8513        assert_eq!(
8514            queue.backpressure_events,
8515            bp_before + 3,
8516            "dropped mutations counted as backpressure"
8517        );
8518    }
8519
8520    // --- Flush trigger tracking ---
8521
8522    #[test]
8523    fn autosave_queue_tracks_trigger_across_flushes() {
8524        let mut queue = AutosaveQueue::with_limit(10);
8525
8526        // Manual trigger.
8527        queue.enqueue_mutation(AutosaveMutationKind::Message);
8528        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8529        assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
8530        queue.finish_flush(t1, true);
8531        assert_eq!(
8532            queue.metrics().last_flush_trigger,
8533            Some(AutosaveFlushTrigger::Manual)
8534        );
8535
8536        // Periodic trigger.
8537        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8538        let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8539        queue.finish_flush(t2, true);
8540        assert_eq!(
8541            queue.metrics().last_flush_trigger,
8542            Some(AutosaveFlushTrigger::Periodic)
8543        );
8544
8545        // Shutdown trigger.
8546        queue.enqueue_mutation(AutosaveMutationKind::Label);
8547        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
8548        queue.finish_flush(t3, true);
8549        assert_eq!(
8550            queue.metrics().last_flush_trigger,
8551            Some(AutosaveFlushTrigger::Shutdown)
8552        );
8553    }
8554
8555    // --- Flush records duration ---
8556
8557    #[test]
8558    fn autosave_queue_flush_records_duration() {
8559        let mut queue = AutosaveQueue::with_limit(10);
8560        queue.enqueue_mutation(AutosaveMutationKind::Message);
8561        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8562        queue.finish_flush(ticket, true);
8563        // Duration should be recorded (>= 0ms).
8564        assert!(queue.metrics().last_flush_duration_ms.is_some());
8565    }
8566
8567    // --- Rapid enqueue-flush cycles ---
8568
8569    #[test]
8570    fn autosave_queue_rapid_single_mutation_flushes() {
8571        let mut queue = AutosaveQueue::with_limit(10);
8572        let rounds = 20;
8573
8574        for _ in 0..rounds {
8575            queue.enqueue_mutation(AutosaveMutationKind::Message);
8576            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8577            queue.finish_flush(ticket, true);
8578        }
8579
8580        let m = queue.metrics();
8581        assert_eq!(m.flush_started, rounds);
8582        assert_eq!(m.flush_succeeded, rounds);
8583        assert_eq!(m.flush_failed, 0);
8584        assert_eq!(m.pending_mutations, 0);
8585        assert_eq!(m.last_flush_batch_size, 1);
8586    }
8587
8588    // --- Saturating counter behavior under heavy load ---
8589
8590    #[test]
8591    fn autosave_queue_many_backpressure_events_accumulate() {
8592        let mut queue = AutosaveQueue::with_limit(1);
8593        let excess: u64 = 100;
8594
8595        // First enqueue goes into the queue; rest are backpressure.
8596        for _ in 0..=excess {
8597            queue.enqueue_mutation(AutosaveMutationKind::Message);
8598        }
8599        assert_eq!(queue.pending_mutations, 1);
8600        assert_eq!(queue.backpressure_events, excess);
8601    }
8602
8603    // --- Durability mode: as_str roundtrip ---
8604
8605    #[test]
8606    fn autosave_durability_mode_as_str_roundtrip() {
8607        for mode in [
8608            AutosaveDurabilityMode::Strict,
8609            AutosaveDurabilityMode::Balanced,
8610            AutosaveDurabilityMode::Throughput,
8611        ] {
8612            let s = mode.as_str();
8613            let parsed = AutosaveDurabilityMode::parse(s);
8614            assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
8615        }
8616    }
8617
8618    // --- Durability mode: should_flush/best_effort truth table ---
8619
8620    #[test]
8621    fn autosave_durability_mode_shutdown_behavior_truth_table() {
8622        assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
8623        assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
8624
8625        assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
8626        assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
8627
8628        assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
8629        assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
8630    }
8631
8632    // --- Durability mode: case-insensitive parsing ---
8633
8634    #[test]
8635    fn autosave_durability_mode_parse_case_insensitive() {
8636        assert_eq!(
8637            AutosaveDurabilityMode::parse("STRICT"),
8638            Some(AutosaveDurabilityMode::Strict)
8639        );
8640        assert_eq!(
8641            AutosaveDurabilityMode::parse("Balanced"),
8642            Some(AutosaveDurabilityMode::Balanced)
8643        );
8644        assert_eq!(
8645            AutosaveDurabilityMode::parse("tHrOuGhPuT"),
8646            Some(AutosaveDurabilityMode::Throughput)
8647        );
8648    }
8649
8650    // --- Durability mode: whitespace trimming ---
8651
8652    #[test]
8653    fn autosave_durability_mode_parse_trims_whitespace() {
8654        assert_eq!(
8655            AutosaveDurabilityMode::parse("  strict  "),
8656            Some(AutosaveDurabilityMode::Strict)
8657        );
8658        assert_eq!(
8659            AutosaveDurabilityMode::parse("\tbalanced\n"),
8660            Some(AutosaveDurabilityMode::Balanced)
8661        );
8662    }
8663
8664    // --- Session-level: save on empty queue is no-op ---
8665
8666    #[test]
8667    fn autosave_session_save_on_empty_queue_is_noop() {
8668        let temp_dir = tempfile::tempdir().unwrap();
8669        let mut session = Session::create();
8670        session.session_dir = Some(temp_dir.path().to_path_buf());
8671
8672        // Save without any mutations — should succeed and not change metrics.
8673        let m_before = session.autosave_metrics();
8674        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
8675        let m_after = session.autosave_metrics();
8676
8677        assert_eq!(m_before.flush_started, m_after.flush_started);
8678        assert_eq!(m_after.pending_mutations, 0);
8679    }
8680
8681    // --- Session-level: mode change mid-session ---
8682
8683    #[test]
8684    fn autosave_session_mode_change_mid_session() {
8685        let mut session = Session::create();
8686        assert_eq!(
8687            session.autosave_durability_mode(),
8688            AutosaveDurabilityMode::Balanced,
8689            "default is balanced"
8690        );
8691
8692        session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
8693        assert_eq!(
8694            session.autosave_durability_mode(),
8695            AutosaveDurabilityMode::Strict
8696        );
8697
8698        session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
8699        assert_eq!(
8700            session.autosave_durability_mode(),
8701            AutosaveDurabilityMode::Throughput
8702        );
8703    }
8704
8705    // --- Session-level: all mutation types enqueue correctly ---
8706
8707    #[test]
8708    fn autosave_session_all_mutation_types_enqueue() {
8709        let mut session = Session::create();
8710
8711        let first_entry_id = session.append_message(make_test_message("msg"));
8712        assert_eq!(session.autosave_metrics().pending_mutations, 1);
8713
8714        session.append_model_change("prov".to_string(), "model".to_string());
8715        assert_eq!(session.autosave_metrics().pending_mutations, 2);
8716
8717        session.append_thinking_level_change("high".to_string());
8718        assert_eq!(session.autosave_metrics().pending_mutations, 3);
8719
8720        session.append_session_info(Some("test-session".to_string()));
8721        assert_eq!(session.autosave_metrics().pending_mutations, 4);
8722
8723        session.append_custom_entry("custom".to_string(), None);
8724        assert_eq!(session.autosave_metrics().pending_mutations, 5);
8725
8726        // Label mutation (needs existing entry to target).
8727        session.add_label(&first_entry_id, Some("test-label".to_string()));
8728        assert_eq!(session.autosave_metrics().pending_mutations, 6);
8729    }
8730
8731    // --- Session-level: flush then verify metrics ---
8732
8733    #[test]
8734    fn autosave_session_manual_save_resets_pending() {
8735        let temp_dir = tempfile::tempdir().unwrap();
8736        let mut session = Session::create();
8737        session.session_dir = Some(temp_dir.path().to_path_buf());
8738
8739        session.append_message(make_test_message("a"));
8740        session.append_message(make_test_message("b"));
8741        session.append_message(make_test_message("c"));
8742        assert_eq!(session.autosave_metrics().pending_mutations, 3);
8743
8744        run_async(async { session.save().await }).unwrap();
8745
8746        let m = session.autosave_metrics();
8747        assert_eq!(m.pending_mutations, 0);
8748        assert_eq!(m.flush_succeeded, 1);
8749        assert_eq!(m.last_flush_batch_size, 3);
8750        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
8751    }
8752
8753    // --- Session-level: periodic flush trigger tracking ---
8754
8755    #[test]
8756    fn autosave_session_periodic_flush_tracks_trigger() {
8757        let temp_dir = tempfile::tempdir().unwrap();
8758        let mut session = Session::create();
8759        session.session_dir = Some(temp_dir.path().to_path_buf());
8760
8761        session.append_message(make_test_message("periodic msg"));
8762        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
8763
8764        let m = session.autosave_metrics();
8765        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
8766        assert_eq!(m.flush_succeeded, 1);
8767    }
8768
8769    // --- Session-level: shutdown flush with balanced mode success ---
8770
8771    #[test]
8772    fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
8773        let temp_dir = tempfile::tempdir().unwrap();
8774        let mut session = Session::create();
8775        session.session_dir = Some(temp_dir.path().to_path_buf());
8776        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
8777
8778        session.append_message(make_test_message("balanced ok"));
8779        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
8780
8781        let m = session.autosave_metrics();
8782        assert_eq!(m.flush_succeeded, 1);
8783        assert_eq!(m.pending_mutations, 0);
8784        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
8785    }
8786
8787    // --- Queue: partial restoration on failure with various fill levels ---
8788
8789    #[test]
8790    fn autosave_queue_failure_partial_restoration() {
8791        let mut queue = AutosaveQueue::with_limit(5);
8792
8793        // Fill to 4 and flush (batch=4).
8794        for _ in 0..4 {
8795            queue.enqueue_mutation(AutosaveMutationKind::Message);
8796        }
8797        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8798        assert_eq!(ticket.batch_size, 4);
8799
8800        // Add 2 while flush is in flight.
8801        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8802        queue.enqueue_mutation(AutosaveMutationKind::Label);
8803        assert_eq!(queue.pending_mutations, 2);
8804
8805        // Fail: available_capacity = 5 - 2 = 3, restored = min(4,3) = 3, dropped = 1.
8806        let bp_before = queue.backpressure_events;
8807        let coal_before = queue.coalesced_mutations;
8808        queue.finish_flush(ticket, false);
8809        assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
8810        assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
8811        assert_eq!(
8812            queue.coalesced_mutations,
8813            coal_before + 1,
8814            "1 dropped coalesced"
8815        );
8816    }
8817
8818    // --- Queue: success flush does not restore ---
8819
8820    #[test]
8821    fn autosave_queue_success_does_not_restore_pending() {
8822        let mut queue = AutosaveQueue::with_limit(10);
8823
8824        queue.enqueue_mutation(AutosaveMutationKind::Message);
8825        queue.enqueue_mutation(AutosaveMutationKind::Message);
8826        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8827
8828        // Add 1 mutation while flush is in flight.
8829        queue.enqueue_mutation(AutosaveMutationKind::Label);
8830        assert_eq!(queue.pending_mutations, 1);
8831
8832        // Success: only the in-flight mutation remains.
8833        queue.finish_flush(ticket, true);
8834        assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
8835        assert_eq!(queue.flush_succeeded, 1);
8836    }
8837
8838    // --- Queue: large batch size tracking ---
8839
8840    #[test]
8841    fn autosave_queue_large_batch_tracking() {
8842        let mut queue = AutosaveQueue::with_limit(500);
8843
8844        for _ in 0..200 {
8845            queue.enqueue_mutation(AutosaveMutationKind::Message);
8846        }
8847
8848        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8849        assert_eq!(ticket.batch_size, 200);
8850        queue.finish_flush(ticket, true);
8851
8852        let m = queue.metrics();
8853        assert_eq!(m.last_flush_batch_size, 200);
8854        assert_eq!(m.flush_succeeded, 1);
8855        assert_eq!(m.pending_mutations, 0);
8856    }
8857
8858    // --- Durability resolve: all invalid falls through to default ---
8859
8860    #[test]
8861    fn autosave_resolve_all_invalid_returns_balanced() {
8862        assert_eq!(
8863            resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
8864            AutosaveDurabilityMode::Balanced
8865        );
8866    }
8867
8868    // --- Session-level: metrics accumulate across many save/flush cycles ---
8869
8870    #[test]
8871    fn autosave_session_metrics_accumulate_over_many_cycles() {
8872        let temp_dir = tempfile::tempdir().unwrap();
8873        let mut session = Session::create();
8874        session.session_dir = Some(temp_dir.path().to_path_buf());
8875
8876        let cycles: u64 = 10;
8877        for i in 0..cycles {
8878            session.append_message(make_test_message(&format!("cycle-{i}")));
8879            run_async(async { session.save().await }).unwrap();
8880        }
8881
8882        let m = session.autosave_metrics();
8883        assert_eq!(m.flush_started, cycles);
8884        assert_eq!(m.flush_succeeded, cycles);
8885        assert_eq!(m.flush_failed, 0);
8886        assert_eq!(m.pending_mutations, 0);
8887        assert_eq!(m.last_flush_batch_size, 1);
8888    }
8889
8890    // --- Queue: coalesced count is cumulative (not per-flush) ---
8891
8892    #[test]
8893    fn autosave_queue_coalesced_is_cumulative() {
8894        let mut queue = AutosaveQueue::with_limit(10);
8895
8896        // Batch 1: 3 mutations => 2 coalesced.
8897        queue.enqueue_mutation(AutosaveMutationKind::Message);
8898        queue.enqueue_mutation(AutosaveMutationKind::Message);
8899        queue.enqueue_mutation(AutosaveMutationKind::Message);
8900        assert_eq!(queue.coalesced_mutations, 2);
8901
8902        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8903        queue.finish_flush(t1, true);
8904
8905        // Batch 2: 2 mutations => 1 more coalesced (total 3).
8906        queue.enqueue_mutation(AutosaveMutationKind::Label);
8907        queue.enqueue_mutation(AutosaveMutationKind::Label);
8908        assert_eq!(queue.coalesced_mutations, 3);
8909    }
8910
8911    // --- Session-level: autosave_queue_limit changes batch size behavior ---
8912
8913    #[test]
8914    fn autosave_session_respects_queue_limit() {
8915        let temp_dir = tempfile::tempdir().unwrap();
8916        let mut session = Session::create();
8917        session.session_dir = Some(temp_dir.path().to_path_buf());
8918        session.set_autosave_queue_limit_for_test(3);
8919
8920        for i in 0..10 {
8921            session.append_message(make_test_message(&format!("lim-{i}")));
8922        }
8923
8924        let m = session.autosave_metrics();
8925        assert_eq!(m.pending_mutations, 3);
8926        assert_eq!(m.max_pending_mutations, 3);
8927        assert_eq!(m.backpressure_events, 7);
8928
8929        // Flush should only capture 3 (the capped count).
8930        run_async(async { session.save().await }).unwrap();
8931        let m = session.autosave_metrics();
8932        assert_eq!(m.last_flush_batch_size, 3);
8933        assert_eq!(m.pending_mutations, 0);
8934    }
8935
8936    // --- Session-level: throughput mode shutdown with successful prior manual save ---
8937
8938    #[test]
8939    fn autosave_session_throughput_shutdown_skips_after_manual_save() {
8940        let temp_dir = tempfile::tempdir().unwrap();
8941        let mut session = Session::create();
8942        session.session_dir = Some(temp_dir.path().to_path_buf());
8943        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
8944
8945        session.append_message(make_test_message("saved"));
8946        run_async(async { session.save().await }).unwrap();
8947        assert_eq!(session.autosave_metrics().flush_succeeded, 1);
8948
8949        // Add more mutations but don't save.
8950        session.append_message(make_test_message("unsaved"));
8951        assert_eq!(session.autosave_metrics().pending_mutations, 1);
8952
8953        // Shutdown skips flush in throughput mode.
8954        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
8955        assert_eq!(
8956            session.autosave_metrics().pending_mutations,
8957            1,
8958            "unsaved mutation remains"
8959        );
8960        assert_eq!(
8961            session.autosave_metrics().flush_succeeded,
8962            1,
8963            "no new flush"
8964        );
8965    }
8966
8967    // --- Queue: begin_flush atomically clears pending ---
8968
8969    #[test]
8970    fn autosave_queue_begin_flush_is_atomic_clear() {
8971        let mut queue = AutosaveQueue::with_limit(10);
8972
8973        queue.enqueue_mutation(AutosaveMutationKind::Message);
8974        queue.enqueue_mutation(AutosaveMutationKind::Message);
8975        queue.enqueue_mutation(AutosaveMutationKind::Message);
8976        assert_eq!(queue.pending_mutations, 3);
8977
8978        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8979
8980        // Pending is immediately 0, even before finish_flush.
8981        assert_eq!(queue.pending_mutations, 0);
8982        assert_eq!(ticket.batch_size, 3);
8983
8984        // New mutations start fresh.
8985        queue.enqueue_mutation(AutosaveMutationKind::Label);
8986        assert_eq!(queue.pending_mutations, 1);
8987
8988        queue.finish_flush(ticket, true);
8989        assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
8990    }
8991
8992    // --- Queue: multiple failures accumulate flush_failed ---
8993
8994    #[test]
8995    fn autosave_queue_multiple_failures_accumulate() {
8996        let mut queue = AutosaveQueue::with_limit(10);
8997
8998        // Each round: enqueue 1 new + restored from prior failure.
8999        // Round 1: enqueue → pending=1, flush fails → restore 1 → pending=1
9000        // Round 2: enqueue → pending=2, flush fails → restore 2 → pending=2
9001        // Round N: pending grows by 1 each round because failures restore.
9002        for round in 1..=5_u64 {
9003            queue.enqueue_mutation(AutosaveMutationKind::Message);
9004            #[allow(clippy::cast_possible_truncation)]
9005            let expected_batch = round as usize;
9006            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
9007            assert_eq!(ticket.batch_size, expected_batch);
9008            queue.finish_flush(ticket, false);
9009            assert_eq!(queue.flush_failed, round);
9010            assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
9011        }
9012        assert_eq!(queue.flush_succeeded, 0);
9013        assert_eq!(queue.flush_started, 5);
9014    }
9015
9016    // --- ExportSnapshot and non-blocking export ---
9017
9018    #[test]
9019    fn export_snapshot_captures_header_and_entries() {
9020        let mut session = Session::create();
9021        session.append_message(make_test_message("hello world"));
9022        session.append_message(make_test_message("second message"));
9023
9024        let snapshot = session.export_snapshot();
9025        assert_eq!(snapshot.header.id, session.header.id);
9026        assert_eq!(snapshot.header.timestamp, session.header.timestamp);
9027        assert_eq!(snapshot.header.cwd, session.header.cwd);
9028        assert_eq!(snapshot.entries.len(), session.entries.len());
9029        assert_eq!(snapshot.path, session.path);
9030    }
9031
9032    #[test]
9033    fn export_snapshot_does_not_include_internal_caches() {
9034        let mut session = Session::create();
9035        for i in 0..10 {
9036            session.append_message(make_test_message(&format!("msg {i}")));
9037        }
9038        // The snapshot should be lighter than a full Session clone because
9039        // it skips autosave_queue, entry_index, entry_ids, and other caches.
9040        let snapshot = session.export_snapshot();
9041        assert_eq!(snapshot.entries.len(), 10);
9042        // Verify the snapshot is a distinct copy (not sharing references).
9043        assert_eq!(snapshot.header.id, session.header.id);
9044    }
9045
9046    #[test]
9047    fn export_snapshot_html_matches_session_html() {
9048        let mut session = Session::create();
9049        session.append_message(make_test_message("hello"));
9050        session.append_message(make_test_message("world"));
9051
9052        let session_html = session.to_html();
9053        let snapshot_html = session.export_snapshot().to_html();
9054        assert_eq!(session_html, snapshot_html);
9055    }
9056
9057    #[test]
9058    fn export_snapshot_empty_session() {
9059        let session = Session::create();
9060        let snapshot = session.export_snapshot();
9061        assert!(snapshot.entries.is_empty());
9062        let html = snapshot.to_html();
9063        assert!(html.contains("Pi Session"));
9064        assert!(html.contains("</html>"));
9065    }
9066
9067    #[test]
9068    fn render_session_html_contains_header_info() {
9069        let mut session = Session::create();
9070        session.header.id = "test-session-id-xyz".to_string();
9071        session.header.cwd = "/test/cwd/path".to_string();
9072
9073        let html = render_session_html(&session.header, &session.entries);
9074        assert!(html.contains("test-session-id-xyz"));
9075        assert!(html.contains("/test/cwd/path"));
9076    }
9077
9078    #[test]
9079    fn render_session_html_renders_all_entry_types() {
9080        let mut session = Session::create();
9081
9082        // Message entry.
9083        session.append_message(make_test_message("user text here"));
9084
9085        // Model change entry.
9086        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
9087
9088        // Thinking level change entry.
9089        session.entries.push(SessionEntry::ThinkingLevelChange(
9090            ThinkingLevelChangeEntry {
9091                base: EntryBase::new(None, "tlc1".to_string()),
9092                thinking_level: "high".to_string(),
9093            },
9094        ));
9095
9096        let html = render_session_html(&session.header, &session.entries);
9097        assert!(html.contains("user text here"));
9098        assert!(html.contains("anthropic"));
9099        assert!(html.contains("claude-sonnet-4-5"));
9100        assert!(html.contains("high"));
9101    }
9102
9103    #[test]
9104    fn export_snapshot_with_path() {
9105        let mut session = Session::create();
9106        session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
9107        session.append_message(make_test_message("msg"));
9108
9109        let snapshot = session.export_snapshot();
9110        assert_eq!(
9111            snapshot.path.as_deref(),
9112            Some(Path::new("/tmp/my-session.jsonl"))
9113        );
9114    }
9115
9116    #[test]
9117    fn fork_plan_snapshot_consistency() {
9118        let mut session = Session::create();
9119        let msg1 = make_test_message("first message");
9120        session.append_message(msg1);
9121        let msg1_id = session.entries[0].base_id().unwrap().clone();
9122
9123        let msg2 = make_test_message("second message");
9124        session.append_message(msg2);
9125        let msg2_id = session.entries[1].base_id().unwrap().clone();
9126
9127        // Plan fork from the second message.
9128        let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
9129
9130        // Fork plan entries should include the path up to the parent.
9131        assert_eq!(plan.leaf_id, Some(msg1_id));
9132        // The plan captures a snapshot of entries — modifying session shouldn't affect plan.
9133        let plan_entry_count = plan.entries.len();
9134        session.append_message(make_test_message("third message"));
9135        assert_eq!(plan.entries.len(), plan_entry_count);
9136    }
9137}