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::provider_metadata::{canonical_provider_id, provider_ids_match};
16use crate::session_index::{
17    SessionIndex, SessionIndexRefreshSummary, enqueue_session_index_snapshot_update,
18    is_session_file_path, session_file_stats,
19};
20use crate::session_store_v2::{self, SessionStoreV2};
21use crate::tui::PiConsole;
22use asupersync::channel::oneshot;
23use asupersync::sync::Mutex;
24use async_trait::async_trait;
25use fs4::fs_std::FileExt;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use sha2::{Digest, Sha256};
29use std::collections::{HashMap, HashSet};
30use std::fmt::Write as _;
31use std::io::{BufReader, IsTerminal, Read, Write};
32use std::path::{Path, PathBuf};
33use std::sync::atomic::{AtomicUsize, Ordering};
34use std::sync::{Arc, OnceLock};
35use std::thread;
36use std::time::Instant;
37#[cfg(test)]
38use std::time::{SystemTime, UNIX_EPOCH};
39use tracing::warn;
40
41/// Current session file format version.
42pub const SESSION_VERSION: u8 = 3;
43const MAX_JSONL_LINE_BYTES: usize = 100 * 1024 * 1024;
44const V2_CHAIN_HASH_GENESIS: &str =
45    "0000000000000000000000000000000000000000000000000000000000000000";
46const ROOT_LEAF_OVERRIDE_SENTINEL: &str = "";
47
48fn finish_worker_result<T, E>(
49    handle: thread::JoinHandle<()>,
50    recv_result: std::result::Result<Result<T>, E>,
51    cancelled_message: &'static str,
52) -> Result<T> {
53    if let Err(panic_payload) = handle.join() {
54        std::panic::resume_unwind(panic_payload);
55    }
56    recv_result.map_err(|_| crate::Error::session(cancelled_message))?
57}
58
59fn read_capped_utf8_line_with_limit<R: std::io::BufRead>(
60    reader: &mut R,
61    max_bytes: usize,
62) -> std::io::Result<Option<String>> {
63    use std::io::BufRead;
64
65    let limit = u64::try_from(max_bytes)
66        .unwrap_or(u64::MAX.saturating_sub(2))
67        .saturating_add(2);
68    let mut bytes = Vec::new();
69    let bytes_read = reader.take(limit).read_until(b'\n', &mut bytes)?;
70    if bytes_read == 0 {
71        return Ok(None);
72    }
73
74    let content_len = bytes.strip_suffix(b"\n").map_or(bytes.len(), <[u8]>::len);
75    if content_len > max_bytes {
76        if !bytes.ends_with(b"\n") {
77            let mut discard = Vec::new();
78            loop {
79                discard.clear();
80                let discarded = reader.read_until(b'\n', &mut discard)?;
81                if discarded == 0 || discard.ends_with(b"\n") {
82                    break;
83                }
84            }
85        }
86        return Err(std::io::Error::new(
87            std::io::ErrorKind::InvalidData,
88            format!("JSONL line exceeds {max_bytes} bytes"),
89        ));
90    }
91
92    String::from_utf8(bytes)
93        .map(Some)
94        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
95}
96
97fn read_capped_utf8_line<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<Option<String>> {
98    read_capped_utf8_line_with_limit(reader, MAX_JSONL_LINE_BYTES)
99}
100
101#[cfg(unix)]
102fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
103    let Some(parent) = path.parent() else {
104        return Ok(());
105    };
106    std::fs::File::open(parent)?.sync_all()
107}
108
109#[cfg(not(unix))]
110fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
111    Ok(())
112}
113
114fn save_jsonl_full_rewrite_blocking(
115    path: &Path,
116    sessions_root: &Path,
117    header: &SessionHeader,
118    entries: &[SessionEntry],
119    persisted_entry_count: usize,
120    header_dirty: bool,
121) -> Result<(SessionHeader, Vec<SessionEntry>)> {
122    let _lock = lock_session_persistence(path)?;
123    let (header_to_write, entries_to_write) =
124        prepare_jsonl_full_rewrite(path, header, entries, persisted_entry_count, header_dirty)?;
125    let original_perms = std::fs::metadata(path).ok().map(|meta| meta.permissions());
126    let parent = path.parent().unwrap_or_else(|| Path::new("."));
127    let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
128    {
129        let mut writer = std::io::BufWriter::with_capacity(1 << 20, temp_file.as_file());
130        serde_json::to_writer(&mut writer, &header_to_write)?;
131        writer.write_all(b"\n")?;
132        for entry in &entries_to_write {
133            serde_json::to_writer(&mut writer, entry)?;
134            writer.write_all(b"\n")?;
135        }
136        writer.flush()?;
137    }
138    temp_file
139        .as_file_mut()
140        .sync_all()
141        .map_err(|e| crate::Error::Io(Box::new(e)))?;
142    if let Some(perms) = original_perms {
143        temp_file
144            .as_file()
145            .set_permissions(perms)
146            .map_err(|e| crate::Error::Io(Box::new(e)))?;
147    }
148    temp_file
149        .persist(path)
150        .map_err(|e| crate::Error::Io(Box::new(e.error)))?;
151    sync_parent_dir(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
152    let mut entries_for_stats = entries_to_write.clone();
153    let finalized = finalize_loaded_entries(&mut entries_for_stats);
154    let message_count = finalized.message_count;
155    let session_name = finalized.name;
156    enqueue_session_index_snapshot_update(
157        sessions_root,
158        path,
159        &header_to_write,
160        message_count,
161        session_name,
162    );
163    Ok((header_to_write, entries_to_write))
164}
165
166fn append_jsonl_entries_blocking(
167    path: &Path,
168    sessions_root: &Path,
169    header: &SessionHeader,
170    serialized_entries: &[u8],
171    message_count: u64,
172    session_name: Option<String>,
173) -> Result<()> {
174    let _lock = lock_session_persistence(path)?;
175    let mut file = std::fs::OpenOptions::new()
176        .append(true)
177        .open(path)
178        .map_err(|e| crate::Error::Io(Box::new(e)))?;
179    file.write_all(serialized_entries)?;
180    file.sync_all().map_err(|e| crate::Error::Io(Box::new(e)))?;
181
182    enqueue_session_index_snapshot_update(sessions_root, path, header, message_count, session_name);
183    Ok(())
184}
185
186fn session_persistence_lock_path(path: &Path) -> PathBuf {
187    let mut lock_path = path.as_os_str().to_os_string();
188    lock_path.push(".lock");
189    PathBuf::from(lock_path)
190}
191
192fn lock_session_persistence(path: &Path) -> Result<SessionPersistenceLockGuard> {
193    let lock_path = session_persistence_lock_path(path);
194    let file = std::fs::OpenOptions::new()
195        .read(true)
196        .write(true)
197        .create(true)
198        .truncate(false)
199        .open(&lock_path)
200        .map_err(|e| crate::Error::Io(Box::new(e)))?;
201    file.lock_exclusive()?;
202    Ok(SessionPersistenceLockGuard { file })
203}
204
205#[derive(Debug)]
206struct SessionPersistenceLockGuard {
207    file: std::fs::File,
208}
209
210impl Drop for SessionPersistenceLockGuard {
211    fn drop(&mut self) {
212        let _ = FileExt::unlock(&self.file);
213    }
214}
215
216fn prepare_jsonl_full_rewrite(
217    path: &Path,
218    header: &SessionHeader,
219    entries: &[SessionEntry],
220    persisted_entry_count: usize,
221    header_dirty: bool,
222) -> Result<(SessionHeader, Vec<SessionEntry>)> {
223    let pending_start = persisted_entry_count.min(entries.len());
224    let mut merged_entries = entries[..pending_start].to_vec();
225    let local_pending = &entries[pending_start..];
226    let mut header_to_write = header.clone();
227
228    if path
229        .try_exists()
230        .map_err(|e| crate::Error::Io(Box::new(e)))?
231    {
232        let (disk_session, _) = open_jsonl_blocking(path.to_path_buf())?;
233        if !header_dirty {
234            header_to_write = disk_session.header;
235        }
236
237        let known_ids: HashSet<&str> = entries
238            .iter()
239            .filter_map(|entry| entry.base_id().map(String::as_str))
240            .collect();
241
242        for disk_entry in disk_session.entries.into_iter().skip(pending_start) {
243            let should_merge = disk_entry
244                .base_id()
245                .is_none_or(|id| !known_ids.contains(id.as_str()));
246            if should_merge {
247                merged_entries.push(disk_entry);
248            }
249        }
250    }
251
252    merged_entries.extend_from_slice(local_pending);
253    Ok((header_to_write, merged_entries))
254}
255
256fn resolve_loaded_leaf_id(
257    header: &SessionHeader,
258    natural_leaf_id: Option<String>,
259    entry_index: &HashMap<String, usize>,
260) -> Option<String> {
261    match header.current_leaf.as_deref() {
262        Some(ROOT_LEAF_OVERRIDE_SENTINEL) => None,
263        Some(leaf_id) if entry_index.contains_key(leaf_id) => Some(leaf_id.to_string()),
264        _ => natural_leaf_id,
265    }
266}
267
268fn normalize_loaded_header(mut header: SessionHeader) -> (SessionHeader, bool) {
269    let header_dirty = header.materialize_branch_fallbacks();
270    (header, header_dirty)
271}
272
273fn total_v2_message_count(store: &SessionStoreV2) -> Result<Option<u64>> {
274    if let Some(manifest) = store.read_manifest()? {
275        return Ok(Some(manifest.counters.messages_total));
276    }
277
278    let mut total = 0u64;
279    for frame in store.read_all_entries()? {
280        if frame.entry_type.eq("message") {
281            total = total.saturating_add(1);
282        }
283    }
284    Ok(Some(total))
285}
286
287/// Handle to a thread-safe shared session.
288#[derive(Clone, Debug)]
289pub struct SessionHandle(pub Arc<Mutex<Session>>);
290
291fn current_path_model_pair(session: &Session) -> Option<(String, String)> {
292    session.effective_model_for_current_path()
293}
294
295fn current_path_model_fields(session: &Session) -> (Option<String>, Option<String>) {
296    if let Some((provider, model_id)) = current_path_model_pair(session) {
297        (Some(provider), Some(model_id))
298    } else {
299        session.header.branch_fallback_model_fields()
300    }
301}
302
303fn current_path_thinking_level(session: &Session) -> Option<String> {
304    session.effective_thinking_level_for_current_path()
305}
306
307#[async_trait]
308impl ExtensionSession for SessionHandle {
309    async fn get_state(&self) -> Value {
310        let cx = AgentCx::for_current_or_request();
311        let Ok(session) = self.0.lock(cx.cx()).await else {
312            return serde_json::json!({
313                "model": null,
314                "thinkingLevel": "off",
315                "durabilityMode": "balanced",
316                "isStreaming": false,
317                "isCompacting": false,
318                "steeringMode": "one-at-a-time",
319                "followUpMode": "one-at-a-time",
320                "sessionFile": null,
321                "sessionId": "",
322                "sessionName": null,
323                "autoCompactionEnabled": false,
324                "messageCount": 0,
325                "pendingMessageCount": 0,
326            });
327        };
328        let session_file = session.path.as_ref().map(|p| p.display().to_string());
329        let session_id = session.header.id.clone();
330        let session_name = session.get_name();
331        let model =
332            current_path_model_pair(&session).map_or(Value::Null, |(provider, model_id)| {
333                serde_json::json!({
334                    "provider": provider,
335                    "id": model_id,
336                })
337            });
338        let thinking_level =
339            current_path_thinking_level(&session).unwrap_or_else(|| "off".to_string());
340        let message_count = session
341            .entries_for_current_path()
342            .iter()
343            .filter(|entry| matches!(entry, SessionEntry::Message(_)))
344            .count();
345        let pending_message_count = session.autosave_metrics().pending_mutations;
346        let durability_mode = session.autosave_durability_mode().as_str();
347        serde_json::json!({
348            "model": model,
349            "thinkingLevel": thinking_level,
350            "durabilityMode": durability_mode,
351            "isStreaming": false,
352            "isCompacting": false,
353            "steeringMode": "one-at-a-time",
354            "followUpMode": "one-at-a-time",
355            "sessionFile": session_file,
356            "sessionId": session_id,
357            "sessionName": session_name,
358            "autoCompactionEnabled": false,
359            "messageCount": message_count,
360            "pendingMessageCount": pending_message_count,
361        })
362    }
363
364    async fn get_messages(&self) -> Vec<SessionMessage> {
365        let cx = AgentCx::for_current_or_request();
366        let Ok(session) = self.0.lock(cx.cx()).await else {
367            return Vec::new();
368        };
369        // Return messages for the current branch only, filtered to
370        // user/assistant/toolResult/bashExecution/custom per spec §3.3.
371        session
372            .entries_for_current_path()
373            .iter()
374            .filter_map(|entry| match entry {
375                SessionEntry::Message(msg) => match msg.message {
376                    SessionMessage::User { .. }
377                    | SessionMessage::Assistant { .. }
378                    | SessionMessage::ToolResult { .. }
379                    | SessionMessage::BashExecution { .. }
380                    | SessionMessage::Custom { .. } => Some(msg.message.clone()),
381                    _ => None,
382                },
383                _ => None,
384            })
385            .collect()
386    }
387
388    async fn get_entries(&self) -> Vec<Value> {
389        let cx = AgentCx::for_current_or_request();
390        let Ok(session) = self.0.lock(cx.cx()).await else {
391            return Vec::new();
392        };
393        session
394            .entries
395            .iter()
396            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
397            .collect()
398    }
399
400    async fn get_branch(&self) -> Vec<Value> {
401        let cx = AgentCx::for_current_or_request();
402        let Ok(session) = self.0.lock(cx.cx()).await else {
403            return Vec::new();
404        };
405        session
406            .entries_for_current_path()
407            .iter()
408            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
409            .collect()
410    }
411
412    async fn set_name(&self, name: String) -> Result<()> {
413        let cx = AgentCx::for_current_or_request();
414        let mut session = self
415            .0
416            .lock(cx.cx())
417            .await
418            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
419        #[cfg(test)]
420        emit_set_name_deadline_probe(&session.header.id, cx.budget().deadline);
421        session.set_name(&name);
422        Ok(())
423    }
424
425    async fn append_message(&self, message: SessionMessage) -> Result<()> {
426        let cx = AgentCx::for_current_or_request();
427        let mut session = self
428            .0
429            .lock(cx.cx())
430            .await
431            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
432        session.append_message(message);
433        Ok(())
434    }
435
436    async fn append_custom_entry(&self, custom_type: String, data: Option<Value>) -> Result<()> {
437        let cx = AgentCx::for_current_or_request();
438        let mut session = self
439            .0
440            .lock(cx.cx())
441            .await
442            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
443        if custom_type.trim().is_empty() {
444            return Err(Error::validation("customType must not be empty"));
445        }
446        session.append_custom_entry(custom_type, data);
447        Ok(())
448    }
449
450    async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
451        let cx = AgentCx::for_current_or_request();
452        let mut session = self
453            .0
454            .lock(cx.cx())
455            .await
456            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
457        let normalized_provider = canonical_provider_id(&provider)
458            .unwrap_or(&provider)
459            .to_string();
460        let (stored_provider, stored_model_id, changed) = match current_path_model_pair(&session) {
461            Some((current_provider, current_model_id))
462                if provider_ids_match(&current_provider, &provider)
463                    && current_model_id.eq_ignore_ascii_case(&model_id) =>
464            {
465                (current_provider, current_model_id, false)
466            }
467            _ => (normalized_provider, model_id.clone(), true),
468        };
469        if changed {
470            session.append_model_change(stored_provider.clone(), stored_model_id.clone());
471        }
472        session.set_model_header(Some(stored_provider), Some(stored_model_id), None);
473        Ok(())
474    }
475
476    async fn get_model(&self) -> (Option<String>, Option<String>) {
477        let cx = AgentCx::for_current_or_request();
478        let Ok(session) = self.0.lock(cx.cx()).await else {
479            return (None, None);
480        };
481        current_path_model_fields(&session)
482    }
483
484    async fn set_thinking_level(&self, level: String) -> Result<()> {
485        let cx = AgentCx::for_current_or_request();
486        let mut session = self
487            .0
488            .lock(cx.cx())
489            .await
490            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
491        let changed = !current_path_thinking_level(&session)
492            .as_deref()
493            .is_some_and(|current| current.eq(level.as_str()));
494        if changed {
495            session.append_thinking_level_change(level.clone());
496        }
497        session.set_model_header(None, None, Some(level));
498        Ok(())
499    }
500
501    async fn get_thinking_level(&self) -> Option<String> {
502        let cx = AgentCx::for_current_or_request();
503        let Ok(session) = self.0.lock(cx.cx()).await else {
504            return None;
505        };
506        current_path_thinking_level(&session)
507    }
508
509    async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
510        let cx = AgentCx::for_current_or_request();
511        let mut session = self
512            .0
513            .lock(cx.cx())
514            .await
515            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
516        if session.add_label(&target_id, label).is_none() {
517            return Err(Error::validation(format!(
518                "target entry '{target_id}' not found in session"
519            )));
520        }
521        Ok(())
522    }
523}
524
525/// Default base URL for the Pi session share viewer.
526pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://buildwithpi.ai/session/";
527
528fn build_share_viewer_url(base_url: Option<&str>, gist_id: &str) -> String {
529    let base_url = base_url
530        .filter(|value| !value.is_empty())
531        .unwrap_or(DEFAULT_SHARE_VIEWER_URL);
532    format!("{base_url}#{gist_id}")
533}
534
535/// Get the share viewer URL for a gist ID.
536///
537/// Matches legacy Pi Agent semantics:
538/// - Use `PI_SHARE_VIEWER_URL` env var when set and non-empty
539/// - Otherwise fall back to `DEFAULT_SHARE_VIEWER_URL`
540/// - Final URL is `{base}#{gist_id}` (no trailing-slash normalization)
541#[must_use]
542pub fn get_share_viewer_url(gist_id: &str) -> String {
543    let base_url = std::env::var("PI_SHARE_VIEWER_URL").ok();
544    build_share_viewer_url(base_url.as_deref(), gist_id)
545}
546
547/// Session persistence backend.
548#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum SessionStoreKind {
550    Jsonl,
551    #[cfg(feature = "sqlite-sessions")]
552    Sqlite,
553}
554
555impl SessionStoreKind {
556    fn from_config(config: &Config) -> Self {
557        let Some(value) = config.session_store.as_deref() else {
558            return Self::Jsonl;
559        };
560
561        if value.eq_ignore_ascii_case("jsonl") {
562            return Self::Jsonl;
563        }
564
565        if value.eq_ignore_ascii_case("sqlite") {
566            #[cfg(feature = "sqlite-sessions")]
567            {
568                return Self::Sqlite;
569            }
570
571            #[cfg(not(feature = "sqlite-sessions"))]
572            {
573                tracing::warn!(
574                    "Config requests session_store=sqlite but binary lacks `sqlite-sessions`; falling back to jsonl"
575                );
576                return Self::Jsonl;
577            }
578        }
579
580        tracing::warn!("Unknown session_store `{value}`, falling back to jsonl");
581        Self::Jsonl
582    }
583
584    const fn extension(self) -> &'static str {
585        match self {
586            Self::Jsonl => "jsonl",
587            #[cfg(feature = "sqlite-sessions")]
588            Self::Sqlite => "sqlite",
589        }
590    }
591}
592
593/// Default upper bound for queued autosave mutations before backpressure coalescing kicks in.
594const DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS: usize = 256;
595
596fn autosave_max_pending_mutations() -> usize {
597    std::env::var("PI_SESSION_AUTOSAVE_MAX_PENDING")
598        .ok()
599        .and_then(|raw| raw.parse::<usize>().ok())
600        .filter(|value| *value > 0)
601        .unwrap_or(DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS)
602}
603
604/// Default number of incremental appends before forcing a full checkpoint rewrite.
605const DEFAULT_COMPACTION_CHECKPOINT_INTERVAL: u64 = 50;
606
607fn compaction_checkpoint_interval() -> u64 {
608    std::env::var("PI_SESSION_COMPACTION_INTERVAL")
609        .ok()
610        .and_then(|raw| raw.parse::<u64>().ok())
611        .filter(|value| *value > 0)
612        .unwrap_or(DEFAULT_COMPACTION_CHECKPOINT_INTERVAL)
613}
614
615/// Durability mode for write-behind autosave behavior.
616#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum AutosaveDurabilityMode {
618    Strict,
619    Balanced,
620    Throughput,
621}
622
623impl AutosaveDurabilityMode {
624    fn parse(raw: &str) -> Option<Self> {
625        match raw.trim().to_ascii_lowercase().as_str() {
626            "strict" => Some(Self::Strict),
627            "balanced" => Some(Self::Balanced),
628            "throughput" => Some(Self::Throughput),
629            _ => None,
630        }
631    }
632
633    fn from_env() -> Self {
634        std::env::var("PI_SESSION_DURABILITY_MODE")
635            .ok()
636            .as_deref()
637            .and_then(Self::parse)
638            .unwrap_or(Self::Balanced)
639    }
640
641    const fn should_flush_on_shutdown(self) -> bool {
642        matches!(self, Self::Strict | Self::Balanced)
643    }
644
645    const fn best_effort_on_shutdown(self) -> bool {
646        matches!(self, Self::Balanced)
647    }
648
649    pub const fn as_str(self) -> &'static str {
650        match self {
651            Self::Strict => "strict",
652            Self::Balanced => "balanced",
653            Self::Throughput => "throughput",
654        }
655    }
656}
657
658fn resolve_autosave_durability_mode(
659    cli_mode: Option<&str>,
660    config_mode: Option<&str>,
661    env_mode: Option<&str>,
662) -> AutosaveDurabilityMode {
663    cli_mode
664        .and_then(AutosaveDurabilityMode::parse)
665        .or_else(|| config_mode.and_then(AutosaveDurabilityMode::parse))
666        .or_else(|| env_mode.and_then(AutosaveDurabilityMode::parse))
667        .unwrap_or(AutosaveDurabilityMode::Balanced)
668}
669
670/// Autosave flush trigger used for observability.
671#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub enum AutosaveFlushTrigger {
673    Manual,
674    Periodic,
675    Shutdown,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum AutosaveMutationKind {
680    Message,
681    Metadata,
682    Label,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686struct AutosaveFlushTicket {
687    batch_size: usize,
688    started_at: Instant,
689    trigger: AutosaveFlushTrigger,
690}
691
692/// Snapshot of autosave queue state and lifecycle counters.
693#[derive(Debug, Clone, Copy, Default)]
694pub struct AutosaveQueueMetrics {
695    pub pending_mutations: usize,
696    pub max_pending_mutations: usize,
697    pub coalesced_mutations: u64,
698    pub backpressure_events: u64,
699    pub flush_started: u64,
700    pub flush_succeeded: u64,
701    pub flush_failed: u64,
702    pub last_flush_batch_size: usize,
703    pub last_flush_duration_ms: Option<u64>,
704    pub last_flush_trigger: Option<AutosaveFlushTrigger>,
705}
706
707#[derive(Debug, Clone)]
708struct AutosaveQueue {
709    pending_mutations: usize,
710    max_pending_mutations: usize,
711    coalesced_mutations: u64,
712    backpressure_events: u64,
713    flush_started: u64,
714    flush_succeeded: u64,
715    flush_failed: u64,
716    last_flush_batch_size: usize,
717    last_flush_duration_ms: Option<u64>,
718    last_flush_trigger: Option<AutosaveFlushTrigger>,
719}
720
721impl AutosaveQueue {
722    fn new() -> Self {
723        Self {
724            pending_mutations: 0,
725            max_pending_mutations: autosave_max_pending_mutations(),
726            coalesced_mutations: 0,
727            backpressure_events: 0,
728            flush_started: 0,
729            flush_succeeded: 0,
730            flush_failed: 0,
731            last_flush_batch_size: 0,
732            last_flush_duration_ms: None,
733            last_flush_trigger: None,
734        }
735    }
736
737    #[cfg(test)]
738    fn with_limit(max_pending_mutations: usize) -> Self {
739        let mut queue = Self::new();
740        queue.max_pending_mutations = max_pending_mutations.max(1);
741        queue
742    }
743
744    const fn metrics(&self) -> AutosaveQueueMetrics {
745        AutosaveQueueMetrics {
746            pending_mutations: self.pending_mutations,
747            max_pending_mutations: self.max_pending_mutations,
748            coalesced_mutations: self.coalesced_mutations,
749            backpressure_events: self.backpressure_events,
750            flush_started: self.flush_started,
751            flush_succeeded: self.flush_succeeded,
752            flush_failed: self.flush_failed,
753            last_flush_batch_size: self.last_flush_batch_size,
754            last_flush_duration_ms: self.last_flush_duration_ms,
755            last_flush_trigger: self.last_flush_trigger,
756        }
757    }
758
759    const fn enqueue_mutation(&mut self, _kind: AutosaveMutationKind) {
760        if self.pending_mutations == 0 {
761            self.pending_mutations = 1;
762            return;
763        }
764        self.coalesced_mutations = self.coalesced_mutations.saturating_add(1);
765        if self.pending_mutations < self.max_pending_mutations {
766            self.pending_mutations += 1;
767        } else {
768            self.backpressure_events = self.backpressure_events.saturating_add(1);
769        }
770    }
771
772    fn begin_flush(&mut self, trigger: AutosaveFlushTrigger) -> Option<AutosaveFlushTicket> {
773        if self.pending_mutations == 0 {
774            return None;
775        }
776        let batch_size = self.pending_mutations;
777        self.pending_mutations = 0;
778        self.flush_started = self.flush_started.saturating_add(1);
779        self.last_flush_batch_size = batch_size;
780        self.last_flush_trigger = Some(trigger);
781        Some(AutosaveFlushTicket {
782            batch_size,
783            started_at: Instant::now(),
784            trigger,
785        })
786    }
787
788    fn finish_flush(&mut self, ticket: AutosaveFlushTicket, success: bool) {
789        let elapsed = ticket.started_at.elapsed().as_millis();
790        let elapsed = u64::try_from(elapsed.min(u128::from(u64::MAX)))
791            .expect("elapsed milliseconds clamped to u64::MAX");
792        self.last_flush_duration_ms = Some(elapsed);
793        self.last_flush_trigger = Some(ticket.trigger);
794        if success {
795            self.flush_succeeded = self.flush_succeeded.saturating_add(1);
796            return;
797        }
798
799        self.flush_failed = self.flush_failed.saturating_add(1);
800        // New mutations may have arrived while the flush was in flight.
801        // Restore only into remaining capacity so pending count never exceeds
802        // `max_pending_mutations`.
803        let available_capacity = self
804            .max_pending_mutations
805            .saturating_sub(self.pending_mutations);
806        let restored = ticket.batch_size.min(available_capacity);
807        self.pending_mutations = self.pending_mutations.saturating_add(restored);
808        let dropped = ticket.batch_size.saturating_sub(restored);
809        if dropped > 0 {
810            let dropped = dropped as u64;
811            self.backpressure_events = self.backpressure_events.saturating_add(dropped);
812            self.coalesced_mutations = self.coalesced_mutations.saturating_add(dropped);
813        }
814    }
815}
816
817// ============================================================================
818// Session
819// ============================================================================
820
821/// A session manages conversation state and persistence.
822#[derive(Debug)]
823#[allow(clippy::struct_excessive_bools)]
824pub struct Session {
825    /// Session header
826    pub header: SessionHeader,
827    /// Session entries (messages, changes, etc.)
828    pub entries: Vec<SessionEntry>,
829    /// Path to the session file (None for in-memory)
830    pub path: Option<PathBuf>,
831    /// Current leaf entry ID. Direct modification outside of `session.rs`
832    /// is forbidden because it can desynchronize the `is_linear` optimization cache.
833    pub(crate) leaf_id: Option<String>,
834    /// Base directory for session storage (optional override)
835    pub session_dir: Option<PathBuf>,
836    store_kind: SessionStoreKind,
837    /// Cached entry IDs for O(1) uniqueness checks when appending.
838    entry_ids: HashSet<String>,
839
840    // -- Performance caches (Gaps A/B/C) --
841    /// True when all entries form a linear chain (no branching).
842    /// When true, `entries_for_current_path()` returns all entries without
843    /// building a parent map — the 99% fast path.
844    is_linear: bool,
845    /// Map from entry ID to index in `self.entries` for O(1) lookup.
846    entry_index: HashMap<String, usize>,
847    /// Incrementally maintained message count (avoids O(n) scan on save).
848    cached_message_count: u64,
849    /// Most recent session name from `SessionInfo` entries.
850    cached_name: Option<String>,
851    /// Write-behind autosave queue state and lifecycle counters.
852    autosave_queue: AutosaveQueue,
853    /// Current durability policy for shutdown final flush behavior.
854    autosave_durability: AutosaveDurabilityMode,
855
856    // -- Incremental append state --
857    /// Number of entries already persisted to disk (high-water mark).
858    /// Uses `Arc<AtomicUsize>` to allow atomic updates from detached background threads,
859    /// ensuring state consistency even if the save future is dropped/cancelled.
860    persisted_entry_count: Arc<AtomicUsize>,
861    /// True when header was modified since last save (forces full rewrite).
862    header_dirty: bool,
863    /// Incremental appends since last full rewrite (checkpoint counter).
864    appends_since_checkpoint: u64,
865    /// Sidecar root when session was loaded from V2 storage.
866    v2_sidecar_root: Option<PathBuf>,
867    /// True when current in-memory entries are a partial hydration view from V2.
868    v2_partial_hydration: bool,
869    /// Resume mode used when loading from V2 sidecar.
870    v2_resume_mode: Option<V2OpenMode>,
871    /// True when the JSONL file has advanced beyond the loaded V2 sidecar.
872    v2_sidecar_stale: bool,
873    /// Offset to add to `cached_message_count` to account for messages not loaded in memory
874    /// (e.g. when using V2 tail hydration).
875    v2_message_count_offset: u64,
876}
877
878impl Clone for Session {
879    fn clone(&self) -> Self {
880        Self {
881            header: self.header.clone(),
882            entries: self.entries.clone(),
883            path: self.path.clone(),
884            leaf_id: self.leaf_id.clone(),
885            session_dir: self.session_dir.clone(),
886            store_kind: self.store_kind,
887            entry_ids: self.entry_ids.clone(),
888            is_linear: self.is_linear,
889            entry_index: self.entry_index.clone(),
890            cached_message_count: self.cached_message_count,
891            cached_name: self.cached_name.clone(),
892            autosave_queue: self.autosave_queue.clone(),
893            autosave_durability: self.autosave_durability,
894            // Deep copy the atomic value to preserve value semantics for clones.
895            // If we just cloned the Arc, a save on the clone would increment the
896            // counter on the original, desynchronizing it from its own entries.
897            persisted_entry_count: Arc::new(AtomicUsize::new(
898                self.persisted_entry_count.load(Ordering::SeqCst),
899            )),
900            header_dirty: self.header_dirty,
901            appends_since_checkpoint: self.appends_since_checkpoint,
902            v2_sidecar_root: self.v2_sidecar_root.clone(),
903            v2_partial_hydration: self.v2_partial_hydration,
904            v2_resume_mode: self.v2_resume_mode,
905            v2_sidecar_stale: self.v2_sidecar_stale,
906            v2_message_count_offset: self.v2_message_count_offset,
907        }
908    }
909}
910
911/// Result of planning a `/fork` operation from a specific user message.
912///
913/// Mirrors legacy semantics:
914/// - The new session's leaf is the *parent* of the selected user message (or `None` if root),
915///   so the selected message can be re-submitted as a new branch without creating consecutive
916///   user messages.
917/// - The selected user message text is returned for editor pre-fill.
918#[derive(Debug, Clone)]
919pub struct ForkPlan {
920    /// Entries to copy into the new session file (path to the fork leaf, inclusive).
921    pub entries: Vec<SessionEntry>,
922    /// Leaf ID to set in the new session (parent of selected user entry).
923    pub leaf_id: Option<String>,
924    /// Text of the selected user message (for editor pre-fill).
925    pub selected_text: String,
926}
927
928/// Lightweight snapshot of session data for non-blocking export.
929///
930/// Captures only the header and entries needed for HTML rendering,
931/// avoiding a full `Session` clone (which includes caches, autosave
932/// queue, and other internal state).
933#[derive(Debug, Clone)]
934pub struct ExportSnapshot {
935    /// Session header (id, timestamp, cwd).
936    pub header: SessionHeader,
937    /// Session entries to render.
938    pub entries: Vec<SessionEntry>,
939    /// Session file path (for default output filename).
940    pub path: Option<PathBuf>,
941}
942
943impl ExportSnapshot {
944    /// Render this snapshot as a standalone HTML document.
945    ///
946    /// Delegates to the shared rendering logic used by `Session::to_html()`.
947    pub fn to_html(&self) -> String {
948        render_session_html(&self.header, &self.entries)
949    }
950}
951
952/// Diagnostics captured while opening a session file.
953#[derive(Debug, Clone, Default)]
954pub struct SessionOpenDiagnostics {
955    pub skipped_entries: Vec<SessionOpenSkippedEntry>,
956    pub orphaned_parent_links: Vec<SessionOpenOrphanedParentLink>,
957}
958
959#[derive(Debug, Clone)]
960pub struct SessionOpenSkippedEntry {
961    /// 1-based line number in the session file.
962    pub line_number: usize,
963    pub error: String,
964}
965
966#[derive(Debug, Clone)]
967pub struct SessionOpenOrphanedParentLink {
968    pub entry_id: String,
969    pub missing_parent_id: String,
970}
971
972/// Stable schema identifier for session cold-start trace bundles.
973pub const SESSION_COLD_START_TRACE_SCHEMA: &str = "pi.session.cold_start_trace.v1";
974pub const SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA: &str =
975    "pi.session.replay_minimization_trace.v1";
976
977/// Bounded, redacted trace bundle for diagnosing large-session startup latency.
978#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
979pub struct SessionColdStartTraceBundle {
980    pub schema: String,
981    pub session_path_hash: String,
982    pub storage: SessionColdStartStorageTrace,
983    pub input: SessionColdStartInputTrace,
984    pub phases: Vec<SessionColdStartPhaseTrace>,
985    pub index_refresh: SessionColdStartIndexRefreshTrace,
986    pub open_diagnostics: SessionColdStartOpenDiagnosticsTrace,
987    pub replay_minimization: SessionReplayMinimizationTrace,
988    pub compaction_scan: SessionColdStartCompactionTrace,
989    pub first_render: SessionColdStartFirstRenderTrace,
990    pub bounds: SessionColdStartBoundsTrace,
991    pub total_elapsed_us: u64,
992}
993
994/// Storage backend selection observed during cold-start tracing.
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
996pub struct SessionColdStartStorageTrace {
997    pub selected_backend: String,
998    pub opened_backend: String,
999    pub path_extension: String,
1000    pub sqlite_feature_enabled: bool,
1001    pub v2_sidecar_present: bool,
1002    pub v2_sidecar_stale: bool,
1003    pub fallback_reason: Option<String>,
1004}
1005
1006/// Aggregate input shape for the loaded session, without raw content or paths.
1007#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1008pub struct SessionColdStartInputTrace {
1009    pub total_entries: usize,
1010    pub total_messages: u64,
1011}
1012
1013/// Timed cold-start phase.
1014#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1015pub struct SessionColdStartPhaseTrace {
1016    pub name: String,
1017    pub elapsed_us: u64,
1018    pub status: String,
1019}
1020
1021/// Incremental session-index refresh summary.
1022#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1023pub struct SessionColdStartIndexRefreshTrace {
1024    pub scanned_files: usize,
1025    pub cache_hit_files: usize,
1026    pub reused_files: usize,
1027    pub refreshed_files: usize,
1028    pub pruned_rows: usize,
1029    pub failed_files: usize,
1030}
1031
1032/// Redacted session-open diagnostics.
1033#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1034pub struct SessionColdStartOpenDiagnosticsTrace {
1035    pub skipped_entries: usize,
1036    pub orphaned_parent_links: usize,
1037}
1038
1039/// Bounded replay-minimization evidence for branch-heavy session resumes.
1040#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1041pub struct SessionReplayMinimizationTrace {
1042    pub schema: String,
1043    pub branch_count: usize,
1044    pub entry_count: usize,
1045    pub selected_depth: usize,
1046    pub scanned_files: usize,
1047    pub replayed_entries: usize,
1048    pub skipped_sibling_entries: usize,
1049    pub deterministic_steps: usize,
1050    pub fallback_behavior: Option<String>,
1051    pub verdict: String,
1052}
1053
1054/// Bounded compaction scan summary for the current session path.
1055#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1056pub struct SessionColdStartCompactionTrace {
1057    pub scanned_entries: usize,
1058    pub compaction_entries: usize,
1059    pub latest_compaction_present: bool,
1060    pub latest_compaction_index_from_end: Option<usize>,
1061    pub first_kept_entry_found: Option<bool>,
1062}
1063
1064/// First-render readiness projection without message text.
1065#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066pub struct SessionColdStartFirstRenderTrace {
1067    pub current_path_entries: usize,
1068    pub projected_messages: usize,
1069    pub user_messages: usize,
1070    pub assistant_messages: usize,
1071    pub tool_messages: usize,
1072    pub system_messages: usize,
1073    pub input_tokens: u64,
1074    pub output_tokens: u64,
1075    pub cache_read_tokens: u64,
1076    pub cache_write_tokens: u64,
1077    pub total_tokens: u64,
1078    pub ready: bool,
1079}
1080
1081/// Explicit redaction/bounding contract for cold-start trace bundles.
1082#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1083pub struct SessionColdStartBoundsTrace {
1084    pub max_phase_count: usize,
1085    pub raw_path_included: bool,
1086    pub raw_cwd_included: bool,
1087    pub raw_message_content_included: bool,
1088}
1089
1090/// Loading strategy for reconstructing a `Session` from a V2 store.
1091#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1092pub enum V2OpenMode {
1093    Full,
1094    ActivePath,
1095    Tail(u64),
1096}
1097
1098const DEFAULT_V2_LAZY_HYDRATION_THRESHOLD: u64 = 10_000;
1099const DEFAULT_V2_TAIL_HYDRATION_COUNT: u64 = 256;
1100
1101fn parse_v2_open_mode(raw: &str) -> Option<V2OpenMode> {
1102    let normalized = raw.trim().to_ascii_lowercase();
1103    if normalized.is_empty() {
1104        return None;
1105    }
1106    match normalized.as_str() {
1107        "full" => Some(V2OpenMode::Full),
1108        "active" | "active_path" | "active-path" => Some(V2OpenMode::ActivePath),
1109        "tail" => Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT)),
1110        _ => normalized
1111            .strip_prefix("tail:")
1112            .and_then(|value| value.parse::<u64>().ok().map(V2OpenMode::Tail)),
1113    }
1114}
1115
1116fn resolve_v2_lazy_hydration_threshold(env_raw: Option<&str>) -> u64 {
1117    env_raw
1118        .and_then(|raw| raw.trim().parse::<u64>().ok())
1119        .unwrap_or(DEFAULT_V2_LAZY_HYDRATION_THRESHOLD)
1120}
1121
1122fn select_v2_open_mode_for_resume(
1123    entry_count: u64,
1124    mode_override_raw: Option<&str>,
1125    threshold_override_raw: Option<&str>,
1126) -> (V2OpenMode, &'static str, u64) {
1127    let lazy_threshold = resolve_v2_lazy_hydration_threshold(threshold_override_raw);
1128    if let Some(raw) = mode_override_raw {
1129        if let Some(mode) = parse_v2_open_mode(raw) {
1130            return (mode, "env_override", lazy_threshold);
1131        }
1132    }
1133
1134    if lazy_threshold > 0 && entry_count > lazy_threshold {
1135        return (
1136            V2OpenMode::ActivePath,
1137            "entry_count_above_lazy_threshold",
1138            lazy_threshold,
1139        );
1140    }
1141
1142    (V2OpenMode::Full, "default_full", lazy_threshold)
1143}
1144
1145impl SessionOpenDiagnostics {
1146    fn warning_lines(&self) -> Vec<String> {
1147        let mut lines = Vec::new();
1148        for skipped in &self.skipped_entries {
1149            lines.push(format!(
1150                "Warning: Skipping corrupted entry at line {} in session file: {}",
1151                skipped.line_number, skipped.error
1152            ));
1153        }
1154
1155        if !self.skipped_entries.is_empty() {
1156            lines.push(format!(
1157                "Warning: Skipped {} corrupted entries while loading session",
1158                self.skipped_entries.len()
1159            ));
1160        }
1161
1162        for orphan in &self.orphaned_parent_links {
1163            lines.push(format!(
1164                "Warning: Entry {} references missing parent {}",
1165                orphan.entry_id, orphan.missing_parent_id
1166            ));
1167        }
1168
1169        if !self.orphaned_parent_links.is_empty() {
1170            lines.push(format!(
1171                "Warning: Detected {} orphaned parent links while loading session",
1172                self.orphaned_parent_links.len()
1173            ));
1174        }
1175
1176        lines
1177    }
1178}
1179
1180impl SessionColdStartTraceBundle {
1181    /// Emit a stable, bounded logging event for the trace bundle.
1182    pub fn emit_log(&self) {
1183        tracing::info!(
1184            schema = self.schema.as_str(),
1185            session_path_hash = self.session_path_hash.as_str(),
1186            selected_backend = self.storage.selected_backend.as_str(),
1187            opened_backend = self.storage.opened_backend.as_str(),
1188            total_entries = self.input.total_entries,
1189            total_messages = self.input.total_messages,
1190            phase_count = self.phases.len(),
1191            open_skipped_entries = self.open_diagnostics.skipped_entries,
1192            open_orphaned_parent_links = self.open_diagnostics.orphaned_parent_links,
1193            index_cache_hit_files = self.index_refresh.cache_hit_files,
1194            replay_branch_count = self.replay_minimization.branch_count,
1195            replay_entry_count = self.replay_minimization.entry_count,
1196            replay_selected_depth = self.replay_minimization.selected_depth,
1197            replay_skipped_sibling_entries = self.replay_minimization.skipped_sibling_entries,
1198            replay_verdict = self.replay_minimization.verdict.as_str(),
1199            first_render_projected_messages = self.first_render.projected_messages,
1200            total_elapsed_us = self.total_elapsed_us,
1201            "session cold-start trace bundle"
1202        );
1203    }
1204}
1205
1206fn elapsed_us_since(start: Instant) -> u64 {
1207    u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX)
1208}
1209
1210fn cold_start_hash_path(path: &Path) -> String {
1211    let mut digest = format!("{:x}", Sha256::digest(path.to_string_lossy().as_bytes()));
1212    digest.truncate(16);
1213    digest
1214}
1215
1216fn session_cold_start_storage_trace(path: &Path) -> SessionColdStartStorageTrace {
1217    let path_extension = path
1218        .extension()
1219        .and_then(|ext| ext.to_str())
1220        .unwrap_or("none")
1221        .to_string();
1222    let sqlite_feature_enabled = cfg!(feature = "sqlite-sessions");
1223    let v2_sidecar_present = session_store_v2::has_v2_sidecar(path);
1224    let v2_sidecar_stale = if v2_sidecar_present {
1225        let v2_root = session_store_v2::v2_sidecar_path(path);
1226        is_v2_sidecar_stale(path, &v2_root)
1227    } else {
1228        false
1229    };
1230
1231    let (selected_backend, fallback_reason) = if matches!(path_extension.as_str(), "sqlite") {
1232        if sqlite_feature_enabled {
1233            ("sqlite", None)
1234        } else {
1235            (
1236                "sqlite_unavailable",
1237                Some("sqlite_sessions_feature_disabled".to_string()),
1238            )
1239        }
1240    } else if v2_sidecar_present && !v2_sidecar_stale {
1241        ("v2_sidecar", None)
1242    } else if v2_sidecar_present {
1243        ("jsonl", Some("v2_sidecar_stale".to_string()))
1244    } else {
1245        ("jsonl", None)
1246    };
1247
1248    SessionColdStartStorageTrace {
1249        selected_backend: selected_backend.to_string(),
1250        opened_backend: "not_opened".to_string(),
1251        path_extension,
1252        sqlite_feature_enabled,
1253        v2_sidecar_present,
1254        v2_sidecar_stale,
1255        fallback_reason,
1256    }
1257}
1258
1259impl Session {
1260    /// Create a new session from CLI args and config.
1261    pub async fn new(cli: &Cli, config: &Config) -> Result<Self> {
1262        let session_dir = cli.session_dir.as_ref().map(PathBuf::from);
1263        let durability_mode = resolve_autosave_durability_mode(
1264            cli.session_durability.as_deref(),
1265            config.session_durability.as_deref(),
1266            std::env::var("PI_SESSION_DURABILITY_MODE").ok().as_deref(),
1267        );
1268        if cli.no_session {
1269            let mut session = Self::in_memory();
1270            session.set_autosave_durability_mode(durability_mode);
1271            return Ok(session);
1272        }
1273
1274        if let Some(path) = &cli.session {
1275            let mut session = Self::open(path).await?;
1276            session.session_dir = session_dir
1277                .clone()
1278                .or_else(|| infer_session_root_from_path(Path::new(path)));
1279            session.set_autosave_durability_mode(durability_mode);
1280            return Ok(session);
1281        }
1282
1283        if cli.resume {
1284            let picker_input_override = config
1285                .session_picker_input
1286                .filter(|value| *value > 0)
1287                .map(|value| value.to_string());
1288            let mut session = Box::pin(Self::resume_with_picker(
1289                session_dir.as_deref(),
1290                config,
1291                picker_input_override,
1292            ))
1293            .await?;
1294            session.set_autosave_durability_mode(durability_mode);
1295            return Ok(session);
1296        }
1297
1298        if cli.r#continue {
1299            let mut session = Self::continue_recent_in_dir(session_dir.as_deref(), config).await?;
1300            session.set_autosave_durability_mode(durability_mode);
1301            return Ok(session);
1302        }
1303
1304        let store_kind = SessionStoreKind::from_config(config);
1305        let mut session = Self::create_with_dir_and_store(session_dir, store_kind);
1306        session.set_autosave_durability_mode(durability_mode);
1307
1308        // Create a new session
1309        Ok(session)
1310    }
1311
1312    /// Resume a session by prompting the user to select from recent sessions.
1313    #[allow(clippy::too_many_lines)]
1314    pub async fn resume_with_picker(
1315        override_dir: Option<&Path>,
1316        config: &Config,
1317        picker_input_override: Option<String>,
1318    ) -> Result<Self> {
1319        let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
1320        let mut picker_input_override = picker_input_override;
1321        // The interactive session picker is part of the TUI front-end. Without
1322        // the `tui` feature there is no terminal picker, so library consumers
1323        // fall through to the non-interactive resolution path below.
1324        #[cfg(feature = "tui")]
1325        if picker_input_override.is_none() && is_interactive {
1326            if let Some(session) = crate::session_picker::pick_session(override_dir).await {
1327                return Ok(session);
1328            }
1329        }
1330
1331        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1332        let store_kind = SessionStoreKind::from_config(config);
1333        let cwd = std::env::current_dir()?;
1334        let encoded_cwd = encode_cwd(&cwd);
1335        let project_session_dir = base_dir.join(&encoded_cwd);
1336        let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
1337
1338        let base_dir_clone = base_dir.clone();
1339        let cwd_display = cwd.display().to_string();
1340        let (tx, mut rx) = oneshot::channel();
1341
1342        let handle = thread::spawn(move || {
1343            let indexed_meta = SessionIndex::for_sessions_root(&base_dir_clone)
1344                .list_sessions(Some(&cwd_display))
1345                .unwrap_or_default();
1346            let cx = AgentCx::for_request();
1347            let _ = tx.send(cx.cx(), Ok(indexed_meta));
1348        });
1349
1350        let cx = AgentCx::for_request();
1351        let recv_result = rx.recv(cx.cx()).await;
1352        let indexed_meta =
1353            finish_worker_result(handle, recv_result, "Session picker index task cancelled")
1354                .unwrap_or_default();
1355        let session_index = SessionIndex::for_sessions_root(&base_dir);
1356        let (entries, missing_paths) = split_indexed_session_entries(indexed_meta);
1357        for path in &missing_paths {
1358            prune_session_index_path(
1359                &session_index,
1360                path,
1361                "Failed to prune missing session from index during picker refresh",
1362            );
1363        }
1364
1365        if project_session_dir_missing {
1366            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1367        }
1368
1369        let scanned = scan_sessions_on_disk(&project_session_dir, entries.clone()).await?;
1370        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1371        for entry in entries {
1372            by_path.insert(entry.path.clone(), entry);
1373        }
1374        for path in &scanned.failed_paths {
1375            prune_session_index_path(
1376                &session_index,
1377                path,
1378                "Failed to prune unreadable session from index during picker refresh",
1379            );
1380            by_path.remove(path);
1381        }
1382        refresh_session_index_entries(
1383            &session_index,
1384            &scanned.refreshed_entries,
1385            "Failed to refresh session metadata in index during picker refresh",
1386        );
1387        merge_scanned_session_entries(&mut by_path, scanned.entries);
1388        let mut entries = by_path.into_values().collect::<Vec<_>>();
1389
1390        if entries.is_empty() {
1391            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1392        }
1393
1394        entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1395        let max_entries = 20usize.min(entries.len());
1396        let mut entries = entries.into_iter().take(max_entries).collect::<Vec<_>>();
1397
1398        let console = PiConsole::new();
1399        console.render_info("Select a session to resume:");
1400
1401        let headers = ["#", "Timestamp", "Messages", "Name", "Path"];
1402
1403        let mut attempts = 0;
1404        loop {
1405            if entries.is_empty() {
1406                console.render_warning("No resumable sessions available. Starting a new session.");
1407                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1408            }
1409
1410            let mut rows: Vec<Vec<String>> = Vec::new();
1411            for (idx, entry) in entries.iter().enumerate() {
1412                rows.push(vec![
1413                    format!("{}", idx + 1),
1414                    entry.timestamp.clone(),
1415                    entry.message_count.to_string(),
1416                    entry.name.clone().unwrap_or_else(|| entry.id.clone()),
1417                    entry.path.display().to_string(),
1418                ]);
1419            }
1420            let row_refs: Vec<Vec<&str>> = rows
1421                .iter()
1422                .map(|row| row.iter().map(String::as_str).collect())
1423                .collect();
1424            console.render_table(&headers, &row_refs);
1425
1426            attempts += 1;
1427            if attempts > 3 {
1428                console.render_warning("No selection made. Starting a new session.");
1429                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1430            }
1431
1432            print!(
1433                "Enter selection (1-{}, blank to start new): ",
1434                entries.len()
1435            );
1436            let _ = std::io::stdout().flush();
1437
1438            let input = if let Some(override_input) = picker_input_override.take() {
1439                override_input
1440            } else {
1441                let mut input = String::new();
1442                std::io::stdin().read_line(&mut input)?;
1443                input
1444            };
1445            let input = input.trim();
1446            if input.is_empty() {
1447                console.render_info("Starting a new session.");
1448                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1449            }
1450
1451            match input.parse::<usize>() {
1452                Ok(selection) if selection > 0 && selection <= entries.len() => {
1453                    let selected = &entries[selection - 1];
1454                    match Self::open(selected.path.to_string_lossy().as_ref()).await {
1455                        Ok(mut session) => {
1456                            session.session_dir = Some(base_dir.clone());
1457                            return Ok(session);
1458                        }
1459                        Err(err) => {
1460                            tracing::warn!(
1461                                path = %selected.path.display(),
1462                                error = %err,
1463                                "Failed to open selected session while resuming"
1464                            );
1465                            prune_session_index_path(
1466                                &session_index,
1467                                &selected.path,
1468                                "Failed to prune unreadable selected session after picker open failure",
1469                            );
1470                            entries.remove(selection - 1);
1471
1472                            if is_interactive {
1473                                console.render_warning(
1474                                    "Selected session could not be opened. Pick another session.",
1475                                );
1476                                continue;
1477                            }
1478
1479                            console.render_warning(
1480                                "Selected session could not be opened. Starting a new session.",
1481                            );
1482                            return Ok(Self::create_with_dir_and_store(
1483                                Some(base_dir.clone()),
1484                                store_kind,
1485                            ));
1486                        }
1487                    }
1488                }
1489                _ => {
1490                    console.render_warning("Invalid selection. Try again.");
1491                }
1492            }
1493        }
1494    }
1495
1496    /// Create an in-memory (ephemeral) session.
1497    pub fn in_memory() -> Self {
1498        Self {
1499            header: SessionHeader::new(),
1500            entries: Vec::new(),
1501            path: None,
1502            leaf_id: None,
1503            session_dir: None,
1504            store_kind: SessionStoreKind::Jsonl,
1505            entry_ids: HashSet::new(),
1506            is_linear: true,
1507            entry_index: HashMap::new(),
1508            cached_message_count: 0,
1509            cached_name: None,
1510            autosave_queue: AutosaveQueue::new(),
1511            autosave_durability: AutosaveDurabilityMode::from_env(),
1512            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1513            header_dirty: false,
1514            appends_since_checkpoint: 0,
1515            v2_sidecar_root: None,
1516            v2_partial_hydration: false,
1517            v2_resume_mode: None,
1518            v2_sidecar_stale: false,
1519            v2_message_count_offset: 0,
1520        }
1521    }
1522
1523    /// Create a new session.
1524    pub fn create() -> Self {
1525        Self::create_with_dir(None)
1526    }
1527
1528    /// Create a new session with an optional base directory override.
1529    pub fn create_with_dir(session_dir: Option<PathBuf>) -> Self {
1530        Self::create_with_dir_and_store(session_dir, SessionStoreKind::Jsonl)
1531    }
1532
1533    pub fn create_with_dir_and_store(
1534        session_dir: Option<PathBuf>,
1535        store_kind: SessionStoreKind,
1536    ) -> Self {
1537        let header = SessionHeader::new();
1538        Self {
1539            header,
1540            entries: Vec::new(),
1541            path: None,
1542            leaf_id: None,
1543            session_dir,
1544            store_kind,
1545            entry_ids: HashSet::new(),
1546            is_linear: true,
1547            entry_index: HashMap::new(),
1548            cached_message_count: 0,
1549            cached_name: None,
1550            autosave_queue: AutosaveQueue::new(),
1551            autosave_durability: AutosaveDurabilityMode::from_env(),
1552            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1553            header_dirty: false,
1554            appends_since_checkpoint: 0,
1555            v2_sidecar_root: None,
1556            v2_partial_hydration: false,
1557            v2_resume_mode: None,
1558            v2_sidecar_stale: false,
1559            v2_message_count_offset: 0,
1560        }
1561    }
1562
1563    /// Open an existing session.
1564    pub async fn open(path: &str) -> Result<Self> {
1565        let (session, diagnostics) = Self::open_with_diagnostics(path).await?;
1566        for warning in diagnostics.warning_lines() {
1567            warn!("{warning}");
1568        }
1569        Ok(session)
1570    }
1571
1572    /// Open an existing session and return diagnostics about any recovered corruption.
1573    pub async fn open_with_diagnostics(path: &str) -> Result<(Self, SessionOpenDiagnostics)> {
1574        Self::open_path_with_diagnostics(PathBuf::from(path)).await
1575    }
1576
1577    /// Build a bounded, redacted trace bundle for session cold-start phases.
1578    pub async fn cold_start_trace_bundle(
1579        path: &Path,
1580        sessions_root: &Path,
1581    ) -> Result<SessionColdStartTraceBundle> {
1582        let total_start = Instant::now();
1583        let mut phases = Vec::with_capacity(4);
1584        let mut storage = session_cold_start_storage_trace(path);
1585
1586        let open_start = Instant::now();
1587        let (session, diagnostics) = Self::open_path_with_diagnostics(path.to_path_buf()).await?;
1588        let open_elapsed_us = elapsed_us_since(open_start);
1589        storage.opened_backend = session.opened_storage_backend_for_trace().to_string();
1590        phases.push(SessionColdStartPhaseTrace {
1591            name: "session_open".to_string(),
1592            elapsed_us: open_elapsed_us,
1593            status: "ok".to_string(),
1594        });
1595
1596        let index_start = Instant::now();
1597        let index_summary = SessionIndex::for_sessions_root(sessions_root).refresh_incremental()?;
1598        phases.push(SessionColdStartPhaseTrace {
1599            name: "session_index_refresh".to_string(),
1600            elapsed_us: elapsed_us_since(index_start),
1601            status: "ok".to_string(),
1602        });
1603        let index_refresh = SessionColdStartIndexRefreshTrace {
1604            scanned_files: index_summary.scanned_files,
1605            cache_hit_files: index_summary.reused_files,
1606            reused_files: index_summary.reused_files,
1607            refreshed_files: index_summary.refreshed_files,
1608            pruned_rows: index_summary.pruned_rows,
1609            failed_files: index_summary.failed_files,
1610        };
1611
1612        let compaction_start = Instant::now();
1613        let compaction_scan = session.cold_start_compaction_scan_trace();
1614        phases.push(SessionColdStartPhaseTrace {
1615            name: "compaction_scan".to_string(),
1616            elapsed_us: elapsed_us_since(compaction_start),
1617            status: "ok".to_string(),
1618        });
1619
1620        let first_render_start = Instant::now();
1621        let first_render = session.cold_start_first_render_trace();
1622        phases.push(SessionColdStartPhaseTrace {
1623            name: "first_render_ready".to_string(),
1624            elapsed_us: elapsed_us_since(first_render_start),
1625            status: "ok".to_string(),
1626        });
1627
1628        let replay_minimization =
1629            session.cold_start_replay_minimization_trace(&storage, &index_summary, &diagnostics);
1630
1631        let bundle = SessionColdStartTraceBundle {
1632            schema: SESSION_COLD_START_TRACE_SCHEMA.to_string(),
1633            session_path_hash: cold_start_hash_path(path),
1634            storage,
1635            input: SessionColdStartInputTrace {
1636                total_entries: session.entries.len(),
1637                total_messages: session.cached_message_count,
1638            },
1639            phases,
1640            index_refresh,
1641            open_diagnostics: SessionColdStartOpenDiagnosticsTrace {
1642                skipped_entries: diagnostics.skipped_entries.len(),
1643                orphaned_parent_links: diagnostics.orphaned_parent_links.len(),
1644            },
1645            replay_minimization,
1646            compaction_scan,
1647            first_render,
1648            bounds: SessionColdStartBoundsTrace {
1649                max_phase_count: 4,
1650                raw_path_included: false,
1651                raw_cwd_included: false,
1652                raw_message_content_included: false,
1653            },
1654            total_elapsed_us: elapsed_us_since(total_start),
1655        };
1656        bundle.emit_log();
1657        Ok(bundle)
1658    }
1659
1660    async fn open_path_with_diagnostics(path: PathBuf) -> Result<(Self, SessionOpenDiagnostics)> {
1661        if !path.exists() {
1662            return Err(crate::Error::SessionNotFound {
1663                path: path.display().to_string(),
1664            });
1665        }
1666
1667        if path
1668            .extension()
1669            .and_then(|ext| ext.to_str())
1670            .is_some_and(|ext| matches!(ext, "sqlite"))
1671        {
1672            #[cfg(feature = "sqlite-sessions")]
1673            {
1674                let session = Self::open_sqlite(&path).await?;
1675                return Ok((session, SessionOpenDiagnostics::default()));
1676            }
1677
1678            #[cfg(not(feature = "sqlite-sessions"))]
1679            {
1680                return Err(Error::session(
1681                    "SQLite session files require building with `--features sqlite-sessions`",
1682                ));
1683            }
1684        }
1685
1686        // Check for V2 sidecar store — enables O(index+tail) resume.
1687        if session_store_v2::has_v2_sidecar(&path) {
1688            let v2_root = session_store_v2::v2_sidecar_path(&path);
1689            let is_stale = is_v2_sidecar_stale(&path, &v2_root);
1690
1691            if is_stale {
1692                tracing::warn!(
1693                    path = %path.display(),
1694                    "V2 sidecar is stale (source JSONL newer); skipping V2 resume"
1695                );
1696            } else {
1697                match Self::open_v2_with_diagnostics(&path).await {
1698                    Ok(result) => return Ok(result),
1699                    Err(e) => {
1700                        tracing::warn!(
1701                            path = %path.display(),
1702                            error = %e,
1703                            "V2 sidecar resume failed, falling back to full JSONL parse"
1704                        );
1705                    }
1706                }
1707            }
1708        }
1709
1710        Self::open_jsonl_with_diagnostics(&path).await
1711    }
1712
1713    const fn opened_storage_backend_for_trace(&self) -> &'static str {
1714        match self.store_kind {
1715            SessionStoreKind::Jsonl => {
1716                if self.v2_sidecar_root.is_some() {
1717                    "v2_sidecar"
1718                } else {
1719                    "jsonl"
1720                }
1721            }
1722            #[cfg(feature = "sqlite-sessions")]
1723            SessionStoreKind::Sqlite => "sqlite",
1724        }
1725    }
1726
1727    fn cold_start_current_path_entries(&self) -> Vec<&SessionEntry> {
1728        if self.leaf_id.is_none() {
1729            return Vec::new();
1730        }
1731        if self.is_linear {
1732            return self.entries.iter().collect();
1733        }
1734        self.entries_for_current_path()
1735    }
1736
1737    fn cold_start_total_entries_and_branch_count(&self) -> (usize, usize) {
1738        let loaded_summary = self.branch_summary();
1739        let mut entry_count = loaded_summary.total_entries;
1740        let mut branch_count = loaded_summary.branch_point_count;
1741
1742        if let Some(v2_root) = self.v2_sidecar_root.as_ref() {
1743            if let Ok(store) = SessionStoreV2::create(v2_root, 64 * 1024 * 1024) {
1744                entry_count =
1745                    entry_count.max(usize::try_from(store.entry_count()).unwrap_or(usize::MAX));
1746                if let Ok(Some(manifest)) = store.read_manifest() {
1747                    branch_count = branch_count.max(
1748                        usize::try_from(manifest.counters.branches_total).unwrap_or(usize::MAX),
1749                    );
1750                    entry_count = entry_count.max(
1751                        usize::try_from(manifest.counters.entries_total).unwrap_or(usize::MAX),
1752                    );
1753                }
1754            }
1755        }
1756
1757        (entry_count, branch_count)
1758    }
1759
1760    fn cold_start_replay_minimization_trace(
1761        &self,
1762        storage: &SessionColdStartStorageTrace,
1763        index_summary: &SessionIndexRefreshSummary,
1764        diagnostics: &SessionOpenDiagnostics,
1765    ) -> SessionReplayMinimizationTrace {
1766        let path_entries = self.cold_start_current_path_entries();
1767        let selected_depth = path_entries.len();
1768        let replayed_entries = path_entries
1769            .iter()
1770            .filter(|entry| {
1771                matches!(
1772                    entry,
1773                    SessionEntry::Message(_)
1774                        | SessionEntry::BranchSummary(_)
1775                        | SessionEntry::Compaction(_)
1776                )
1777            })
1778            .count();
1779        let (entry_count, branch_count) = self.cold_start_total_entries_and_branch_count();
1780        let skipped_sibling_entries = entry_count.saturating_sub(selected_depth);
1781        let deterministic_steps = selected_depth
1782            .saturating_add(index_summary.scanned_files)
1783            .saturating_add(diagnostics.skipped_entries.len())
1784            .saturating_add(diagnostics.orphaned_parent_links.len());
1785        let opened_backend = storage.opened_backend.as_str();
1786        let selected_backend = storage.selected_backend.as_str();
1787        let backend_changed = !matches!(
1788            (opened_backend, selected_backend),
1789            ("jsonl", "jsonl") | ("v2_sidecar", "v2_sidecar")
1790        );
1791
1792        let fallback_behavior = if !diagnostics.orphaned_parent_links.is_empty() {
1793            Some("orphaned_parent_links_detected".to_string())
1794        } else if !diagnostics.skipped_entries.is_empty() {
1795            Some("corrupt_jsonl_entries_skipped".to_string())
1796        } else if backend_changed {
1797            Some(format!(
1798                "{}_fallback_to_{}",
1799                storage.selected_backend, storage.opened_backend
1800            ))
1801        } else if let Some(reason) = storage.fallback_reason.as_ref() {
1802            Some(reason.clone())
1803        } else if matches!(opened_backend, "jsonl") && !storage.v2_sidecar_present {
1804            Some("jsonl_full_scan_without_sidecar".to_string())
1805        } else {
1806            None
1807        };
1808
1809        let verdict = if diagnostics.orphaned_parent_links.is_empty()
1810            && diagnostics.skipped_entries.is_empty()
1811            && skipped_sibling_entries > 0
1812            && fallback_behavior.is_none()
1813        {
1814            "bounded_selected_branch".to_string()
1815        } else if fallback_behavior.is_some() {
1816            "fallback_explicit".to_string()
1817        } else {
1818            "linear_or_single_branch".to_string()
1819        };
1820
1821        SessionReplayMinimizationTrace {
1822            schema: SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA.to_string(),
1823            branch_count,
1824            entry_count,
1825            selected_depth,
1826            scanned_files: index_summary.scanned_files,
1827            replayed_entries,
1828            skipped_sibling_entries,
1829            deterministic_steps,
1830            fallback_behavior,
1831            verdict,
1832        }
1833    }
1834
1835    fn cold_start_compaction_scan_trace(&self) -> SessionColdStartCompactionTrace {
1836        let path_entries = self.cold_start_current_path_entries();
1837        let mut compaction_entries = 0usize;
1838        let mut latest = None;
1839
1840        for (idx, entry) in path_entries.iter().enumerate() {
1841            if let SessionEntry::Compaction(compaction) = entry {
1842                compaction_entries = compaction_entries.saturating_add(1);
1843                latest = Some((idx, compaction.first_kept_entry_id.clone()));
1844            }
1845        }
1846
1847        let (latest_compaction_index_from_end, first_kept_entry_found) =
1848            if let Some((idx, first_kept_entry_id)) = latest {
1849                let found = path_entries.iter().any(|entry| {
1850                    entry
1851                        .base_id()
1852                        .is_some_and(|entry_id| entry_id.eq(&first_kept_entry_id))
1853                });
1854                (
1855                    Some(path_entries.len().saturating_sub(idx.saturating_add(1))),
1856                    Some(found),
1857                )
1858            } else {
1859                (None, None)
1860            };
1861
1862        SessionColdStartCompactionTrace {
1863            scanned_entries: path_entries.len(),
1864            compaction_entries,
1865            latest_compaction_present: latest_compaction_index_from_end.is_some(),
1866            latest_compaction_index_from_end,
1867            first_kept_entry_found,
1868        }
1869    }
1870
1871    fn cold_start_first_render_trace(&self) -> SessionColdStartFirstRenderTrace {
1872        let path_entries = self.cold_start_current_path_entries();
1873        let mut trace = SessionColdStartFirstRenderTrace {
1874            current_path_entries: path_entries.len(),
1875            projected_messages: 0,
1876            user_messages: 0,
1877            assistant_messages: 0,
1878            tool_messages: 0,
1879            system_messages: 0,
1880            input_tokens: 0,
1881            output_tokens: 0,
1882            cache_read_tokens: 0,
1883            cache_write_tokens: 0,
1884            total_tokens: 0,
1885            ready: true,
1886        };
1887
1888        for entry in path_entries {
1889            let SessionEntry::Message(message_entry) = entry else {
1890                continue;
1891            };
1892
1893            match &message_entry.message {
1894                SessionMessage::User { .. } => {
1895                    trace.projected_messages = trace.projected_messages.saturating_add(1);
1896                    trace.user_messages = trace.user_messages.saturating_add(1);
1897                }
1898                SessionMessage::Assistant { message } => {
1899                    trace.projected_messages = trace.projected_messages.saturating_add(1);
1900                    trace.assistant_messages = trace.assistant_messages.saturating_add(1);
1901                    trace.input_tokens = trace.input_tokens.saturating_add(message.usage.input);
1902                    trace.output_tokens = trace.output_tokens.saturating_add(message.usage.output);
1903                    trace.cache_read_tokens = trace
1904                        .cache_read_tokens
1905                        .saturating_add(message.usage.cache_read);
1906                    trace.cache_write_tokens = trace
1907                        .cache_write_tokens
1908                        .saturating_add(message.usage.cache_write);
1909                    trace.total_tokens = trace
1910                        .total_tokens
1911                        .saturating_add(message.usage.total_tokens);
1912                }
1913                SessionMessage::ToolResult { .. } | SessionMessage::BashExecution { .. } => {
1914                    trace.projected_messages = trace.projected_messages.saturating_add(1);
1915                    trace.tool_messages = trace.tool_messages.saturating_add(1);
1916                }
1917                SessionMessage::Custom { display: true, .. } => {
1918                    trace.projected_messages = trace.projected_messages.saturating_add(1);
1919                    trace.system_messages = trace.system_messages.saturating_add(1);
1920                }
1921                SessionMessage::Custom { display: false, .. }
1922                | SessionMessage::CompactionSummary { .. }
1923                | SessionMessage::BranchSummary { .. } => {}
1924            }
1925        }
1926
1927        trace
1928    }
1929
1930    /// Open a session from an already-open V2 store with an explicit read mode.
1931    pub fn open_from_v2(
1932        store: &SessionStoreV2,
1933        header: SessionHeader,
1934        mode: V2OpenMode,
1935    ) -> Result<(Self, SessionOpenDiagnostics)> {
1936        header
1937            .validate()
1938            .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
1939        let (header, normalized_header_dirty) = normalize_loaded_header(header);
1940        let frames = match mode {
1941            V2OpenMode::Full => store.read_all_entries()?,
1942            V2OpenMode::ActivePath => match store.head() {
1943                Some(head) => store.read_active_path(&head.entry_id)?,
1944                None => Vec::new(),
1945            },
1946            V2OpenMode::Tail(count) => store.read_tail_entries(count)?,
1947        };
1948
1949        let mut diagnostics = SessionOpenDiagnostics::default();
1950        let mut entries = Vec::with_capacity(frames.len());
1951        for frame in &frames {
1952            match session_store_v2::frame_to_session_entry(frame) {
1953                Ok(entry) => entries.push(entry),
1954                Err(e) => {
1955                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
1956                        line_number: usize::try_from(frame.entry_seq).unwrap_or(0),
1957                        error: e.to_string(),
1958                    });
1959                }
1960            }
1961        }
1962
1963        let finalized = finalize_loaded_entries(&mut entries);
1964        for orphan in &finalized.orphans {
1965            diagnostics
1966                .orphaned_parent_links
1967                .push(SessionOpenOrphanedParentLink {
1968                    entry_id: orphan.0.clone(),
1969                    missing_parent_id: orphan.1.clone(),
1970                });
1971        }
1972
1973        let mut v2_message_count_offset = 0;
1974        if matches!(mode, V2OpenMode::Tail(_) | V2OpenMode::ActivePath) {
1975            if let Ok(Some(total)) = total_v2_message_count(store) {
1976                let loaded = finalized.message_count;
1977                v2_message_count_offset = total.saturating_sub(loaded);
1978            }
1979        }
1980
1981        let entry_count = entries.len();
1982        let natural_leaf_id = finalized.leaf_id.clone();
1983        let leaf_id =
1984            resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
1985        Ok((
1986            Self {
1987                header,
1988                entries,
1989                path: None,
1990                leaf_id: leaf_id.clone(),
1991                session_dir: None,
1992                store_kind: SessionStoreKind::Jsonl,
1993                entry_ids: finalized.entry_ids,
1994                is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
1995                entry_index: finalized.entry_index,
1996                cached_message_count: finalized
1997                    .message_count
1998                    .saturating_add(v2_message_count_offset),
1999                cached_name: finalized.name,
2000                autosave_queue: AutosaveQueue::new(),
2001                autosave_durability: AutosaveDurabilityMode::from_env(),
2002                persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
2003                header_dirty: normalized_header_dirty,
2004                appends_since_checkpoint: 0,
2005                v2_sidecar_root: None,
2006                v2_partial_hydration: !matches!(mode, V2OpenMode::Full),
2007                v2_resume_mode: Some(mode),
2008                v2_sidecar_stale: false,
2009                v2_message_count_offset,
2010            },
2011            diagnostics,
2012        ))
2013    }
2014
2015    /// Open using the V2 sidecar store (async wrapper around blocking read).
2016    async fn open_v2_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
2017        let path_buf = path.to_path_buf();
2018        let (tx, mut rx) = oneshot::channel();
2019
2020        let handle = thread::spawn(move || {
2021            let res = crate::session::open_from_v2_store_blocking(path_buf);
2022            let cx = AgentCx::for_request();
2023            let _ = tx.send(cx.cx(), res);
2024        });
2025
2026        let cx = AgentCx::for_request();
2027        let recv_result = rx.recv(cx.cx()).await;
2028        finish_worker_result(handle, recv_result, "V2 open task cancelled")
2029    }
2030
2031    async fn open_jsonl_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
2032        let path_buf = path.to_path_buf();
2033        let (tx, mut rx) = oneshot::channel();
2034
2035        let handle = thread::spawn(move || {
2036            let res = open_jsonl_blocking(path_buf);
2037            let cx = AgentCx::for_request();
2038            let _ = tx.send(cx.cx(), res);
2039        });
2040
2041        let cx = AgentCx::for_request();
2042        let recv_result = rx.recv(cx.cx()).await;
2043        finish_worker_result(handle, recv_result, "Open task cancelled")
2044    }
2045
2046    #[cfg(feature = "sqlite-sessions")]
2047    async fn open_sqlite(path: &Path) -> Result<Self> {
2048        let (header, mut entries) = crate::session_sqlite::load_session(path).await?;
2049        let (header, normalized_header_dirty) = normalize_loaded_header(header);
2050        let finalized = finalize_loaded_entries(&mut entries);
2051        let entry_count = entries.len();
2052        let natural_leaf_id = finalized.leaf_id.clone();
2053        let leaf_id =
2054            resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
2055
2056        Ok(Self {
2057            header,
2058            entries,
2059            path: Some(path.to_path_buf()),
2060            leaf_id: leaf_id.clone(),
2061            session_dir: None,
2062            store_kind: SessionStoreKind::Sqlite,
2063            entry_ids: finalized.entry_ids,
2064            is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
2065            entry_index: finalized.entry_index,
2066            cached_message_count: finalized.message_count,
2067            cached_name: finalized.name,
2068            autosave_queue: AutosaveQueue::new(),
2069            autosave_durability: AutosaveDurabilityMode::from_env(),
2070            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
2071            header_dirty: normalized_header_dirty,
2072            appends_since_checkpoint: 0,
2073            v2_sidecar_root: None,
2074            v2_partial_hydration: false,
2075            v2_resume_mode: None,
2076            v2_sidecar_stale: false,
2077            v2_message_count_offset: 0,
2078        })
2079    }
2080
2081    /// Continue the most recent session.
2082    pub async fn continue_recent_in_dir(
2083        override_dir: Option<&Path>,
2084        config: &Config,
2085    ) -> Result<Self> {
2086        let store_kind = SessionStoreKind::from_config(config);
2087        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
2088        let cwd = std::env::current_dir()?;
2089        let cwd_display = cwd.display().to_string();
2090        let encoded_cwd = encode_cwd(&cwd);
2091        let project_session_dir = base_dir.join(&encoded_cwd);
2092        let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
2093
2094        // Prefer the session index for fast lookup.
2095        let base_dir_clone = base_dir.clone();
2096        let cwd_display_clone = cwd_display.clone();
2097        let (tx, mut rx) = oneshot::channel();
2098
2099        let handle = thread::spawn(move || {
2100            let index = SessionIndex::for_sessions_root(&base_dir_clone);
2101            let mut indexed_sessions = index
2102                .list_sessions(Some(&cwd_display_clone))
2103                .unwrap_or_default();
2104
2105            if indexed_sessions.is_empty() && index.reindex_all().is_ok() {
2106                indexed_sessions = index
2107                    .list_sessions(Some(&cwd_display_clone))
2108                    .unwrap_or_default();
2109            }
2110            let cx = AgentCx::for_request();
2111            let _ = tx.send(cx.cx(), Ok(indexed_sessions));
2112        });
2113
2114        let cx = AgentCx::for_request();
2115        let recv_result = rx.recv(cx.cx()).await;
2116        let indexed_meta =
2117            finish_worker_result(handle, recv_result, "Recent session index task cancelled")
2118                .unwrap_or_default();
2119
2120        let index = SessionIndex::for_sessions_root(&base_dir);
2121        let (indexed_sessions, missing_paths) = split_indexed_session_entries(indexed_meta);
2122        for path in &missing_paths {
2123            prune_session_index_path(
2124                &index,
2125                path,
2126                "Failed to prune missing session from index during recent-session refresh",
2127            );
2128        }
2129
2130        if project_session_dir_missing {
2131            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
2132        }
2133
2134        let scanned = scan_sessions_on_disk(&project_session_dir, indexed_sessions.clone()).await?;
2135
2136        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
2137        for entry in indexed_sessions {
2138            by_path.insert(entry.path.clone(), entry);
2139        }
2140        for path in &scanned.failed_paths {
2141            prune_session_index_path(
2142                &index,
2143                path,
2144                "Failed to prune unreadable session from index during recent-session refresh",
2145            );
2146            by_path.remove(path);
2147        }
2148        refresh_session_index_entries(
2149            &index,
2150            &scanned.refreshed_entries,
2151            "Failed to refresh session metadata in index during recent-session refresh",
2152        );
2153        merge_scanned_session_entries(&mut by_path, scanned.entries);
2154
2155        let mut candidates = by_path.into_values().collect::<Vec<_>>();
2156        candidates.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
2157
2158        for entry in &candidates {
2159            match Self::open(entry.path.to_string_lossy().as_ref()).await {
2160                Ok(mut session) => {
2161                    session.session_dir = Some(base_dir.clone());
2162                    return Ok(session);
2163                }
2164                Err(err) => {
2165                    tracing::warn!(
2166                        path = %entry.path.display(),
2167                        error = %err,
2168                        "Skipping unreadable session candidate while continuing"
2169                    );
2170                    prune_session_index_path(
2171                        &index,
2172                        &entry.path,
2173                        "Failed to prune unreadable session after resume candidate open failure",
2174                    );
2175                }
2176            }
2177        }
2178
2179        Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind))
2180    }
2181
2182    /// Save the session to disk.
2183    pub async fn save(&mut self) -> Result<()> {
2184        let ticket = self
2185            .autosave_queue
2186            .begin_flush(AutosaveFlushTrigger::Manual);
2187        let result = self.save_inner().await;
2188        if let Some(ticket) = ticket {
2189            self.autosave_queue.finish_flush(ticket, result.is_ok());
2190        }
2191        result
2192    }
2193
2194    /// Flush queued autosave mutations using the requested trigger.
2195    ///
2196    /// This is the write-behind entry point: no-op when there are no pending
2197    /// mutations, and one persistence operation for all coalesced mutations when
2198    /// pending work exists.
2199    pub async fn flush_autosave(&mut self, trigger: AutosaveFlushTrigger) -> Result<()> {
2200        let Some(ticket) = self.autosave_queue.begin_flush(trigger) else {
2201            return Ok(());
2202        };
2203        let result = self.save_inner().await;
2204        self.autosave_queue.finish_flush(ticket, result.is_ok());
2205        result
2206    }
2207
2208    /// Final shutdown flush respecting the configured durability mode.
2209    pub async fn flush_autosave_on_shutdown(&mut self) -> Result<()> {
2210        if !self.autosave_durability.should_flush_on_shutdown() {
2211            return Ok(());
2212        }
2213        let result = self.flush_autosave(AutosaveFlushTrigger::Shutdown).await;
2214        if result.is_err() && self.autosave_durability.best_effort_on_shutdown() {
2215            if let Err(err) = &result {
2216                tracing::warn!(error = %err, "best-effort autosave flush failed during shutdown");
2217            }
2218            return Ok(());
2219        }
2220        result
2221    }
2222
2223    /// Current autosave queue and lifecycle counters for observability.
2224    pub const fn autosave_metrics(&self) -> AutosaveQueueMetrics {
2225        self.autosave_queue.metrics()
2226    }
2227
2228    pub const fn autosave_durability_mode(&self) -> AutosaveDurabilityMode {
2229        self.autosave_durability
2230    }
2231
2232    pub const fn set_autosave_durability_mode(&mut self, mode: AutosaveDurabilityMode) {
2233        self.autosave_durability = mode;
2234    }
2235
2236    #[cfg(test)]
2237    fn set_autosave_queue_limit_for_test(&mut self, max_pending_mutations: usize) {
2238        self.autosave_queue = AutosaveQueue::with_limit(max_pending_mutations);
2239    }
2240
2241    #[cfg(test)]
2242    const fn set_autosave_durability_for_test(&mut self, mode: AutosaveDurabilityMode) {
2243        self.autosave_durability = mode;
2244    }
2245
2246    /// Ensure a lazily hydrated V2 session is fully hydrated before persisting.
2247    ///
2248    /// Partial V2 hydration intentionally loads only a subset of entries for fast
2249    /// resume. Before any save path that could trigger a full JSONL rewrite, we
2250    /// must rehydrate all V2 entries to preserve non-active branches.
2251    fn ensure_full_v2_hydration_before_save(&mut self) -> Result<()> {
2252        if !self.v2_partial_hydration {
2253            return Ok(());
2254        }
2255
2256        let Some(v2_root) = self.v2_sidecar_root.clone() else {
2257            tracing::warn!(
2258                "session marked as partially hydrated from V2 but sidecar root is unavailable; disabling partial flag"
2259            );
2260            self.v2_partial_hydration = false;
2261            return Ok(());
2262        };
2263
2264        let pending_start = self
2265            .persisted_entry_count
2266            .load(Ordering::SeqCst)
2267            .min(self.entries.len());
2268        let previous_mode = self.v2_resume_mode;
2269
2270        let use_jsonl_rehydration = self
2271            .path
2272            .as_ref()
2273            .is_some_and(|path| self.v2_sidecar_stale || is_v2_sidecar_stale(path, &v2_root));
2274        let (fully_hydrated, diagnostics, rehydration_source) = if use_jsonl_rehydration {
2275            let path = self.path.clone().ok_or_else(|| {
2276                Error::session("missing JSONL path while rehydrating stale V2 session")
2277            })?;
2278            let (session, diagnostics) = open_jsonl_blocking(path)?;
2279            (session, diagnostics, "jsonl")
2280        } else {
2281            let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
2282            let (session, diagnostics) =
2283                Self::open_from_v2(&store, self.header.clone(), V2OpenMode::Full)?;
2284            (session, diagnostics, "v2")
2285        };
2286        if !diagnostics.skipped_entries.is_empty() || !diagnostics.orphaned_parent_links.is_empty()
2287        {
2288            tracing::error!(
2289                skipped_entries = diagnostics.skipped_entries.len(),
2290                orphaned_parent_links = diagnostics.orphaned_parent_links.len(),
2291                rehydration_source,
2292                "full V2 rehydration before save failed integrity check; aborting save to prevent data loss"
2293            );
2294            return Err(Error::session(format!(
2295                "V2 rehydration failed with {} skipped entries and {} orphaned links",
2296                diagnostics.skipped_entries.len(),
2297                diagnostics.orphaned_parent_links.len()
2298            )));
2299        }
2300
2301        // Extract pending in-memory entries by moving them out of `self.entries`
2302        // only after full hydration succeeds, preserving fail-safe behavior on
2303        // early-return errors and avoiding per-entry clone cost.
2304        let pending_entries = if pending_start >= self.entries.len() {
2305            Vec::new()
2306        } else {
2307            self.entries.split_off(pending_start)
2308        };
2309
2310        let persisted_entry_count = fully_hydrated.entries.len();
2311        let mut merged_entries = fully_hydrated.entries;
2312        merged_entries.extend(pending_entries);
2313
2314        let finalized = finalize_loaded_entries(&mut merged_entries);
2315        self.entries = merged_entries;
2316        self.leaf_id = finalized.leaf_id;
2317        self.entry_ids = finalized.entry_ids;
2318        self.is_linear = finalized.is_linear;
2319        self.entry_index = finalized.entry_index;
2320        self.cached_message_count = finalized.message_count;
2321        self.cached_name = finalized.name;
2322        self.persisted_entry_count
2323            .store(persisted_entry_count, Ordering::SeqCst);
2324        self.v2_partial_hydration = false;
2325        self.v2_resume_mode = Some(V2OpenMode::Full);
2326        self.v2_sidecar_stale = false;
2327        self.v2_message_count_offset = 0;
2328
2329        tracing::debug!(
2330            previous_mode = ?previous_mode,
2331            rehydration_source,
2332            persisted_entry_count,
2333            pending_entries = self.entries.len().saturating_sub(persisted_entry_count),
2334            "fully rehydrated V2 session before save"
2335        );
2336
2337        Ok(())
2338    }
2339
2340    /// Returns `true` when a full rewrite is required instead of incremental append.
2341    fn should_full_rewrite(&self) -> bool {
2342        let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
2343
2344        // First save — no file exists yet.
2345        if persisted_count == 0 {
2346            return true;
2347        }
2348        // If the backing file disappeared between saves, recover by rewriting
2349        // the full in-memory session instead of attempting an append.
2350        if self
2351            .path
2352            .as_ref()
2353            .is_some_and(|path| path.try_exists().is_ok_and(|exists| !exists))
2354        {
2355            return true;
2356        }
2357        // Header was modified since last save.
2358        if self.header_dirty {
2359            return true;
2360        }
2361        // Periodic checkpoint to clean up accumulated partial writes.
2362        if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
2363            return true;
2364        }
2365        // Defensive: if persisted count somehow exceeds entries, force full rewrite.
2366        if persisted_count > self.entries.len() {
2367            return true;
2368        }
2369        false
2370    }
2371
2372    /// Save the session to disk.
2373    #[allow(clippy::too_many_lines)]
2374    async fn save_inner(&mut self) -> Result<()> {
2375        self.ensure_entry_ids();
2376
2377        let store_kind = match self
2378            .path
2379            .as_ref()
2380            .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
2381        {
2382            Some("jsonl") => SessionStoreKind::Jsonl,
2383            Some("sqlite") => {
2384                #[cfg(feature = "sqlite-sessions")]
2385                {
2386                    SessionStoreKind::Sqlite
2387                }
2388
2389                #[cfg(not(feature = "sqlite-sessions"))]
2390                {
2391                    return Err(Error::session(
2392                        "SQLite session files require building with `--features sqlite-sessions`",
2393                    ));
2394                }
2395            }
2396            _ => self.store_kind,
2397        };
2398
2399        if self.path.is_none() {
2400            // Create a new path
2401            let base_dir = self
2402                .session_dir
2403                .clone()
2404                .unwrap_or_else(Config::sessions_dir);
2405            let cwd = if self.header.cwd.trim().is_empty() {
2406                std::env::current_dir()?
2407            } else {
2408                let configured_cwd = PathBuf::from(self.header.cwd.trim());
2409                if configured_cwd.is_absolute() {
2410                    configured_cwd
2411                } else {
2412                    std::env::current_dir()?.join(configured_cwd)
2413                }
2414            };
2415            let encoded_cwd = encode_cwd(&cwd);
2416            let project_session_dir = base_dir.join(&encoded_cwd);
2417
2418            asupersync::fs::create_dir_all(&project_session_dir).await?;
2419
2420            let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ");
2421            // Robust against malformed/legacy session ids: keep a short, filename-safe suffix.
2422            let short_id = {
2423                let prefix: String = self
2424                    .header
2425                    .id
2426                    .chars()
2427                    .take(8)
2428                    .map(|ch| {
2429                        if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
2430                            ch
2431                        } else {
2432                            '_'
2433                        }
2434                    })
2435                    .collect();
2436                if prefix.trim_matches('_').is_empty() {
2437                    "session".to_string()
2438                } else {
2439                    prefix
2440                }
2441            };
2442            let filename = format!("{}_{}.{}", timestamp, short_id, store_kind.extension());
2443            self.path = Some(project_session_dir.join(filename));
2444        }
2445
2446        // Persist a repaired id for legacy or manually corrupted in-memory headers.
2447        // The filename fallback above still keeps empty ids on-disk-path-safe.
2448        if self.header.id.trim().is_empty() {
2449            self.header.id = uuid::Uuid::new_v4().to_string();
2450            self.header_dirty = true;
2451        }
2452        let desired_leaf_override = self.persisted_leaf_override();
2453        if !self.header.current_leaf.eq(&desired_leaf_override) {
2454            self.header.current_leaf = desired_leaf_override;
2455            self.header_dirty = true;
2456        }
2457        self.header
2458            .validate()
2459            .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
2460
2461        let session_dir_clone = self.session_dir.clone();
2462        let path = self.path.clone().ok_or_else(|| {
2463            Error::session("Session path not set - cannot save session".to_string())
2464        })?;
2465        let path_clone = path.clone();
2466
2467        match store_kind {
2468            SessionStoreKind::Jsonl => {
2469                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
2470
2471                if self.should_full_rewrite() {
2472                    if self.v2_partial_hydration {
2473                        self.ensure_full_v2_hydration_before_save()?;
2474                    }
2475                    // Gap C: use incrementally maintained stats instead of O(n) scan.
2476                    // === Full rewrite path (first save, header change, checkpoint) ===
2477                    let header_snapshot = self.header.clone();
2478                    let entries_to_save = self.entries.clone();
2479                    let persisted_entry_count = self.persisted_entry_count.load(Ordering::SeqCst);
2480                    let header_dirty = self.header_dirty;
2481                    let path_for_task = path_clone.clone();
2482                    let sessions_root_for_task = sessions_root.clone();
2483                    let (saved_header, saved_entries) =
2484                        asupersync::runtime::spawn_blocking(move || {
2485                            save_jsonl_full_rewrite_blocking(
2486                                &path_for_task,
2487                                &sessions_root_for_task,
2488                                &header_snapshot,
2489                                &entries_to_save,
2490                                persisted_entry_count,
2491                                header_dirty,
2492                            )
2493                        })
2494                        .await?;
2495
2496                    let previous_leaf = self.leaf_id.clone();
2497                    self.header = saved_header;
2498                    self.entries = saved_entries;
2499                    let finalized = finalize_loaded_entries(&mut self.entries);
2500                    self.entry_ids = finalized.entry_ids;
2501                    self.entry_index = finalized.entry_index;
2502                    self.cached_message_count = finalized
2503                        .message_count
2504                        .saturating_add(self.v2_message_count_offset);
2505                    self.cached_name = finalized.name;
2506                    self.leaf_id = previous_leaf
2507                        .filter(|id| self.entry_index.contains_key(id))
2508                        .or_else(|| finalized.leaf_id.clone());
2509                    self.is_linear = finalized.is_linear && self.leaf_id.eq(&finalized.leaf_id);
2510                    self.persisted_entry_count
2511                        .store(self.entries.len(), Ordering::SeqCst);
2512                    self.header_dirty = false;
2513                    self.appends_since_checkpoint = 0;
2514                    self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
2515                } else {
2516                    let message_count = self.cached_message_count;
2517                    // === Incremental append path ===
2518                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2519                    if new_start < self.entries.len() {
2520                        let session_name = self.cached_name.clone();
2521                        // Pre-serialize new entries into a single buffer (typically 1-3 entries).
2522                        let new_entries = &self.entries[new_start..];
2523                        // Scale buffer reservation from observed on-disk average entry size to
2524                        // avoid repeated growth/copy when appending large entries.
2525                        let estimated_entry_bytes = asupersync::fs::metadata(&path_clone)
2526                            .await
2527                            .ok()
2528                            .and_then(|meta| usize::try_from(meta.len()).ok())
2529                            .map_or(512, |file_bytes| {
2530                                let avg = file_bytes / new_start.max(1);
2531                                avg.clamp(512, 256 * 1024)
2532                            });
2533                        let mut serialized_buf = Vec::with_capacity(
2534                            new_entries
2535                                .len()
2536                                .saturating_mul(estimated_entry_bytes.saturating_add(1)),
2537                        );
2538                        for entry in new_entries {
2539                            serde_json::to_writer(&mut serialized_buf, entry)?;
2540                            serialized_buf.push(b'\n');
2541                        }
2542                        let new_count = self.entries.len();
2543
2544                        let header_snapshot = self.header.clone();
2545                        let path_for_task = path_clone.clone();
2546                        let sessions_root_for_task = sessions_root.clone();
2547                        asupersync::runtime::spawn_blocking(move || {
2548                            append_jsonl_entries_blocking(
2549                                &path_for_task,
2550                                &sessions_root_for_task,
2551                                &header_snapshot,
2552                                &serialized_buf,
2553                                message_count,
2554                                session_name,
2555                            )
2556                        })
2557                        .await?;
2558
2559                        self.persisted_entry_count
2560                            .store(new_count, Ordering::SeqCst);
2561                        self.appends_since_checkpoint += 1;
2562                        self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
2563                    }
2564                    // No new entries → no-op, nothing to write.
2565                }
2566            }
2567            #[cfg(feature = "sqlite-sessions")]
2568            SessionStoreKind::Sqlite => {
2569                let message_count = self.cached_message_count;
2570                let session_name = self.cached_name.clone();
2571
2572                if self.should_full_rewrite() {
2573                    // === Full rewrite path (first save, header change, checkpoint) ===
2574                    crate::session_sqlite::save_session(&path_clone, &self.header, &self.entries)
2575                        .await?;
2576                    self.persisted_entry_count
2577                        .store(self.entries.len(), Ordering::SeqCst);
2578                    self.header_dirty = false;
2579                    self.appends_since_checkpoint = 0;
2580                } else {
2581                    // === Incremental append path ===
2582                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2583                    if new_start < self.entries.len() {
2584                        crate::session_sqlite::append_entries(
2585                            &path_clone,
2586                            &self.entries[new_start..],
2587                            new_start,
2588                            message_count,
2589                            session_name.as_deref(),
2590                        )
2591                        .await?;
2592                        self.persisted_entry_count
2593                            .store(self.entries.len(), Ordering::SeqCst);
2594                        self.appends_since_checkpoint += 1;
2595                    }
2596                    // No new entries → no-op, nothing to write.
2597                }
2598
2599                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
2600                enqueue_session_index_snapshot_update(
2601                    &sessions_root,
2602                    &path_clone,
2603                    &self.header,
2604                    message_count,
2605                    session_name,
2606                );
2607            }
2608        }
2609        Ok(())
2610    }
2611
2612    const fn enqueue_autosave_mutation(&mut self, kind: AutosaveMutationKind) {
2613        self.autosave_queue.enqueue_mutation(kind);
2614    }
2615
2616    fn latest_model_change_for_current_path(&self) -> Option<(String, String)> {
2617        for entry in self.entries_for_current_path().iter().rev() {
2618            if let SessionEntry::ModelChange(change) = entry {
2619                return Some((change.provider.clone(), change.model_id.clone()));
2620            }
2621        }
2622        None
2623    }
2624
2625    fn latest_thinking_level_for_current_path(&self) -> Option<String> {
2626        for entry in self.entries_for_current_path().iter().rev() {
2627            if let SessionEntry::ThinkingLevelChange(change) = entry {
2628                return Some(change.thinking_level.clone());
2629            }
2630        }
2631        None
2632    }
2633
2634    pub fn effective_model_for_current_path(&self) -> Option<(String, String)> {
2635        // If there's an explicit model change on the current path, use it
2636        if let Some(model) = self.latest_model_change_for_current_path() {
2637            return Some(model);
2638        }
2639
2640        // If other branches have model changes, we only inherit EXPLICIT fallbacks.
2641        // We do NOT inherit `self.header.provider` because that reflects another branch's tip.
2642        if self.has_any_model_change() {
2643            return self
2644                .header
2645                .fallback_provider
2646                .clone()
2647                .zip(self.header.fallback_model_id.clone());
2648        }
2649
2650        self.header
2651            .provider
2652            .clone()
2653            .zip(self.header.model_id.clone())
2654    }
2655
2656    pub fn effective_thinking_level_for_current_path(&self) -> Option<String> {
2657        // If there's an explicit thinking level change on the current path, use it
2658        if let Some(level) = self.latest_thinking_level_for_current_path() {
2659            return Some(level);
2660        }
2661
2662        // If other branches have thinking level changes, we only inherit EXPLICIT fallbacks.
2663        // We do NOT inherit `self.header.thinking_level` because that reflects another branch's tip.
2664        if self.has_any_thinking_level_change() {
2665            return self.header.fallback_thinking_level.clone();
2666        }
2667
2668        self.header.thinking_level.clone()
2669    }
2670
2671    fn has_any_model_change(&self) -> bool {
2672        self.entries
2673            .iter()
2674            .any(|entry| matches!(entry, SessionEntry::ModelChange(_)))
2675    }
2676
2677    fn has_any_thinking_level_change(&self) -> bool {
2678        self.entries
2679            .iter()
2680            .any(|entry| matches!(entry, SessionEntry::ThinkingLevelChange(_)))
2681    }
2682
2683    fn persisted_leaf_override(&self) -> Option<String> {
2684        if self.entries.is_empty() {
2685            return None;
2686        }
2687
2688        match (
2689            self.leaf_id.as_deref(),
2690            self.entries
2691                .last()
2692                .and_then(SessionEntry::base_id)
2693                .map(String::as_str),
2694        ) {
2695            (None, _) => Some(ROOT_LEAF_OVERRIDE_SENTINEL.to_string()),
2696            (Some(current), Some(natural_tip)) if current.eq(natural_tip) => None,
2697            (Some(current), _) => Some(current.to_string()),
2698        }
2699    }
2700
2701    fn sync_navigation_state_to_header(&mut self) {
2702        let mut changed = false;
2703
2704        let desired_leaf_override = self.persisted_leaf_override();
2705        if !self.header.current_leaf.eq(&desired_leaf_override) {
2706            self.header.current_leaf = desired_leaf_override;
2707            changed = true;
2708        }
2709
2710        if let Some((provider, model_id)) = self.effective_model_for_current_path() {
2711            if !self
2712                .header
2713                .provider
2714                .as_deref()
2715                .is_some_and(|current| current.eq(provider.as_str()))
2716                || !self
2717                    .header
2718                    .model_id
2719                    .as_deref()
2720                    .is_some_and(|current| current.eq(model_id.as_str()))
2721            {
2722                self.header.provider = Some(provider);
2723                self.header.model_id = Some(model_id);
2724                changed = true;
2725            }
2726        } else if self.has_any_model_change()
2727            && (self.header.provider.is_some() || self.header.model_id.is_some())
2728        {
2729            self.header.provider = None;
2730            self.header.model_id = None;
2731            changed = true;
2732        }
2733
2734        if let Some(thinking_level) = self.effective_thinking_level_for_current_path() {
2735            if !self
2736                .header
2737                .thinking_level
2738                .as_deref()
2739                .is_some_and(|current| current.eq(thinking_level.as_str()))
2740            {
2741                self.header.thinking_level = Some(thinking_level);
2742                changed = true;
2743            }
2744        } else if self.has_any_thinking_level_change() && self.header.thinking_level.is_some() {
2745            self.header.thinking_level = None;
2746            changed = true;
2747        }
2748
2749        if changed {
2750            self.header_dirty = true;
2751            self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2752        }
2753    }
2754
2755    fn clear_persisted_leaf_override_after_append(&mut self) {
2756        let desired_leaf_override = self.persisted_leaf_override();
2757        if !self.header.current_leaf.eq(&desired_leaf_override) {
2758            self.header.current_leaf = desired_leaf_override;
2759            self.header_dirty = true;
2760        }
2761    }
2762
2763    /// Append a session message entry.
2764    pub fn append_message(&mut self, message: SessionMessage) -> String {
2765        let id = self.next_entry_id();
2766        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2767        let entry = SessionEntry::Message(MessageEntry { base, message });
2768        self.leaf_id = Some(id.clone());
2769        self.entries.push(entry);
2770        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2771        self.entry_ids.insert(id.clone());
2772        self.cached_message_count += 1;
2773        self.clear_persisted_leaf_override_after_append();
2774        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2775        id
2776    }
2777
2778    /// Append a message from the model message types.
2779    pub fn append_model_message(&mut self, message: Message) -> String {
2780        self.append_message(SessionMessage::from(message))
2781    }
2782
2783    pub fn append_model_change(&mut self, provider: String, model_id: String) -> String {
2784        let id = self.next_entry_id();
2785        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2786        let entry = SessionEntry::ModelChange(ModelChangeEntry {
2787            base,
2788            provider,
2789            model_id,
2790        });
2791        self.leaf_id = Some(id.clone());
2792        self.entries.push(entry);
2793        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2794        self.entry_ids.insert(id.clone());
2795        self.clear_persisted_leaf_override_after_append();
2796        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2797        id
2798    }
2799
2800    pub fn append_thinking_level_change(&mut self, thinking_level: String) -> String {
2801        let id = self.next_entry_id();
2802        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2803        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
2804            base,
2805            thinking_level,
2806        });
2807        self.leaf_id = Some(id.clone());
2808        self.entries.push(entry);
2809        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2810        self.entry_ids.insert(id.clone());
2811        self.clear_persisted_leaf_override_after_append();
2812        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2813        id
2814    }
2815
2816    pub fn append_session_info(&mut self, name: Option<String>) -> String {
2817        let id = self.next_entry_id();
2818        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2819        if name.is_some() {
2820            self.cached_name.clone_from(&name);
2821        }
2822        let entry = SessionEntry::SessionInfo(SessionInfoEntry { base, name });
2823        self.leaf_id = Some(id.clone());
2824        self.entries.push(entry);
2825        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2826        self.entry_ids.insert(id.clone());
2827        self.clear_persisted_leaf_override_after_append();
2828        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2829        id
2830    }
2831
2832    /// Append a custom entry (extension state, etc).
2833    pub fn append_custom_entry(
2834        &mut self,
2835        custom_type: String,
2836        data: Option<serde_json::Value>,
2837    ) -> String {
2838        let id = self.next_entry_id();
2839        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2840        let entry = SessionEntry::Custom(CustomEntry {
2841            base,
2842            custom_type,
2843            data,
2844        });
2845        self.leaf_id = Some(id.clone());
2846        self.entries.push(entry);
2847        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2848        self.entry_ids.insert(id.clone());
2849        self.clear_persisted_leaf_override_after_append();
2850        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2851        id
2852    }
2853
2854    pub fn append_bash_execution(
2855        &mut self,
2856        command: String,
2857        output: String,
2858        exit_code: i32,
2859        cancelled: bool,
2860        truncated: bool,
2861        full_output_path: Option<String>,
2862    ) -> String {
2863        let id = self.next_entry_id();
2864        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2865        let entry = SessionEntry::Message(MessageEntry {
2866            base,
2867            message: SessionMessage::BashExecution {
2868                command,
2869                output,
2870                exit_code,
2871                cancelled: Some(cancelled),
2872                truncated: Some(truncated),
2873                full_output_path,
2874                timestamp: Some(chrono::Utc::now().timestamp_millis()),
2875                extra: HashMap::new(),
2876            },
2877        });
2878        self.leaf_id = Some(id.clone());
2879        self.entries.push(entry);
2880        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2881        self.entry_ids.insert(id.clone());
2882        self.cached_message_count += 1;
2883        self.clear_persisted_leaf_override_after_append();
2884        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2885        id
2886    }
2887
2888    /// Get the current session name from the cached value (Gap C).
2889    pub fn get_name(&self) -> Option<String> {
2890        self.cached_name.clone()
2891    }
2892
2893    /// Set the session name by appending a `SessionInfo` entry.
2894    pub fn set_name(&mut self, name: &str) -> String {
2895        self.append_session_info(Some(name.to_string()))
2896    }
2897
2898    pub fn append_compaction(
2899        &mut self,
2900        summary: String,
2901        first_kept_entry_id: String,
2902        tokens_before: u64,
2903        details: Option<Value>,
2904        from_hook: Option<bool>,
2905    ) -> String {
2906        let id = self.next_entry_id();
2907        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2908        let entry = SessionEntry::Compaction(CompactionEntry {
2909            base,
2910            summary,
2911            first_kept_entry_id,
2912            tokens_before,
2913            details,
2914            from_hook,
2915        });
2916        self.leaf_id = Some(id.clone());
2917        self.entries.push(entry);
2918        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2919        self.entry_ids.insert(id.clone());
2920        self.clear_persisted_leaf_override_after_append();
2921        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2922        id
2923    }
2924
2925    pub fn append_branch_summary(
2926        &mut self,
2927        from_id: String,
2928        summary: String,
2929        details: Option<Value>,
2930        from_hook: Option<bool>,
2931    ) -> String {
2932        let id = self.next_entry_id();
2933        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2934        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
2935            base,
2936            from_id,
2937            summary,
2938            details,
2939            from_hook,
2940        });
2941        self.leaf_id = Some(id.clone());
2942        self.entries.push(entry);
2943        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2944        self.entry_ids.insert(id.clone());
2945        self.clear_persisted_leaf_override_after_append();
2946        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2947        id
2948    }
2949
2950    pub fn ensure_entry_ids(&mut self) {
2951        // `rebuild_all_caches()` runs `finalize_loaded_entries()`, which already
2952        // fills missing entry IDs and rebuilds all derived caches in one pass.
2953        self.rebuild_all_caches();
2954    }
2955
2956    /// Rebuild all derived caches from `self.entries`.
2957    ///
2958    /// Called after bulk mutations (save round-trip, ensure_entry_ids) where
2959    /// incremental maintenance is impractical.
2960    fn rebuild_all_caches(&mut self) {
2961        let finalized = finalize_loaded_entries(&mut self.entries);
2962        self.entry_ids = finalized.entry_ids;
2963        self.entry_index = finalized.entry_index;
2964        self.cached_message_count = finalized
2965            .message_count
2966            .saturating_add(self.v2_message_count_offset);
2967        self.cached_name = finalized.name;
2968        // is_linear requires BOTH: no branching in the entry tree AND the
2969        // current leaf_id pointing at the last entry.  If the user navigated
2970        // to a mid-chain entry before saving, the leaf differs from the tip
2971        // and the fast path would return wrong results.
2972        self.is_linear = finalized.is_linear && self.leaf_id.eq(&finalized.leaf_id);
2973    }
2974
2975    /// Convert session entries to model messages (for provider context).
2976    pub fn to_messages(&self) -> Vec<Message> {
2977        let mut messages = Vec::new();
2978        for entry in &self.entries {
2979            if let SessionEntry::Message(msg_entry) = entry {
2980                if let Some(message) = session_message_to_model(&msg_entry.message) {
2981                    messages.push(message);
2982                }
2983            }
2984        }
2985        messages
2986    }
2987
2988    /// Render the session as a standalone HTML document.
2989    ///
2990    /// Delegates to `render_session_html()` for the actual rendering. For
2991    /// non-blocking export, prefer `export_snapshot().to_html()` which avoids
2992    /// cloning internal caches.
2993    pub fn to_html(&self) -> String {
2994        render_session_html(&self.header, &self.entries)
2995    }
2996
2997    /// Update header model info.
2998    pub fn set_model_header(
2999        &mut self,
3000        provider: Option<String>,
3001        model_id: Option<String>,
3002        thinking_level: Option<String>,
3003    ) {
3004        let changed = provider.is_some() || model_id.is_some() || thinking_level.is_some();
3005        if provider.is_some() {
3006            self.header.provider = provider;
3007        }
3008        if model_id.is_some() {
3009            self.header.model_id = model_id;
3010        }
3011        if thinking_level.is_some() {
3012            self.header.thinking_level = thinking_level;
3013        }
3014        if changed {
3015            self.header_dirty = true;
3016            self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
3017        }
3018    }
3019
3020    pub fn set_branched_from(&mut self, path: Option<String>) {
3021        self.header.parent_session = path;
3022        self.header_dirty = true;
3023        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
3024    }
3025
3026    /// Create a lightweight snapshot for non-blocking HTML export.
3027    ///
3028    /// Captures only the fields needed by `to_html()` (header, entries, path),
3029    /// avoiding a full `Session::clone()` which includes caches, autosave queues,
3030    /// persistence state, and other internal bookkeeping.
3031    pub fn export_snapshot(&self) -> ExportSnapshot {
3032        ExportSnapshot {
3033            header: self.header.clone(),
3034            entries: self.entries.clone(),
3035            path: self.path.clone(),
3036        }
3037    }
3038
3039    /// Plan a `/fork` from a user message entry ID.
3040    ///
3041    /// Returns the entries to copy into a new session (path to the parent of the selected
3042    /// user message), the new leaf id, and the selected user message text for editor pre-fill.
3043    pub fn plan_fork_from_user_message(&self, entry_id: &str) -> Result<ForkPlan> {
3044        let entry = self
3045            .get_entry(entry_id)
3046            .ok_or_else(|| Error::session(format!("Fork target not found: {entry_id}")))?;
3047
3048        let SessionEntry::Message(message_entry) = entry else {
3049            return Err(Error::session(format!(
3050                "Fork target is not a message entry: {entry_id}"
3051            )));
3052        };
3053
3054        let SessionMessage::User { content, .. } = &message_entry.message else {
3055            return Err(Error::session(format!(
3056                "Fork target is not a user message: {entry_id}"
3057            )));
3058        };
3059
3060        let selected_text = user_content_to_text(content);
3061        let leaf_id = message_entry.base.parent_id.clone();
3062
3063        let entries = if let Some(ref leaf_id) = leaf_id {
3064            if self.is_linear {
3065                let idx = self.entry_index.get(leaf_id).copied().ok_or_else(|| {
3066                    Error::session(format!("Failed to build fork: missing entry {leaf_id}"))
3067                })?;
3068                self.entries[..=idx].to_vec()
3069            } else {
3070                let path_ids = self.get_path_to_entry(leaf_id);
3071                let mut entries = Vec::new();
3072                for path_id in path_ids {
3073                    let entry = self.get_entry(&path_id).ok_or_else(|| {
3074                        Error::session(format!("Failed to build fork: missing entry {path_id}"))
3075                    })?;
3076                    entries.push(entry.clone());
3077                }
3078                entries
3079            }
3080        } else {
3081            Vec::new()
3082        };
3083
3084        Ok(ForkPlan {
3085            entries,
3086            leaf_id,
3087            selected_text,
3088        })
3089    }
3090
3091    fn next_entry_id(&self) -> String {
3092        let use_entry_id_cache = session_entry_id_cache_enabled();
3093
3094        if use_entry_id_cache {
3095            // Use the cached set for O(1) collision checks.
3096            // generate_entry_id handles generation + collision retry logic.
3097            generate_entry_id(&self.entry_ids)
3098        } else {
3099            // Fallback: scan entries to build the exclusion set on demand.
3100            // This is slower (O(N)) but only used if the cache feature flag is disabled.
3101            let existing = entry_id_set(&self.entries);
3102            generate_entry_id(&existing)
3103        }
3104    }
3105
3106    // ========================================================================
3107    // Tree Navigation
3108    // ========================================================================
3109
3110    /// Build a map from parent ID to children IDs.
3111    fn build_children_map(&self) -> HashMap<Option<String>, Vec<String>> {
3112        let mut children: HashMap<Option<String>, Vec<String>> =
3113            HashMap::with_capacity(self.entries.len());
3114        for entry in &self.entries {
3115            if let Some(id) = entry.base_id() {
3116                children
3117                    .entry(entry.base().parent_id.clone())
3118                    .or_default()
3119                    .push(id.clone());
3120            }
3121        }
3122        children
3123    }
3124
3125    /// Get the path from an entry back to the root (inclusive).
3126    /// Returns entry IDs in order from root to the specified entry.
3127    pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
3128        // Fast path: in linear sessions, every ancestor chain is a prefix of `entries`.
3129        if self.is_linear {
3130            if let Some(&idx) = self.entry_index.get(entry_id) {
3131                let mut path = Vec::with_capacity(idx + 1);
3132                for entry in &self.entries[..=idx] {
3133                    if let Some(id) = entry.base_id() {
3134                        path.push(id.clone());
3135                    }
3136                }
3137                return path;
3138            }
3139        }
3140
3141        let mut path = Vec::new();
3142        let mut visited = std::collections::HashSet::with_capacity(self.entries.len().min(128));
3143        let mut current = Some(entry_id.to_string());
3144
3145        while let Some(id) = current {
3146            if !visited.insert(id.clone()) {
3147                tracing::warn!(
3148                    "Cycle detected in session tree while building ancestor path at entry: {id}"
3149                );
3150                break;
3151            }
3152            path.push(id.clone());
3153            current = self
3154                .get_entry(&id)
3155                .and_then(|entry| entry.base().parent_id.clone());
3156        }
3157
3158        path.reverse();
3159        path
3160    }
3161
3162    /// Get direct children of an entry.
3163    pub fn get_children(&self, entry_id: Option<&str>) -> Vec<String> {
3164        self.entries
3165            .iter()
3166            .filter_map(|entry| {
3167                let id = entry.base_id()?;
3168                if entry.base().parent_id.as_deref().eq(&entry_id) {
3169                    Some(id.clone())
3170                } else {
3171                    None
3172                }
3173            })
3174            .collect()
3175    }
3176
3177    /// List all leaf nodes (entries with no children).
3178    pub fn list_leaves(&self) -> Vec<String> {
3179        let mut has_children: HashSet<&str> = HashSet::with_capacity(self.entries.len());
3180        for entry in &self.entries {
3181            if let Some(parent_id) = entry.base().parent_id.as_deref() {
3182                has_children.insert(parent_id);
3183            }
3184        }
3185
3186        self.entries
3187            .iter()
3188            .filter_map(|e| {
3189                let id = e.base_id()?;
3190                if has_children.contains(id.as_str()) {
3191                    None
3192                } else {
3193                    Some(id.clone())
3194                }
3195            })
3196            .collect()
3197    }
3198
3199    /// Navigate to a specific entry, making it the current leaf.
3200    /// Returns true if the entry exists.
3201    pub fn navigate_to(&mut self, entry_id: &str) -> bool {
3202        // Gap B: O(1) existence check via entry_index.
3203        let exists = self.entry_index.contains_key(entry_id);
3204        if exists {
3205            // Gap A: navigating away from the tip breaks linearity.
3206            let is_tip = self
3207                .entries
3208                .last()
3209                .and_then(|e| e.base_id())
3210                .is_some_and(|id| id.eq(entry_id));
3211            if !is_tip {
3212                self.is_linear = false;
3213            }
3214            self.leaf_id = Some(entry_id.to_string());
3215            self.sync_header_to_current_path_metadata();
3216            true
3217        } else {
3218            false
3219        }
3220    }
3221
3222    /// Get the current leaf entry ID.
3223    pub fn leaf_id(&self) -> Option<&str> {
3224        self.leaf_id.as_deref()
3225    }
3226
3227    /// Initialize the session entries and leaf from a `ForkPlan`.
3228    ///
3229    /// This safely applies the new entries and leaf, and rebuilds
3230    /// all internal caches (including the `is_linear` optimization flag).
3231    pub fn init_from_fork_plan(&mut self, plan: ForkPlan) {
3232        self.entries = plan.entries;
3233        self.leaf_id = plan.leaf_id;
3234        self.rebuild_all_caches();
3235        self.sync_navigation_state_to_header();
3236    }
3237
3238    /// Set the leaf ID directly (for tests only).
3239    pub fn _test_set_leaf_id(&mut self, id: Option<String>) {
3240        self.leaf_id = id;
3241        self.rebuild_all_caches();
3242        self.sync_navigation_state_to_header();
3243    }
3244
3245    fn sync_header_to_current_path_metadata(&mut self) {
3246        self.sync_navigation_state_to_header();
3247    }
3248
3249    /// Revert the last user message on the current path, effectively abandoning it.
3250    /// This is used during API retries to prevent duplicating the user prompt in the session history.
3251    pub fn revert_last_user_message(&mut self) -> bool {
3252        let mut current_id = self.leaf_id.clone();
3253        let mut reverted_any = false;
3254
3255        while let Some(id) = current_id {
3256            if let Some(entry) = self.get_entry(&id) {
3257                let parent_id = entry.base().parent_id.clone();
3258                let is_user = if let SessionEntry::Message(msg_entry) = entry {
3259                    matches!(msg_entry.message, SessionMessage::User { .. })
3260                } else {
3261                    false
3262                };
3263
3264                self.leaf_id.clone_from(&parent_id);
3265                self.is_linear = false;
3266                reverted_any = true;
3267
3268                if is_user {
3269                    // We found and reverted the user message, we can stop walking back.
3270                    break;
3271                }
3272
3273                current_id = parent_id;
3274            } else {
3275                break;
3276            }
3277        }
3278        if reverted_any {
3279            self.sync_navigation_state_to_header();
3280        }
3281        reverted_any
3282    }
3283
3284    /// Reset the leaf pointer to root (before any entries).
3285    ///
3286    /// After calling this, the next appended entry will become a new root entry
3287    /// (`parent_id = None`). This is used by interactive `/tree` navigation when
3288    /// re-editing the first user message.
3289    pub fn reset_leaf(&mut self) {
3290        self.leaf_id = None;
3291        self.is_linear = false;
3292        self.sync_navigation_state_to_header();
3293    }
3294
3295    /// Create a new branch starting from a specific entry.
3296    /// Sets the leaf_id to the specified entry so new entries branch from there.
3297    /// Returns true if the entry exists.
3298    pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
3299        self.navigate_to(entry_id)
3300    }
3301
3302    /// Get the entry at a specific ID (Gap B: O(1) via `entry_index`).
3303    pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
3304        self.entry_index
3305            .get(entry_id)
3306            .and_then(|&idx| self.entries.get(idx))
3307    }
3308
3309    /// Get the entry at a specific ID, mutable (Gap B: O(1) via `entry_index`).
3310    pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
3311        self.entry_index
3312            .get(entry_id)
3313            .copied()
3314            .and_then(|idx| self.entries.get_mut(idx))
3315    }
3316
3317    /// Entries along the current leaf path, in chronological order.
3318    ///
3319    /// Gap A: when `is_linear` is true (the 99% case — no branching has
3320    /// occurred), this returns all entries directly without building a
3321    /// parent map or tracing the path.
3322    pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
3323        let Some(leaf_id) = &self.leaf_id else {
3324            return Vec::new();
3325        };
3326
3327        // Fast path: linear session — all entries are on the current path.
3328        if self.is_linear {
3329            return self.entries.iter().collect();
3330        }
3331
3332        let mut path_indices = Vec::with_capacity(16);
3333        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3334        let mut current = Some(leaf_id.clone());
3335
3336        while let Some(id) = current.as_ref() {
3337            if !visited.insert(id.clone()) {
3338                tracing::warn!(
3339                    "Cycle detected in session tree while collecting current path entries at: {id}"
3340                );
3341                break;
3342            }
3343            let Some(&idx) = self.entry_index.get(id.as_str()) else {
3344                break;
3345            };
3346            let Some(entry) = self.entries.get(idx) else {
3347                break;
3348            };
3349            path_indices.push(idx);
3350            current.clone_from(&entry.base().parent_id);
3351        }
3352
3353        path_indices.reverse();
3354        path_indices
3355            .into_iter()
3356            .filter_map(|idx| self.entries.get(idx))
3357            .collect()
3358    }
3359
3360    /// Convert session entries along the current path to model messages.
3361    /// This follows parent_id links from leaf_id back to root.
3362    pub fn to_messages_for_current_path(&self) -> Vec<Message> {
3363        if self.leaf_id.is_none() {
3364            return Vec::new();
3365        }
3366
3367        if self.is_linear {
3368            return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
3369        }
3370
3371        let path_entries = self.entries_for_current_path();
3372        Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
3373    }
3374
3375    fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
3376        match entry {
3377            SessionEntry::Message(msg_entry) => {
3378                if let Some(message) = session_message_to_model(&msg_entry.message) {
3379                    messages.push(message);
3380                }
3381            }
3382            SessionEntry::BranchSummary(summary) => {
3383                let summary_message = SessionMessage::BranchSummary {
3384                    summary: summary.summary.clone(),
3385                    from_id: summary.from_id.clone(),
3386                };
3387                if let Some(message) = session_message_to_model(&summary_message) {
3388                    messages.push(message);
3389                }
3390            }
3391            _ => {}
3392        }
3393    }
3394
3395    fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
3396    where
3397        F: Fn(usize) -> &'a SessionEntry,
3398    {
3399        let mut last_compaction = None;
3400        for idx in (0..path_len).rev() {
3401            if let SessionEntry::Compaction(compaction) = entry_at(idx) {
3402                last_compaction = Some((idx, compaction));
3403                break;
3404            }
3405        }
3406
3407        if let Some((compaction_idx, compaction)) = last_compaction {
3408            let mut messages = Vec::with_capacity(path_len);
3409            let summary_message = SessionMessage::CompactionSummary {
3410                summary: compaction.summary.clone(),
3411                tokens_before: compaction.tokens_before,
3412            };
3413            if let Some(message) = session_message_to_model(&summary_message) {
3414                messages.push(message);
3415            }
3416
3417            let has_kept_entry = (0..path_len).any(|idx| {
3418                entry_at(idx)
3419                    .base_id()
3420                    .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3421            });
3422
3423            let mut keep = false;
3424            let mut past_compaction = false;
3425            for idx in 0..path_len {
3426                let entry = entry_at(idx);
3427                if idx.eq(&compaction_idx) {
3428                    past_compaction = true;
3429                }
3430                if !keep {
3431                    if has_kept_entry {
3432                        if entry
3433                            .base_id()
3434                            .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3435                        {
3436                            keep = true;
3437                        } else {
3438                            continue;
3439                        }
3440                    } else if past_compaction {
3441                        tracing::warn!(
3442                            first_kept_entry_id = %compaction.first_kept_entry_id,
3443                            "Compaction references missing entry; including all post-compaction entries"
3444                        );
3445                        keep = true;
3446                    } else {
3447                        continue;
3448                    }
3449                }
3450                Self::append_model_message_for_entry(&mut messages, entry);
3451            }
3452
3453            return messages;
3454        }
3455
3456        let mut messages = Vec::with_capacity(path_len);
3457        for idx in 0..path_len {
3458            Self::append_model_message_for_entry(&mut messages, entry_at(idx));
3459        }
3460        messages
3461    }
3462
3463    /// Find the nearest ancestor that is a fork point (has multiple children)
3464    /// and return its children (sibling branch roots). Each sibling is represented
3465    /// by its branch-root entry ID plus the leaf ID reachable from that root.
3466    ///
3467    /// Returns `(fork_point_id, sibling_leaves)` where each sibling leaf is
3468    /// a leaf entry ID reachable through the fork point's children. The current
3469    /// leaf is included in the list.
3470    pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
3471        let children_map = self.build_children_map();
3472        let leaf_id = self.leaf_id.as_ref()?;
3473        let path = self.get_path_to_entry(leaf_id);
3474        if path.is_empty() {
3475            return None;
3476        }
3477
3478        // Walk backwards from current leaf's path to find the nearest fork point.
3479        // A fork point is any entry whose parent has >1 children, OR None (root)
3480        // with >1 root entries.
3481        // We check each entry's parent to see if the parent has multiple children.
3482        for (idx, entry_id) in path.iter().enumerate().rev() {
3483            let parent_of_entry = self
3484                .get_entry(entry_id)
3485                .and_then(|e| e.base().parent_id.clone());
3486
3487            let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
3488                continue;
3489            };
3490
3491            if siblings_at_parent.len() > 1 {
3492                // This is a fork point. Collect all leaves reachable from each sibling.
3493                let mut branches = Vec::new();
3494                let current_branch_ids: HashSet<&str> =
3495                    path[idx..].iter().map(String::as_str).collect();
3496                for sibling_root in siblings_at_parent {
3497                    let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
3498                    let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
3499                    let is_current = current_branch_ids.contains(sibling_root.as_str());
3500                    branches.push(SiblingBranch {
3501                        root_id: sibling_root.clone(),
3502                        leaf_id: leaf,
3503                        preview,
3504                        message_count: msg_count,
3505                        is_current,
3506                    });
3507                }
3508                return Some((parent_of_entry, branches));
3509            }
3510        }
3511
3512        None
3513    }
3514
3515    /// Follow the first child chain to reach the deepest leaf from a starting entry.
3516    fn deepest_leaf_from(
3517        children_map: &HashMap<Option<String>, Vec<String>>,
3518        start_id: &str,
3519    ) -> String {
3520        let mut current = start_id.to_string();
3521        let mut visited = HashSet::new();
3522        loop {
3523            if !visited.insert(current.clone()) {
3524                tracing::warn!("Cycle detected in session tree at entry: {current}");
3525                return current;
3526            }
3527            let children = children_map.get(&Some(current.clone()));
3528            match children.and_then(|c| c.first()) {
3529                Some(child) => current.clone_from(child),
3530                None => return current,
3531            }
3532        }
3533    }
3534
3535    /// Compute a short preview (first user message on the path) and the number
3536    /// of message entries for a leaf in a single parent-chain walk.
3537    fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
3538        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3539        let mut current = Some(leaf_id.to_string());
3540        let mut preview = None;
3541        let mut count = 0usize;
3542
3543        while let Some(id) = current.as_ref() {
3544            if !visited.insert(id.clone()) {
3545                tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
3546                break;
3547            }
3548            let Some(entry) = self.get_entry(id.as_str()) else {
3549                break;
3550            };
3551            if matches!(entry, SessionEntry::Message(_)) {
3552                count = count.saturating_add(1);
3553            }
3554            if let SessionEntry::Message(msg) = entry {
3555                if let SessionMessage::User { content, .. } = &msg.message {
3556                    let text = user_content_to_text(content);
3557                    let trimmed = text.trim();
3558                    if !trimmed.is_empty() {
3559                        preview = Some(if trimmed.chars().count() > 60 {
3560                            let truncated: String = trimmed.chars().take(57).collect();
3561                            format!("{truncated}...")
3562                        } else {
3563                            trimmed.to_string()
3564                        });
3565                    }
3566                }
3567            }
3568            current.clone_from(&entry.base().parent_id);
3569        }
3570
3571        (preview.unwrap_or_else(|| String::from("(empty)")), count)
3572    }
3573
3574    /// Get a summary of branches in this session.
3575    pub fn branch_summary(&self) -> BranchInfo {
3576        let leaves = self.list_leaves();
3577        let children_map = self.build_children_map();
3578
3579        // Find branch points (entries with multiple children)
3580        let branch_points: Vec<String> = self
3581            .entries
3582            .iter()
3583            .filter_map(|e| {
3584                let id = e.base_id()?;
3585                let children = children_map.get(&Some(id.clone()))?;
3586                if children.len() > 1 {
3587                    Some(id.clone())
3588                } else {
3589                    None
3590                }
3591            })
3592            .collect();
3593
3594        BranchInfo {
3595            total_entries: self.entries.len(),
3596            leaf_count: leaves.len(),
3597            branch_point_count: branch_points.len(),
3598            current_leaf: self.leaf_id.clone(),
3599            leaves,
3600            branch_points,
3601        }
3602    }
3603
3604    /// Add a label to an entry.
3605    pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
3606        // Verify target exists
3607        self.get_entry(target_id)?;
3608
3609        let id = self.next_entry_id();
3610        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
3611        let entry = SessionEntry::Label(LabelEntry {
3612            base,
3613            target_id: target_id.to_string(),
3614            label,
3615        });
3616        self.leaf_id = Some(id.clone());
3617        self.entries.push(entry);
3618        self.entry_index.insert(id.clone(), self.entries.len() - 1);
3619        self.entry_ids.insert(id.clone());
3620        self.clear_persisted_leaf_override_after_append();
3621        self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
3622        Some(id)
3623    }
3624}
3625
3626/// Summary of branches in a session.
3627#[derive(Debug, Clone)]
3628pub struct BranchInfo {
3629    pub total_entries: usize,
3630    pub leaf_count: usize,
3631    pub branch_point_count: usize,
3632    pub current_leaf: Option<String>,
3633    pub leaves: Vec<String>,
3634    pub branch_points: Vec<String>,
3635}
3636
3637/// A sibling branch at a fork point.
3638#[derive(Debug, Clone)]
3639pub struct SiblingBranch {
3640    /// Entry ID of the branch root (child of the fork point).
3641    pub root_id: String,
3642    /// Leaf entry ID reachable from this branch root.
3643    pub leaf_id: String,
3644    /// Short preview of the first user message on this branch.
3645    pub preview: String,
3646    /// Number of message entries along the path.
3647    pub message_count: usize,
3648    /// Whether the current session leaf is on this branch.
3649    pub is_current: bool,
3650}
3651
3652#[derive(Debug, Clone)]
3653struct SessionPickEntry {
3654    path: PathBuf,
3655    id: String,
3656    cwd: String,
3657    timestamp: String,
3658    message_count: u64,
3659    name: Option<String>,
3660    last_modified_ms: i64,
3661    size_bytes: u64,
3662}
3663
3664impl SessionPickEntry {
3665    fn from_meta(meta: crate::session_index::SessionMeta) -> Self {
3666        Self {
3667            path: PathBuf::from(meta.path),
3668            id: meta.id,
3669            cwd: meta.cwd,
3670            timestamp: meta.timestamp,
3671            message_count: meta.message_count,
3672            name: meta.name,
3673            last_modified_ms: meta.last_modified_ms,
3674            size_bytes: meta.size_bytes,
3675        }
3676    }
3677
3678    fn to_meta(&self) -> crate::session_index::SessionMeta {
3679        crate::session_index::SessionMeta {
3680            path: self.path.display().to_string(),
3681            id: self.id.clone(),
3682            cwd: self.cwd.clone(),
3683            timestamp: self.timestamp.clone(),
3684            message_count: self.message_count,
3685            last_modified_ms: self.last_modified_ms,
3686            size_bytes: self.size_bytes,
3687            name: self.name.clone(),
3688        }
3689    }
3690}
3691
3692fn indexed_session_path_is_missing(path: &Path) -> bool {
3693    match path.try_exists() {
3694        Ok(exists) => !exists,
3695        Err(err) => {
3696            tracing::warn!(
3697                path = %path.display(),
3698                error = %err,
3699                "Failed to determine whether indexed session path exists; deferring prune"
3700            );
3701            false
3702        }
3703    }
3704}
3705
3706fn split_indexed_session_entries(
3707    metas: Vec<crate::session_index::SessionMeta>,
3708) -> (Vec<SessionPickEntry>, Vec<PathBuf>) {
3709    let mut entries = Vec::new();
3710    let mut missing_paths = Vec::new();
3711
3712    for meta in metas {
3713        let path = PathBuf::from(&meta.path);
3714        if indexed_session_path_is_missing(&path) {
3715            missing_paths.push(path);
3716            continue;
3717        }
3718
3719        entries.push(SessionPickEntry::from_meta(meta));
3720    }
3721
3722    (entries, missing_paths)
3723}
3724
3725fn prune_session_index_path(index: &SessionIndex, path: &Path, reason: &'static str) {
3726    if let Err(err) = index.delete_session_path(path) {
3727        tracing::warn!(
3728            path = %path.display(),
3729            error = %err,
3730            reason,
3731            "Failed to prune session from index"
3732        );
3733    }
3734}
3735
3736fn can_reuse_known_entry(known_entry: &SessionPickEntry, disk_ms: i64, disk_size: u64) -> bool {
3737    (known_entry.last_modified_ms, known_entry.size_bytes)
3738        .cmp(&(disk_ms, disk_size))
3739        .is_eq()
3740}
3741
3742struct ScanSessionsResult {
3743    entries: Vec<SessionPickEntry>,
3744    refreshed_entries: Vec<SessionPickEntry>,
3745    failed_paths: Vec<PathBuf>,
3746}
3747
3748fn refresh_session_index_entries(
3749    index: &SessionIndex,
3750    entries: &[SessionPickEntry],
3751    reason: &'static str,
3752) {
3753    for entry in entries {
3754        if let Err(err) = index.upsert_session_meta(entry.to_meta()) {
3755            tracing::warn!(
3756                path = %entry.path.display(),
3757                error = %err,
3758                reason,
3759                "Failed to refresh session metadata in index"
3760            );
3761        }
3762    }
3763}
3764
3765fn merge_scanned_session_entries(
3766    by_path: &mut HashMap<PathBuf, SessionPickEntry>,
3767    entries: Vec<SessionPickEntry>,
3768) {
3769    for entry in entries {
3770        // Disk is the source of truth for session metadata. The scan either
3771        // reparsed the file or confirmed the cached snapshot still matches, so
3772        // it should always win over the earlier index view for that path.
3773        by_path.insert(entry.path.clone(), entry);
3774    }
3775}
3776
3777async fn scan_sessions_on_disk(
3778    project_session_dir: &Path,
3779    known: Vec<SessionPickEntry>,
3780) -> Result<ScanSessionsResult> {
3781    let path_buf = project_session_dir.to_path_buf();
3782    let (tx, mut rx) = oneshot::channel();
3783
3784    let handle = thread::Builder::new()
3785        .name("session-scan".to_string())
3786        .spawn(move || {
3787            let res = (|| -> Result<ScanSessionsResult> {
3788                let mut entries = Vec::new();
3789                let mut refreshed_entries = Vec::new();
3790                let mut failed_paths = Vec::new();
3791                let dir_entries = std::fs::read_dir(&path_buf)
3792                    .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
3793
3794                let known_map: HashMap<PathBuf, SessionPickEntry> =
3795                    known.into_iter().map(|e| (e.path.clone(), e)).collect();
3796
3797                for entry in dir_entries {
3798                    let entry =
3799                        entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
3800                    let path = entry.path();
3801                    if is_session_file_path(&path) {
3802                        // Optimization: if we already have this file indexed and both mtime and
3803                        // size match, reuse indexed metadata to avoid a full parse.
3804                        if let Ok((disk_ms, disk_size)) = session_file_stats(&path) {
3805                            if let Some(known_entry) = known_map.get(&path) {
3806                                if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
3807                                    entries.push(known_entry.clone());
3808                                    continue;
3809                                }
3810                            }
3811                        }
3812
3813                        match load_session_meta(&path) {
3814                            Ok(meta) => {
3815                                refreshed_entries.push(meta.clone());
3816                                entries.push(meta);
3817                            }
3818                            Err(_) => failed_paths.push(path),
3819                        }
3820                    }
3821                }
3822                Ok(ScanSessionsResult {
3823                    entries,
3824                    refreshed_entries,
3825                    failed_paths,
3826                })
3827            })();
3828            let cx = AgentCx::for_request();
3829            let _ = tx.send(cx.cx(), res);
3830        })
3831        .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
3832
3833    let cx = AgentCx::for_request();
3834    let recv_result = rx.recv(cx.cx()).await;
3835    finish_worker_result(handle, recv_result, "Scan task cancelled")
3836}
3837
3838fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
3839    match path.extension().and_then(|ext| ext.to_str()) {
3840        Some("jsonl") => load_session_meta_jsonl(path),
3841        #[cfg(feature = "sqlite-sessions")]
3842        Some("sqlite") => load_session_meta_sqlite(path),
3843        _ => Err(Error::session(format!(
3844            "Unsupported session file extension: {}",
3845            path.display()
3846        ))),
3847    }
3848}
3849
3850#[derive(Deserialize)]
3851struct PartialEntry {
3852    #[serde(default)]
3853    r#type: String,
3854    #[serde(default)]
3855    name: Option<String>,
3856}
3857
3858fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
3859    let file = std::fs::File::open(path)
3860        .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
3861    let mut reader = BufReader::new(file);
3862
3863    let Some(header_line) = read_capped_utf8_line(&mut reader)
3864        .map_err(|e| Error::session(format!("Failed to read header: {e}")))?
3865    else {
3866        return Err(Error::session("Empty session file"));
3867    };
3868
3869    let header: SessionHeader =
3870        serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
3871    header
3872        .validate()
3873        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3874
3875    let mut message_count = 0u64;
3876    let mut name = None;
3877    loop {
3878        let Some(line_content) = read_capped_utf8_line(&mut reader)
3879            .map_err(|e| Error::session(format!("Failed to read session entry: {e}")))?
3880        else {
3881            break;
3882        };
3883        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
3884            match entry.r#type.as_str() {
3885                "message" => message_count += 1,
3886                "session_info" if entry.name.is_some() => {
3887                    name = entry.name;
3888                }
3889                _ => {}
3890            }
3891        }
3892    }
3893
3894    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3895
3896    Ok(SessionPickEntry {
3897        path: path.to_path_buf(),
3898        id: header.id,
3899        cwd: header.cwd,
3900        timestamp: header.timestamp,
3901        message_count,
3902        name,
3903        last_modified_ms,
3904        size_bytes,
3905    })
3906}
3907
3908#[cfg(feature = "sqlite-sessions")]
3909fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
3910    let meta = futures::executor::block_on(async {
3911        crate::session_sqlite::load_session_meta(path).await
3912    })?;
3913    let header = meta.header;
3914    header
3915        .validate()
3916        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3917
3918    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3919
3920    Ok(SessionPickEntry {
3921        path: path.to_path_buf(),
3922        id: header.id,
3923        cwd: header.cwd,
3924        timestamp: header.timestamp,
3925        message_count: meta.message_count,
3926        name: meta.name,
3927        last_modified_ms,
3928        size_bytes,
3929    })
3930}
3931
3932// ============================================================================
3933// Session Header
3934// ============================================================================
3935
3936/// Session file header.
3937#[derive(Debug, Clone, Serialize, Deserialize)]
3938#[serde(rename_all = "camelCase")]
3939pub struct SessionHeader {
3940    pub r#type: String,
3941    #[serde(skip_serializing_if = "Option::is_none")]
3942    pub version: Option<u8>,
3943    pub id: String,
3944    pub timestamp: String,
3945    pub cwd: String,
3946    #[serde(skip_serializing_if = "Option::is_none")]
3947    pub provider: Option<String>,
3948    #[serde(skip_serializing_if = "Option::is_none")]
3949    pub model_id: Option<String>,
3950    #[serde(skip_serializing_if = "Option::is_none")]
3951    pub thinking_level: Option<String>,
3952    #[serde(skip_serializing_if = "Option::is_none")]
3953    pub fallback_provider: Option<String>,
3954    #[serde(skip_serializing_if = "Option::is_none")]
3955    pub fallback_model_id: Option<String>,
3956    #[serde(skip_serializing_if = "Option::is_none")]
3957    pub fallback_thinking_level: Option<String>,
3958    #[serde(skip_serializing_if = "Option::is_none", rename = "leafId")]
3959    pub current_leaf: Option<String>,
3960    #[serde(
3961        skip_serializing_if = "Option::is_none",
3962        rename = "branchedFrom",
3963        alias = "parentSession"
3964    )]
3965    pub parent_session: Option<String>,
3966}
3967
3968impl SessionHeader {
3969    pub fn new() -> Self {
3970        let now = chrono::Utc::now();
3971        Self {
3972            r#type: "session".to_string(),
3973            version: Some(SESSION_VERSION),
3974            id: uuid::Uuid::new_v4().to_string(),
3975            timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3976            cwd: std::env::current_dir()
3977                .map(|p| p.display().to_string())
3978                .unwrap_or_default(),
3979            provider: None,
3980            model_id: None,
3981            thinking_level: None,
3982            fallback_provider: None,
3983            fallback_model_id: None,
3984            fallback_thinking_level: None,
3985            current_leaf: None,
3986            parent_session: None,
3987        }
3988    }
3989
3990    fn branch_fallback_model_fields(&self) -> (Option<String>, Option<String>) {
3991        (
3992            self.fallback_provider
3993                .clone()
3994                .or_else(|| self.provider.clone()),
3995            self.fallback_model_id
3996                .clone()
3997                .or_else(|| self.model_id.clone()),
3998        )
3999    }
4000
4001    fn materialize_branch_fallbacks(&mut self) -> bool {
4002        // Track mutations as booleans, then materialize them.
4003        // This pattern avoids clippy::useless_let_if_seq while
4004        // remaining readable for multiple independent conditions.
4005        let set_provider = self.fallback_provider.is_none() && self.provider.is_some();
4006        let set_model_id = self.fallback_model_id.is_none() && self.model_id.is_some();
4007        let set_thinking = self.fallback_thinking_level.is_none() && self.thinking_level.is_some();
4008
4009        if set_provider {
4010            self.fallback_provider = self.provider.clone();
4011        }
4012        if set_model_id {
4013            self.fallback_model_id = self.model_id.clone();
4014        }
4015        if set_thinking {
4016            self.fallback_thinking_level = self.thinking_level.clone();
4017        }
4018
4019        set_provider || set_model_id || set_thinking
4020    }
4021
4022    pub fn validate(&self) -> std::result::Result<(), String> {
4023        if !self.r#type.eq("session") {
4024            return Err(format!("type must be `session`, got `{}`", self.r#type));
4025        }
4026        if !self.version.eq(&Some(SESSION_VERSION)) {
4027            return Err(format!(
4028                "version must be {SESSION_VERSION}, got {}",
4029                self.version
4030                    .map_or_else(|| "none".to_string(), |value| value.to_string())
4031            ));
4032        }
4033        if self.id.trim().is_empty() {
4034            return Err("id must be non-empty".to_string());
4035        }
4036        if self.timestamp.trim().is_empty() {
4037            return Err("timestamp must be non-empty".to_string());
4038        }
4039        if self.cwd.trim().is_empty() {
4040            return Err("cwd must be non-empty".to_string());
4041        }
4042        Ok(())
4043    }
4044
4045    pub fn is_valid(&self) -> bool {
4046        self.validate().is_ok()
4047    }
4048}
4049
4050impl Default for SessionHeader {
4051    fn default() -> Self {
4052        Self::new()
4053    }
4054}
4055
4056// ============================================================================
4057// Session Entries
4058// ============================================================================
4059
4060/// A session entry.
4061#[derive(Debug, Clone, Serialize, Deserialize)]
4062#[serde(tag = "type", rename_all = "snake_case")]
4063pub enum SessionEntry {
4064    Message(MessageEntry),
4065    ModelChange(ModelChangeEntry),
4066    ThinkingLevelChange(ThinkingLevelChangeEntry),
4067    Compaction(CompactionEntry),
4068    BranchSummary(BranchSummaryEntry),
4069    Label(LabelEntry),
4070    SessionInfo(SessionInfoEntry),
4071    Custom(CustomEntry),
4072}
4073
4074impl SessionEntry {
4075    pub const fn base(&self) -> &EntryBase {
4076        match self {
4077            Self::Message(e) => &e.base,
4078            Self::ModelChange(e) => &e.base,
4079            Self::ThinkingLevelChange(e) => &e.base,
4080            Self::Compaction(e) => &e.base,
4081            Self::BranchSummary(e) => &e.base,
4082            Self::Label(e) => &e.base,
4083            Self::SessionInfo(e) => &e.base,
4084            Self::Custom(e) => &e.base,
4085        }
4086    }
4087
4088    pub const fn base_mut(&mut self) -> &mut EntryBase {
4089        match self {
4090            Self::Message(e) => &mut e.base,
4091            Self::ModelChange(e) => &mut e.base,
4092            Self::ThinkingLevelChange(e) => &mut e.base,
4093            Self::Compaction(e) => &mut e.base,
4094            Self::BranchSummary(e) => &mut e.base,
4095            Self::Label(e) => &mut e.base,
4096            Self::SessionInfo(e) => &mut e.base,
4097            Self::Custom(e) => &mut e.base,
4098        }
4099    }
4100
4101    pub const fn base_id(&self) -> Option<&String> {
4102        self.base().id.as_ref()
4103    }
4104}
4105
4106/// Base entry fields.
4107#[derive(Debug, Clone, Serialize, Deserialize)]
4108#[serde(rename_all = "camelCase")]
4109pub struct EntryBase {
4110    #[serde(skip_serializing_if = "Option::is_none")]
4111    pub id: Option<String>,
4112    #[serde(skip_serializing_if = "Option::is_none")]
4113    pub parent_id: Option<String>,
4114    pub timestamp: String,
4115}
4116
4117impl EntryBase {
4118    pub fn new(parent_id: Option<String>, id: String) -> Self {
4119        Self {
4120            id: Some(id),
4121            parent_id,
4122            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4123        }
4124    }
4125}
4126
4127/// Message entry.
4128#[derive(Debug, Clone, Serialize, Deserialize)]
4129#[serde(rename_all = "camelCase")]
4130pub struct MessageEntry {
4131    #[serde(flatten)]
4132    pub base: EntryBase,
4133    pub message: SessionMessage,
4134}
4135
4136/// Session message payload.
4137#[derive(Debug, Clone, Serialize, Deserialize)]
4138#[serde(
4139    tag = "role",
4140    rename_all = "camelCase",
4141    rename_all_fields = "camelCase"
4142)]
4143pub enum SessionMessage {
4144    User {
4145        content: UserContent,
4146        #[serde(skip_serializing_if = "Option::is_none")]
4147        timestamp: Option<i64>,
4148    },
4149    Assistant {
4150        #[serde(flatten)]
4151        message: AssistantMessage,
4152    },
4153    ToolResult {
4154        tool_call_id: String,
4155        tool_name: String,
4156        content: Vec<ContentBlock>,
4157        #[serde(skip_serializing_if = "Option::is_none")]
4158        details: Option<Value>,
4159        #[serde(default)]
4160        is_error: bool,
4161        #[serde(skip_serializing_if = "Option::is_none")]
4162        timestamp: Option<i64>,
4163    },
4164    Custom {
4165        custom_type: String,
4166        content: String,
4167        #[serde(default)]
4168        display: bool,
4169        #[serde(skip_serializing_if = "Option::is_none")]
4170        details: Option<Value>,
4171        #[serde(skip_serializing_if = "Option::is_none")]
4172        timestamp: Option<i64>,
4173    },
4174    BashExecution {
4175        command: String,
4176        output: String,
4177        exit_code: i32,
4178        #[serde(skip_serializing_if = "Option::is_none")]
4179        cancelled: Option<bool>,
4180        #[serde(skip_serializing_if = "Option::is_none")]
4181        truncated: Option<bool>,
4182        #[serde(skip_serializing_if = "Option::is_none")]
4183        full_output_path: Option<String>,
4184        #[serde(skip_serializing_if = "Option::is_none")]
4185        timestamp: Option<i64>,
4186        #[serde(flatten)]
4187        extra: HashMap<String, Value>,
4188    },
4189    BranchSummary {
4190        summary: String,
4191        from_id: String,
4192    },
4193    CompactionSummary {
4194        summary: String,
4195        tokens_before: u64,
4196    },
4197}
4198
4199impl From<Message> for SessionMessage {
4200    fn from(message: Message) -> Self {
4201        match message {
4202            Message::User(user) => Self::User {
4203                content: user.content,
4204                timestamp: Some(user.timestamp),
4205            },
4206            Message::Assistant(assistant) => Self::Assistant {
4207                message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
4208            },
4209            Message::ToolResult(result) => {
4210                let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
4211                Self::ToolResult {
4212                    tool_call_id: result.tool_call_id,
4213                    tool_name: result.tool_name,
4214                    content: result.content,
4215                    details: result.details,
4216                    is_error: result.is_error,
4217                    timestamp: Some(result.timestamp),
4218                }
4219            }
4220            Message::Custom(custom) => Self::Custom {
4221                custom_type: custom.custom_type,
4222                content: custom.content,
4223                display: custom.display,
4224                details: custom.details,
4225                timestamp: Some(custom.timestamp),
4226            },
4227        }
4228    }
4229}
4230
4231/// Model change entry.
4232#[derive(Debug, Clone, Serialize, Deserialize)]
4233#[serde(rename_all = "camelCase")]
4234pub struct ModelChangeEntry {
4235    #[serde(flatten)]
4236    pub base: EntryBase,
4237    pub provider: String,
4238    pub model_id: String,
4239}
4240
4241/// Thinking level change entry.
4242#[derive(Debug, Clone, Serialize, Deserialize)]
4243#[serde(rename_all = "camelCase")]
4244pub struct ThinkingLevelChangeEntry {
4245    #[serde(flatten)]
4246    pub base: EntryBase,
4247    pub thinking_level: String,
4248}
4249
4250/// Compaction entry.
4251#[derive(Debug, Clone, Serialize, Deserialize)]
4252#[serde(rename_all = "camelCase")]
4253pub struct CompactionEntry {
4254    #[serde(flatten)]
4255    pub base: EntryBase,
4256    pub summary: String,
4257    pub first_kept_entry_id: String,
4258    pub tokens_before: u64,
4259    #[serde(skip_serializing_if = "Option::is_none")]
4260    pub details: Option<serde_json::Value>,
4261    #[serde(skip_serializing_if = "Option::is_none")]
4262    pub from_hook: Option<bool>,
4263}
4264
4265/// Branch summary entry.
4266#[derive(Debug, Clone, Serialize, Deserialize)]
4267#[serde(rename_all = "camelCase")]
4268pub struct BranchSummaryEntry {
4269    #[serde(flatten)]
4270    pub base: EntryBase,
4271    pub from_id: String,
4272    pub summary: String,
4273    #[serde(skip_serializing_if = "Option::is_none")]
4274    pub details: Option<serde_json::Value>,
4275    #[serde(skip_serializing_if = "Option::is_none")]
4276    pub from_hook: Option<bool>,
4277}
4278
4279/// Label entry.
4280#[derive(Debug, Clone, Serialize, Deserialize)]
4281#[serde(rename_all = "camelCase")]
4282pub struct LabelEntry {
4283    #[serde(flatten)]
4284    pub base: EntryBase,
4285    pub target_id: String,
4286    #[serde(skip_serializing_if = "Option::is_none")]
4287    pub label: Option<String>,
4288}
4289
4290/// Session info entry.
4291#[derive(Debug, Clone, Serialize, Deserialize)]
4292#[serde(rename_all = "camelCase")]
4293pub struct SessionInfoEntry {
4294    #[serde(flatten)]
4295    pub base: EntryBase,
4296    #[serde(skip_serializing_if = "Option::is_none")]
4297    pub name: Option<String>,
4298}
4299
4300/// Custom entry.
4301#[derive(Debug, Clone, Serialize, Deserialize)]
4302#[serde(rename_all = "camelCase")]
4303pub struct CustomEntry {
4304    #[serde(flatten)]
4305    pub base: EntryBase,
4306    pub custom_type: String,
4307    #[serde(skip_serializing_if = "Option::is_none")]
4308    pub data: Option<serde_json::Value>,
4309}
4310
4311// ============================================================================
4312// Utilities
4313// ============================================================================
4314
4315/// Encode a working directory path for use in session directory names.
4316pub fn encode_cwd(path: &std::path::Path) -> String {
4317    let s = path.display().to_string();
4318    let s = s.trim_start_matches(['/', '\\']);
4319    let s = s.replace(['/', '\\', ':'], "-");
4320    format!("--{s}--")
4321}
4322
4323fn infer_session_root_from_path(path: &Path) -> Option<PathBuf> {
4324    let parent = path.parent()?.to_path_buf();
4325    if parent
4326        .file_name()
4327        .and_then(|name| name.to_str())
4328        .is_some_and(|name| name.starts_with("--") && name.ends_with("--") && name.len() > 4)
4329    {
4330        return parent.parent().map(PathBuf::from).or(Some(parent));
4331    }
4332    Some(parent)
4333}
4334
4335pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
4336    match message {
4337        SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
4338            content: content.clone(),
4339            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4340        })),
4341        SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
4342        SessionMessage::ToolResult {
4343            tool_call_id,
4344            tool_name,
4345            content,
4346            details,
4347            is_error,
4348            timestamp,
4349        } => Some(Message::tool_result(ToolResultMessage {
4350            tool_call_id: tool_call_id.clone(),
4351            tool_name: tool_name.clone(),
4352            content: content.clone(),
4353            details: details.clone(),
4354            is_error: *is_error,
4355            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4356        })),
4357        SessionMessage::Custom {
4358            custom_type,
4359            content,
4360            display,
4361            details,
4362            timestamp,
4363        } => Some(Message::Custom(crate::model::CustomMessage {
4364            content: content.clone(),
4365            custom_type: custom_type.clone(),
4366            display: *display,
4367            details: details.clone(),
4368            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4369        })),
4370        SessionMessage::BashExecution {
4371            command,
4372            output,
4373            exit_code,
4374            cancelled,
4375            truncated,
4376            full_output_path,
4377            timestamp,
4378            extra,
4379        } => {
4380            if extra
4381                .get("excludeFromContext")
4382                .and_then(Value::as_bool)
4383                .is_some_and(|v| v)
4384            {
4385                return None;
4386            }
4387            let text = bash_execution_to_text(
4388                command,
4389                output,
4390                *exit_code,
4391                cancelled.unwrap_or(false),
4392                truncated.unwrap_or(false),
4393                full_output_path.as_deref(),
4394            );
4395            Some(Message::User(UserMessage {
4396                content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
4397                timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4398            }))
4399        }
4400        SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
4401            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4402                "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
4403            )))]),
4404            timestamp: chrono::Utc::now().timestamp_millis(),
4405        })),
4406        SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
4407            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4408                "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
4409            )))]),
4410            timestamp: chrono::Utc::now().timestamp_millis(),
4411        })),
4412    }
4413}
4414
4415const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
4416const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
4417
4418const BRANCH_SUMMARY_PREFIX: &str =
4419    "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
4420const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
4421
4422pub(crate) fn bash_execution_to_text(
4423    command: &str,
4424    output: &str,
4425    exit_code: i32,
4426    cancelled: bool,
4427    truncated: bool,
4428    full_output_path: Option<&str>,
4429) -> String {
4430    let mut text = format!("Ran `{command}`\n");
4431    if output.is_empty() {
4432        text.push_str("(no output)");
4433    } else {
4434        text.push_str("```\n");
4435        text.push_str(output);
4436        if !output.ends_with('\n') {
4437            text.push('\n');
4438        }
4439        text.push_str("```");
4440    }
4441
4442    if cancelled {
4443        text.push_str("\n\n(command cancelled)");
4444    } else if exit_code != 0 {
4445        let _ = write!(text, "\n\nCommand exited with code {exit_code}");
4446    }
4447
4448    if truncated {
4449        if let Some(path) = full_output_path {
4450            let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
4451        } else {
4452            text.push_str("\n\n[Output truncated]");
4453        }
4454    }
4455
4456    text
4457}
4458
4459/// Render session header and entries as a standalone HTML document.
4460///
4461/// Shared implementation used by both `Session::to_html()` and
4462/// `ExportSnapshot::to_html()`.
4463#[allow(clippy::too_many_lines)]
4464fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
4465    let mut html = String::new();
4466    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
4467    html.push_str("<title>Pi Session</title>");
4468    html.push_str("<style>");
4469    html.push_str(
4470        "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
4471            h1{margin:0 0 8px 0;}
4472            .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
4473            .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
4474            .msg.user{border-left:4px solid #4fc3f7;}
4475            .msg.assistant{border-left:4px solid #81c784;}
4476            .msg.tool{border-left:4px solid #ffb74d;}
4477            .msg.system{border-left:4px solid #ef9a9a;}
4478            .role{font-weight:600;margin-bottom:8px;}
4479            pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
4480            .thinking summary{cursor:pointer;}
4481            img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
4482            .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
4483            ",
4484    );
4485    html.push_str("</style></head><body>");
4486
4487    let _ = write!(
4488        html,
4489        "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
4490        escape_html(&header.id),
4491        escape_html(&header.timestamp),
4492        escape_html(&header.cwd)
4493    );
4494
4495    for entry in entries {
4496        match entry {
4497            SessionEntry::Message(message) => {
4498                html.push_str(&render_session_message(&message.message));
4499            }
4500            SessionEntry::ModelChange(change) => {
4501                let _ = write!(
4502                    html,
4503                    "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
4504                    escape_html(&change.provider),
4505                    escape_html(&change.model_id)
4506                );
4507            }
4508            SessionEntry::ThinkingLevelChange(change) => {
4509                let _ = write!(
4510                    html,
4511                    "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
4512                    escape_html(&change.thinking_level)
4513                );
4514            }
4515            SessionEntry::Compaction(compaction) => {
4516                let _ = write!(
4517                    html,
4518                    "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4519                    escape_html(&compaction.summary)
4520                );
4521            }
4522            SessionEntry::BranchSummary(summary) => {
4523                let _ = write!(
4524                    html,
4525                    "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4526                    escape_html(&summary.summary)
4527                );
4528            }
4529            SessionEntry::SessionInfo(info) => {
4530                if let Some(name) = &info.name {
4531                    let _ = write!(
4532                        html,
4533                        "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
4534                        escape_html(name)
4535                    );
4536                }
4537            }
4538            SessionEntry::Custom(custom) => {
4539                let _ = write!(
4540                    html,
4541                    "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
4542                    escape_html(&custom.custom_type)
4543                );
4544            }
4545            SessionEntry::Label(_) => {}
4546        }
4547    }
4548
4549    html.push_str("</body></html>");
4550    html
4551}
4552
4553fn render_session_message(message: &SessionMessage) -> String {
4554    match message {
4555        SessionMessage::User { content, .. } => {
4556            let mut html = String::new();
4557            html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
4558            html.push_str(&render_user_content(content));
4559            html.push_str("</div>");
4560            html
4561        }
4562        SessionMessage::Assistant { message } => {
4563            let mut html = String::new();
4564            html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
4565            html.push_str(&render_blocks(&message.content));
4566            html.push_str("</div>");
4567            html
4568        }
4569        SessionMessage::ToolResult {
4570            tool_name,
4571            content,
4572            is_error,
4573            details,
4574            ..
4575        } => {
4576            let mut html = String::new();
4577            let role = if *is_error { "Tool Error" } else { "Tool" };
4578            let _ = write!(
4579                html,
4580                "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
4581                role,
4582                escape_html(tool_name)
4583            );
4584            html.push_str(&render_blocks(content));
4585            if let Some(details) = details {
4586                let details_str =
4587                    serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
4588                let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
4589            }
4590            html.push_str("</div>");
4591            html
4592        }
4593        SessionMessage::Custom {
4594            custom_type,
4595            content,
4596            ..
4597        } => {
4598            let mut html = String::new();
4599            let _ = write!(
4600                html,
4601                "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
4602                escape_html(custom_type),
4603                escape_html(content)
4604            );
4605            html
4606        }
4607        SessionMessage::BashExecution {
4608            command,
4609            output,
4610            exit_code,
4611            ..
4612        } => {
4613            let mut html = String::new();
4614            let _ = write!(
4615                html,
4616                "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
4617                escape_html(command),
4618                escape_html(output)
4619            );
4620            html
4621        }
4622        SessionMessage::BranchSummary { summary, .. } => {
4623            format!(
4624                "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4625                escape_html(summary)
4626            )
4627        }
4628        SessionMessage::CompactionSummary { summary, .. } => {
4629            format!(
4630                "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4631                escape_html(summary)
4632            )
4633        }
4634    }
4635}
4636
4637fn render_user_content(content: &UserContent) -> String {
4638    match content {
4639        UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
4640        UserContent::Blocks(blocks) => render_blocks(blocks),
4641    }
4642}
4643
4644fn render_blocks(blocks: &[ContentBlock]) -> String {
4645    let mut html = String::new();
4646    for block in blocks {
4647        match block {
4648            ContentBlock::Text(text) => {
4649                let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
4650            }
4651            ContentBlock::Thinking(thinking) => {
4652                let _ = write!(
4653                    html,
4654                    "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
4655                    escape_html(&thinking.thinking)
4656                );
4657            }
4658            ContentBlock::Image(image) => {
4659                let _ = write!(
4660                    html,
4661                    "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
4662                    escape_html(&image.mime_type),
4663                    escape_html(&image.data)
4664                );
4665            }
4666            ContentBlock::ToolCall(tool_call) => {
4667                let args = serde_json::to_string_pretty(&tool_call.arguments)
4668                    .unwrap_or_else(|_| tool_call.arguments.to_string());
4669                let _ = write!(
4670                    html,
4671                    "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
4672                    escape_html(&tool_call.name),
4673                    escape_html(&args)
4674                );
4675            }
4676            ContentBlock::RedactedThinking(_) => {
4677                // Render a compact placeholder so transcript readers can tell
4678                // a redacted-thinking block was present without exposing the
4679                // opaque payload (which is provider-internal safety state).
4680                html.push_str(
4681                    "<details class=\"thinking\"><summary>Thinking (redacted)</summary></details>",
4682                );
4683            }
4684        }
4685    }
4686    html
4687}
4688
4689fn escape_html(input: &str) -> String {
4690    let mut escaped = String::with_capacity(input.len());
4691    for ch in input.chars() {
4692        match ch {
4693            '&' => escaped.push_str("&amp;"),
4694            '<' => escaped.push_str("&lt;"),
4695            '>' => escaped.push_str("&gt;"),
4696            '"' => escaped.push_str("&quot;"),
4697            '\'' => escaped.push_str("&#39;"),
4698            _ => escaped.push(ch),
4699        }
4700    }
4701    escaped
4702}
4703
4704fn user_content_to_text(content: &UserContent) -> String {
4705    match content {
4706        UserContent::Text(text) => text.clone(),
4707        UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
4708    }
4709}
4710
4711fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
4712    let mut output = String::new();
4713    for block in blocks {
4714        match block {
4715            ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
4716            ContentBlock::Image(image) => {
4717                push_line(&mut output, &format!("[image: {}]", image.mime_type));
4718            }
4719            ContentBlock::Thinking(thinking_block) => {
4720                push_line(&mut output, &thinking_block.thinking);
4721            }
4722            ContentBlock::ToolCall(call) => {
4723                push_line(&mut output, &format!("[tool call: {}]", call.name));
4724            }
4725            ContentBlock::RedactedThinking(_) => {
4726                push_line(&mut output, "[thinking: redacted]");
4727            }
4728        }
4729    }
4730    output
4731}
4732
4733fn push_line(out: &mut String, line: &str) {
4734    if !out.is_empty() {
4735        out.push('\n');
4736    }
4737    out.push_str(line);
4738}
4739
4740fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
4741    entries
4742        .iter()
4743        .filter_map(|e| e.base_id().cloned())
4744        .collect()
4745}
4746
4747/// Minimum entry count to activate parallel deserialization (Gap E).
4748const PARALLEL_THRESHOLD: usize = 512;
4749/// Number of JSONL lines deserialized per batch in the blocking open path.
4750const JSONL_PARSE_BATCH_SIZE: usize = 8192;
4751
4752/// Parse a JSONL session file on the current (blocking) thread.
4753///
4754/// Combines Gap E (parallel deserialization) and Gap F (single-pass
4755/// finalization) for the fastest possible open path.
4756#[allow(clippy::too_many_lines)]
4757fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4758    let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
4759    let mut reader = std::io::BufReader::new(file);
4760
4761    let Some(header_line) =
4762        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4763    else {
4764        return Err(crate::Error::session("Empty session file"));
4765    };
4766    if header_line.trim().is_empty() {
4767        return Err(crate::Error::session("Empty session file"));
4768    }
4769
4770    // Parse header (first line)
4771    let header: SessionHeader = serde_json::from_str(&header_line)
4772        .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
4773    header
4774        .validate()
4775        .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
4776    let (header, normalized_header_dirty) = normalize_loaded_header(header);
4777
4778    let mut entries = Vec::new();
4779    let mut diagnostics = SessionOpenDiagnostics::default();
4780
4781    // Gap E: parallel deserialization for large sessions.
4782    // Batch processing to bound memory usage while allowing parallelism.
4783    let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
4784
4785    let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
4786    let mut current_line_num = 2; // Header is line 1
4787
4788    loop {
4789        line_batch.clear();
4790        let mut batch_eof = false;
4791
4792        for _ in 0..JSONL_PARSE_BATCH_SIZE {
4793            match read_capped_utf8_line(&mut reader) {
4794                Ok(None) => {
4795                    batch_eof = true;
4796                    break;
4797                }
4798                Ok(Some(line)) => {
4799                    if !line.trim().is_empty() {
4800                        line_batch.push((current_line_num, line));
4801                    }
4802                }
4803                Err(e) => {
4804                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4805                        line_number: current_line_num,
4806                        error: format!("IO error reading line: {e}"),
4807                    });
4808                }
4809            }
4810            current_line_num += 1;
4811        }
4812
4813        if line_batch.is_empty() {
4814            if batch_eof {
4815                break;
4816            }
4817            continue;
4818        }
4819
4820        if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
4821            let chunk_size = (line_batch.len() / num_threads).max(64);
4822
4823            let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
4824                std::thread::scope(|s| {
4825                    line_batch
4826                        .chunks(chunk_size)
4827                        .map(|chunk| {
4828                            s.spawn(move || {
4829                                let mut ok = Vec::with_capacity(chunk.len());
4830                                let mut skip = Vec::new();
4831                                for (line_num, line) in chunk {
4832                                    match serde_json::from_str::<SessionEntry>(line) {
4833                                        Ok(entry) => ok.push(entry),
4834                                        Err(e) => {
4835                                            skip.push(SessionOpenSkippedEntry {
4836                                                line_number: *line_num,
4837                                                error: e.to_string(),
4838                                            });
4839                                        }
4840                                    }
4841                                }
4842                                (ok, skip)
4843                            })
4844                        })
4845                        .collect::<Vec<_>>()
4846                        .into_iter()
4847                        .map(|h| {
4848                            h.join().map_err(|panic_payload| {
4849                                let panic_message =
4850                                    panic_payload.downcast_ref::<String>().map_or_else(
4851                                        || {
4852                                            panic_payload.downcast_ref::<&str>().map_or_else(
4853                                                || "unknown panic payload".to_string(),
4854                                                |message| (*message).to_string(),
4855                                            )
4856                                        },
4857                                        std::clone::Clone::clone,
4858                                    );
4859                                Error::session(format!(
4860                                    "parallel session parse worker panicked: {panic_message}"
4861                                ))
4862                            })
4863                        })
4864                        .collect()
4865                });
4866            let chunk_results = chunk_results?;
4867
4868            for (chunk_entries, chunk_skipped) in chunk_results {
4869                entries.extend(chunk_entries);
4870                diagnostics.skipped_entries.extend(chunk_skipped);
4871            }
4872        } else {
4873            // Sequential path
4874            for (line_num, line) in &line_batch {
4875                match serde_json::from_str::<SessionEntry>(line) {
4876                    Ok(entry) => entries.push(entry),
4877                    Err(e) => {
4878                        diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4879                            line_number: *line_num,
4880                            error: e.to_string(),
4881                        });
4882                    }
4883                }
4884            }
4885        }
4886
4887        if batch_eof {
4888            break;
4889        }
4890    }
4891
4892    // --- Single-pass load finalization (Gap F) ---
4893    let finalized = finalize_loaded_entries(&mut entries);
4894    for orphan in &finalized.orphans {
4895        diagnostics
4896            .orphaned_parent_links
4897            .push(SessionOpenOrphanedParentLink {
4898                entry_id: orphan.0.clone(),
4899                missing_parent_id: orphan.1.clone(),
4900            });
4901    }
4902
4903    let entry_count = entries.len();
4904    let natural_leaf_id = finalized.leaf_id.clone();
4905    let leaf_id = resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
4906
4907    Ok((
4908        Session {
4909            header,
4910            entries,
4911            path: Some(path_buf),
4912            leaf_id: leaf_id.clone(),
4913            session_dir: None,
4914            store_kind: SessionStoreKind::Jsonl,
4915            entry_ids: finalized.entry_ids,
4916            is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
4917            entry_index: finalized.entry_index,
4918            cached_message_count: finalized.message_count,
4919            cached_name: finalized.name,
4920            autosave_queue: AutosaveQueue::new(),
4921            autosave_durability: AutosaveDurabilityMode::from_env(),
4922            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
4923            header_dirty: normalized_header_dirty,
4924            appends_since_checkpoint: 0,
4925            v2_sidecar_root: None,
4926            v2_partial_hydration: false,
4927            v2_resume_mode: None,
4928            v2_sidecar_stale: false,
4929            v2_message_count_offset: 0,
4930        },
4931        diagnostics,
4932    ))
4933}
4934
4935/// Open a session from its V2 sidecar store.
4936///
4937/// Reads the JSONL header (first line) for `SessionHeader`, then loads
4938/// entries from the V2 segment store via its offset index — O(index + tail)
4939/// instead of the O(n) full-file parse that `open_jsonl_blocking` performs.
4940#[allow(clippy::too_many_lines)]
4941fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4942    // 1. Read JSONL header (first line only).
4943    let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4944    let mut reader = BufReader::new(file);
4945    let Some(header_line) =
4946        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4947    else {
4948        return Err(crate::Error::session("Empty JSONL session file"));
4949    };
4950    let header: SessionHeader = serde_json::from_str(header_line.trim())
4951        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4952    header.validate().map_err(|reason| {
4953        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4954    })?;
4955
4956    // 2. Open V2 sidecar store.
4957    let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
4958    let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
4959
4960    // 3. Choose an explicit hydration strategy for resume:
4961    // - env override (PI_SESSION_V2_OPEN_MODE)
4962    // - auto lazy mode for large sessions
4963    let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
4964    let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
4965    if let Some(raw) = mode_override_raw.as_deref() {
4966        if parse_v2_open_mode(raw).is_none() {
4967            tracing::warn!(
4968                value = %raw,
4969                "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
4970            );
4971        }
4972    }
4973    if let Some(raw) = threshold_override_raw.as_deref() {
4974        if raw.trim().parse::<u64>().is_err() {
4975            tracing::warn!(
4976                value = %raw,
4977                "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
4978            );
4979        }
4980    }
4981
4982    let entry_count = store.entry_count();
4983    let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
4984        entry_count,
4985        mode_override_raw.as_deref(),
4986        threshold_override_raw.as_deref(),
4987    );
4988    let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
4989        && entry_count > 0
4990        && store.head().is_none()
4991    {
4992        tracing::warn!(
4993            entry_count,
4994            "active-path hydration selected but store has no head; falling back to full hydration"
4995        );
4996        V2OpenMode::Full
4997    } else {
4998        selected_mode
4999    };
5000    tracing::debug!(
5001        entry_count,
5002        lazy_threshold,
5003        selection_reason,
5004        ?mode,
5005        "selected V2 resume hydration mode"
5006    );
5007
5008    // 4. Load entries using the selected mode.
5009    let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
5010    session.path = Some(jsonl_path);
5011    session.v2_sidecar_root = Some(v2_root);
5012    session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
5013    session.v2_resume_mode = Some(mode);
5014    Ok((session, diagnostics))
5015}
5016
5017/// Create a V2 sidecar store from an existing JSONL session file.
5018///
5019/// This is the migration path: parse the full JSONL once and write each entry
5020/// into the V2 segmented store with offset index. Subsequent opens can then
5021/// use `open_from_v2_store_blocking` for O(index+tail) resume.
5022pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
5023    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5024    if !v2_root.exists() {
5025        return build_v2_sidecar_from_jsonl_into(jsonl_path, &v2_root);
5026    }
5027
5028    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5029    let _staged_store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5030        Ok(store) => store,
5031        Err(err) => {
5032            let _ = cleanup_sidecar_root(&staging_root);
5033            return Err(err);
5034        }
5035    };
5036
5037    let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5038    if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5039        let _ = cleanup_sidecar_root(&staging_root);
5040        return Err(crate::Error::Io(Box::new(err)));
5041    }
5042
5043    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5044        let _ = std::fs::rename(&backup_root, &v2_root);
5045        let _ = cleanup_sidecar_root(&staging_root);
5046        return Err(crate::Error::Io(Box::new(err)));
5047    }
5048
5049    if let Err(err) = cleanup_sidecar_root(&backup_root) {
5050        tracing::warn!(
5051            path = %backup_root.display(),
5052            error = %err,
5053            "create_v2_sidecar_from_jsonl left backup sidecar after successful swap"
5054        );
5055    }
5056
5057    SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)
5058}
5059
5060fn build_v2_sidecar_from_jsonl_into(jsonl_path: &Path, v2_root: &Path) -> Result<SessionStoreV2> {
5061    let build_result = (|| -> Result<SessionStoreV2> {
5062        let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5063        let mut reader = std::io::BufReader::new(file);
5064
5065        let header_line = read_capped_utf8_line(&mut reader)
5066            .map_err(|e| crate::Error::Io(Box::new(e)))?
5067            .filter(|l| !l.trim().is_empty())
5068            .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
5069
5070        let header: SessionHeader = serde_json::from_str(header_line.trim())
5071            .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5072        header.validate().map_err(|reason| {
5073            crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5074        })?;
5075
5076        if v2_root.exists() {
5077            std::fs::remove_dir_all(v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5078        }
5079        let mut store = SessionStoreV2::create(v2_root, 64 * 1024 * 1024)?;
5080
5081        loop {
5082            let Some(line) =
5083                read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5084            else {
5085                break;
5086            };
5087            if line.trim().is_empty() {
5088                continue;
5089            }
5090            let entry: SessionEntry = serde_json::from_str(&line)
5091                .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5092            let (entry_id, parent_entry_id, entry_type, payload) =
5093                session_store_v2::session_entry_to_frame_args(&entry)?;
5094            store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5095        }
5096
5097        store.write_manifest(header.id, "jsonl")?;
5098
5099        Ok(store)
5100    })();
5101
5102    if build_result.is_err() && v2_root.exists() {
5103        let _ = std::fs::remove_dir_all(v2_root);
5104    }
5105
5106    build_result
5107}
5108
5109fn unique_sidecar_aux_path(v2_root: &Path, suffix: &str) -> PathBuf {
5110    let file_name = v2_root
5111        .file_name()
5112        .and_then(|name| name.to_str())
5113        .unwrap_or("session.v2");
5114    v2_root.with_file_name(format!(
5115        "{file_name}.{suffix}.{}",
5116        uuid::Uuid::new_v4().simple()
5117    ))
5118}
5119
5120fn cleanup_sidecar_root(path: &Path) -> Result<()> {
5121    if path.exists() {
5122        std::fs::remove_dir_all(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5123    }
5124    Ok(())
5125}
5126
5127/// Migrate a JSONL session to V2 with full verification and event logging.
5128///
5129/// Returns the `MigrationEvent` that was recorded in the V2 store's migration
5130/// ledger. The migration is atomic: if verification fails, the sidecar is
5131/// removed and an error is returned.
5132pub fn migrate_jsonl_to_v2(
5133    jsonl_path: &Path,
5134    correlation_id: &str,
5135) -> Result<session_store_v2::MigrationEvent> {
5136    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5137    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5138    let store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5139        Ok(store) => store,
5140        Err(err) => {
5141            let _ = cleanup_sidecar_root(&staging_root);
5142            return Err(err);
5143        }
5144    };
5145
5146    // Verify fidelity.
5147    let verification = match verify_v2_against_jsonl(jsonl_path, &store) {
5148        Ok(verification) => verification,
5149        Err(err) => {
5150            let _ = cleanup_sidecar_root(&staging_root);
5151            return Err(err);
5152        }
5153    };
5154
5155    if !(verification.entry_count_match
5156        && verification.hash_chain_match
5157        && verification.index_consistent)
5158    {
5159        // Verification failed — remove the sidecar.
5160        cleanup_sidecar_root(&staging_root)?;
5161        return Err(crate::Error::session(format!(
5162            "V2 migration verification failed: count={} hash={} index={}",
5163            verification.entry_count_match,
5164            verification.hash_chain_match,
5165            verification.index_consistent,
5166        )));
5167    }
5168
5169    let event = session_store_v2::MigrationEvent {
5170        schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5171        migration_id: uuid::Uuid::new_v4().to_string(),
5172        phase: "forward".to_string(),
5173        at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5174        source_path: jsonl_path.display().to_string(),
5175        target_path: session_store_v2::v2_sidecar_path(jsonl_path)
5176            .display()
5177            .to_string(),
5178        source_format: "jsonl_v3".to_string(),
5179        target_format: "native_v2".to_string(),
5180        verification,
5181        outcome: "ok".to_string(),
5182        error_class: None,
5183        correlation_id: correlation_id.to_string(),
5184    };
5185    if let Err(err) = store.append_migration_event(event.clone()) {
5186        let _ = cleanup_sidecar_root(&staging_root);
5187        return Err(err);
5188    }
5189
5190    let backup_root = if v2_root.exists() {
5191        let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5192        if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5193            let _ = cleanup_sidecar_root(&staging_root);
5194            return Err(crate::Error::Io(Box::new(err)));
5195        }
5196        Some(backup_root)
5197    } else {
5198        None
5199    };
5200
5201    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5202        if let Some(backup_root) = backup_root.as_ref() {
5203            let _ = std::fs::rename(backup_root, &v2_root);
5204        }
5205        let _ = cleanup_sidecar_root(&staging_root);
5206        return Err(crate::Error::Io(Box::new(err)));
5207    }
5208
5209    if let Some(backup_root) = backup_root {
5210        if let Err(err) = cleanup_sidecar_root(&backup_root) {
5211            tracing::warn!(
5212                path = %backup_root.display(),
5213                error = %err,
5214                "V2 migration left backup sidecar after successful swap"
5215            );
5216        }
5217    }
5218
5219    Ok(event)
5220}
5221
5222/// Verify a V2 sidecar against its source JSONL for fidelity.
5223///
5224/// Compares entry count, entry IDs in order, and validates the V2 store's
5225/// internal integrity (checksums + hash chain).
5226pub fn verify_v2_against_jsonl(
5227    jsonl_path: &Path,
5228    store: &SessionStoreV2,
5229) -> Result<session_store_v2::MigrationVerification> {
5230    // Parse all JSONL entries (skip header).
5231    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5232    let mut reader = std::io::BufReader::new(file);
5233
5234    let Some(header_line) =
5235        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5236    else {
5237        return Err(crate::Error::session("Empty JSONL session file"));
5238    };
5239    if header_line.trim().is_empty() {
5240        return Err(crate::Error::session("Empty JSONL session file"));
5241    }
5242
5243    let header: SessionHeader = serde_json::from_str(header_line.trim())
5244        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5245    header.validate().map_err(|reason| {
5246        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5247    })?;
5248
5249    let mut jsonl_ids: Vec<String> = Vec::new();
5250    let mut jsonl_chain_hash = V2_CHAIN_HASH_GENESIS.to_string();
5251
5252    loop {
5253        let Some(line) =
5254            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5255        else {
5256            break;
5257        };
5258        if line.trim().is_empty() {
5259            continue;
5260        }
5261        let entry: SessionEntry = serde_json::from_str(&line)
5262            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5263        let id = entry
5264            .base_id()
5265            .cloned()
5266            .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
5267        jsonl_ids.push(id);
5268        jsonl_chain_hash = session_entry_chain_hash_step(&jsonl_chain_hash, &entry)?;
5269    }
5270
5271    // Read V2 store entries.
5272    let frames = store.read_all_entries()?;
5273    let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
5274
5275    let entry_count_match = jsonl_ids.len().eq(&v2_ids.len()) && jsonl_ids.eq(&v2_ids);
5276
5277    // Check hash chain via validate_integrity (which also verifies checksums).
5278    let index_consistent = store.validate_integrity().is_ok();
5279
5280    let hash_chain_match = jsonl_chain_hash.eq(store.chain_hash());
5281
5282    Ok(session_store_v2::MigrationVerification {
5283        entry_count_match,
5284        hash_chain_match,
5285        index_consistent,
5286    })
5287}
5288
5289fn is_v2_sidecar_stale(jsonl_path: &Path, v2_root: &Path) -> bool {
5290    let Some(jsonl_meta) = std::fs::metadata(jsonl_path).ok() else {
5291        return true;
5292    };
5293
5294    let v2_index = v2_root.join("index").join("offsets.jsonl");
5295    let v2_manifest = v2_root.join("manifest.json");
5296    let Some(v2_meta) = std::fs::metadata(&v2_index)
5297        .or_else(|_| std::fs::metadata(&v2_manifest))
5298        .ok()
5299    else {
5300        return true;
5301    };
5302
5303    let Some(jsonl_mtime) = jsonl_meta.modified().ok() else {
5304        return true;
5305    };
5306    let Some(v2_mtime) = v2_meta.modified().ok() else {
5307        return true;
5308    };
5309
5310    jsonl_mtime > v2_mtime
5311}
5312
5313fn session_entry_chain_hash_step(prev_chain: &str, entry: &SessionEntry) -> Result<String> {
5314    let (_, _, _, payload) = session_store_v2::session_entry_to_frame_args(entry)?;
5315    let payload_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&payload)?));
5316    let mut hasher = Sha256::new();
5317    hasher.update(prev_chain.as_bytes());
5318    hasher.update(payload_sha256.as_bytes());
5319    Ok(format!("{:x}", hasher.finalize()))
5320}
5321
5322/// Remove a V2 sidecar, reverting to JSONL-only storage.
5323///
5324/// Logs a rollback event in the migration ledger before removing the sidecar.
5325/// Returns `Ok(())` if the sidecar was removed (or didn't exist).
5326pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
5327    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5328    if !v2_root.exists() {
5329        return Ok(());
5330    }
5331
5332    // Try to log the rollback event before deleting.
5333    if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5334        let event = session_store_v2::MigrationEvent {
5335            schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5336            migration_id: uuid::Uuid::new_v4().to_string(),
5337            phase: "rollback_to_jsonl".to_string(),
5338            at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5339            source_path: v2_root.display().to_string(),
5340            target_path: jsonl_path.display().to_string(),
5341            source_format: "native_v2".to_string(),
5342            target_format: "jsonl_v3".to_string(),
5343            verification: session_store_v2::MigrationVerification {
5344                entry_count_match: true,
5345                hash_chain_match: true,
5346                index_consistent: true,
5347            },
5348            outcome: "ok".to_string(),
5349            error_class: None,
5350            correlation_id: correlation_id.to_string(),
5351        };
5352        let _ = store.append_migration_event(event);
5353    }
5354
5355    std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5356    Ok(())
5357}
5358
5359/// Current migration state of a JSONL session.
5360#[derive(Debug, Clone, PartialEq, Eq)]
5361pub enum MigrationState {
5362    /// No V2 sidecar exists — pure JSONL.
5363    Unmigrated,
5364    /// V2 sidecar exists and passes integrity validation.
5365    Migrated,
5366    /// V2 sidecar exists but fails integrity validation.
5367    Corrupt { error: String },
5368    /// V2 sidecar directory exists but is missing critical files (partial write).
5369    Partial,
5370}
5371
5372/// Query the migration state of a JSONL session file.
5373pub fn migration_status(jsonl_path: &Path) -> MigrationState {
5374    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5375    if !v2_root.exists() {
5376        return MigrationState::Unmigrated;
5377    }
5378
5379    let segments_dir = v2_root.join("segments");
5380    if !segments_dir.exists() {
5381        return MigrationState::Partial;
5382    }
5383
5384    let index_path = v2_root.join("index").join("offsets.jsonl");
5385    if !index_path.exists() {
5386        match jsonl_has_entry_lines(jsonl_path) {
5387            Ok(true) => return MigrationState::Partial,
5388            Ok(false) => {}
5389            Err(e) => {
5390                return MigrationState::Corrupt {
5391                    error: e.to_string(),
5392                };
5393            }
5394        }
5395    }
5396
5397    let inspector = match SessionStoreV2::open_for_inspection(&v2_root, 64 * 1024 * 1024) {
5398        Ok(store) => store,
5399        Err(e) => {
5400            return MigrationState::Corrupt {
5401                error: e.to_string(),
5402            };
5403        }
5404    };
5405
5406    match inspector.read_index() {
5407        Ok(_) => match inspector.validate_integrity() {
5408            Ok(()) => MigrationState::Migrated,
5409            Err(e) => MigrationState::Corrupt {
5410                error: e.to_string(),
5411            },
5412        },
5413        Err(e) if migration_status_can_rebuild_index(&e) => {
5414            match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5415                Ok(store) => match verify_v2_against_jsonl(jsonl_path, &store) {
5416                    Ok(verification)
5417                        if verification.entry_count_match
5418                            && verification.hash_chain_match
5419                            && verification.index_consistent =>
5420                    {
5421                        MigrationState::Migrated
5422                    }
5423                    Ok(verification) => MigrationState::Corrupt {
5424                        error: format!(
5425                            "migration verification failed after index rebuild: count={} hash={} index={}",
5426                            verification.entry_count_match,
5427                            verification.hash_chain_match,
5428                            verification.index_consistent,
5429                        ),
5430                    },
5431                    Err(err) => MigrationState::Corrupt {
5432                        error: err.to_string(),
5433                    },
5434                },
5435                Err(err) => MigrationState::Corrupt {
5436                    error: err.to_string(),
5437                },
5438            }
5439        }
5440        Err(e) => MigrationState::Corrupt {
5441            error: e.to_string(),
5442        },
5443    }
5444}
5445
5446fn migration_status_can_rebuild_index(error: &Error) -> bool {
5447    match error {
5448        Error::Json(_) => true,
5449        Error::Io(err) => matches!(
5450            err.kind(),
5451            std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::InvalidData
5452        ),
5453        _ => false,
5454    }
5455}
5456
5457/// Dry-run a JSONL → V2 migration without persisting the sidecar.
5458///
5459/// Creates the V2 store in a temporary directory, runs verification, then
5460/// cleans up. Returns the verification result so callers can inspect
5461/// entry counts and integrity before committing.
5462pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
5463    let tmp_dir =
5464        tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
5465    let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
5466
5467    // Parse JSONL and populate a temporary V2 store.
5468    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5469    let mut reader = std::io::BufReader::new(file);
5470
5471    let Some(header_line) =
5472        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5473    else {
5474        return Err(crate::Error::session("Empty JSONL session file"));
5475    };
5476
5477    let header: SessionHeader = serde_json::from_str(header_line.trim_end())
5478        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5479    header.validate().map_err(|reason| {
5480        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5481    })?;
5482
5483    let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
5484
5485    loop {
5486        let Some(line) =
5487            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5488        else {
5489            break;
5490        };
5491        if line.trim().is_empty() {
5492            continue;
5493        }
5494        let entry: SessionEntry = serde_json::from_str(line.trim_end())
5495            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5496        let (entry_id, parent_entry_id, entry_type, payload) =
5497            session_store_v2::session_entry_to_frame_args(&entry)?;
5498        store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5499    }
5500
5501    // Verify against source JSONL (but using the temp store).
5502    verify_v2_against_jsonl(jsonl_path, &store)
5503    // tmp_dir drops here → auto-cleanup
5504}
5505
5506/// Recover from a partial or corrupted V2 migration.
5507///
5508/// If the sidecar is in a partial/corrupt state, removes it and optionally
5509/// re-runs the migration. Returns the final migration state.
5510pub fn recover_partial_migration(
5511    jsonl_path: &Path,
5512    correlation_id: &str,
5513    re_migrate: bool,
5514) -> Result<MigrationState> {
5515    let status = migration_status(jsonl_path);
5516    match &status {
5517        MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
5518        MigrationState::Partial | MigrationState::Corrupt { .. } => {
5519            // Remove the broken sidecar.
5520            let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5521            if v2_root.exists() {
5522                std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5523            }
5524
5525            if re_migrate {
5526                migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
5527                Ok(MigrationState::Migrated)
5528            } else {
5529                Ok(MigrationState::Unmigrated)
5530            }
5531        }
5532    }
5533}
5534
5535fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
5536    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5537    let mut reader = std::io::BufReader::new(file);
5538
5539    let Some(_line) =
5540        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5541    else {
5542        return Err(crate::Error::session("Empty JSONL session file"));
5543    };
5544
5545    loop {
5546        let Some(line) =
5547            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5548        else {
5549            return Ok(false);
5550        };
5551        if !line.trim().is_empty() {
5552            return Ok(true);
5553        }
5554    }
5555}
5556
5557/// Result of single-pass load finalization (Gap F).
5558///
5559/// Replaces the previous multi-pass approach (`ensure_entry_ids` +
5560/// `entry_id_set` + orphan detection + stats) with a single O(n) scan
5561/// that produces all required caches at once.
5562struct LoadFinalization {
5563    leaf_id: Option<String>,
5564    entry_ids: HashSet<String>,
5565    entry_index: HashMap<String, usize>,
5566    message_count: u64,
5567    name: Option<String>,
5568    is_linear: bool,
5569    orphans: Vec<(String, String)>,
5570}
5571
5572/// Single-pass finalization of loaded entries.
5573///
5574/// 1. Assigns IDs to entries missing them (`ensure_entry_ids` work).
5575/// 2. Builds `entry_ids` set and `entry_index` map.
5576/// 3. Detects orphaned parent links.
5577/// 4. Computes `session_entry_stats` (message count + name).
5578/// 5. Determines `is_linear` (no branching, leaf == last entry).
5579fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
5580    // First pass: assign missing IDs (same logic as `ensure_entry_ids`).
5581    let mut entry_ids: HashSet<String> = entries
5582        .iter()
5583        .filter_map(|e| e.base_id().cloned())
5584        .collect();
5585    for entry in entries.iter_mut() {
5586        if entry.base().id.is_none() {
5587            let id = generate_entry_id(&entry_ids);
5588            entry.base_mut().id = Some(id.clone());
5589            entry_ids.insert(id);
5590        }
5591    }
5592
5593    // Second (main) pass: build all caches in one scan.
5594    let mut entry_index = HashMap::with_capacity(entries.len());
5595    let mut message_count = 0u64;
5596    let mut name: Option<String> = None;
5597    let mut leaf_id: Option<String> = None;
5598    let mut orphans = Vec::new();
5599    // Track parent_ids seen as children's parent to detect branching.
5600    let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
5601    let mut has_branching = false;
5602    let mut root_count = 0u32;
5603
5604    for (idx, entry) in entries.iter().enumerate() {
5605        let Some(id) = entry.base_id() else {
5606            continue;
5607        };
5608        entry_index.insert(id.clone(), idx);
5609        leaf_id = Some(id.clone());
5610
5611        // Orphan detection.
5612        if let Some(parent_id) = entry.base().parent_id.as_ref() {
5613            if !entry_ids.contains(parent_id) {
5614                orphans.push((id.clone(), parent_id.clone()));
5615            }
5616        } else {
5617            root_count += 1;
5618        }
5619
5620        // Branch detection: if any parent_id has >1 child, it's branched.
5621        if !has_branching {
5622            let parent_key = entry.base().parent_id.as_deref();
5623            let count = parent_id_child_count.entry(parent_key).or_insert(0);
5624            *count += 1;
5625            if *count > 1 {
5626                has_branching = true;
5627            }
5628        }
5629
5630        // Stats.
5631        match entry {
5632            SessionEntry::Message(_) => message_count += 1,
5633            SessionEntry::SessionInfo(info) if info.name.is_some() => {
5634                name.clone_from(&info.name);
5635            }
5636            _ => {}
5637        }
5638    }
5639
5640    // is_linear: no branching detected in the entry set, exactly one root, and no orphans.
5641    // Note: callers (e.g. rebuild_all_caches) add the additional check that
5642    // self.leaf_id == finalized.leaf_id to confirm we're at the tip.
5643    let is_linear = !has_branching && root_count <= 1 && orphans.is_empty();
5644
5645    LoadFinalization {
5646        leaf_id,
5647        entry_ids,
5648        entry_index,
5649        message_count,
5650        name,
5651        is_linear,
5652        orphans,
5653    }
5654}
5655
5656fn parse_env_bool(value: &str) -> bool {
5657    matches!(
5658        value.trim().to_ascii_lowercase().as_str(),
5659        "1" | "true" | "yes" | "on"
5660    )
5661}
5662
5663fn session_entry_id_cache_enabled() -> bool {
5664    static ENABLED: OnceLock<bool> = OnceLock::new();
5665    *ENABLED.get_or_init(|| {
5666        std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
5667    })
5668}
5669
5670#[cfg(test)]
5671fn ensure_entry_ids(entries: &mut [SessionEntry]) {
5672    let mut existing = entry_id_set(entries);
5673    for entry in entries.iter_mut() {
5674        if entry.base().id.is_none() {
5675            let id = generate_entry_id(&existing);
5676            entry.base_mut().id = Some(id.clone());
5677            existing.insert(id);
5678        }
5679    }
5680}
5681
5682/// Generate a unique entry ID (8 hex characters), falling back to UUID on collision.
5683fn generate_entry_id(existing: &HashSet<String>) -> String {
5684    for _ in 0..100 {
5685        let uuid = uuid::Uuid::new_v4();
5686        let id = uuid.simple().to_string()[..8].to_string();
5687        if !existing.contains(&id) {
5688            return id;
5689        }
5690    }
5691    uuid::Uuid::new_v4().to_string()
5692}
5693
5694#[cfg(test)]
5695type SetNameDeadlineProbe = Option<(String, std::sync::mpsc::Sender<Option<asupersync::Time>>)>;
5696
5697#[cfg(test)]
5698fn set_name_deadline_probe() -> &'static std::sync::Mutex<SetNameDeadlineProbe> {
5699    static PROBE: std::sync::OnceLock<std::sync::Mutex<SetNameDeadlineProbe>> =
5700        std::sync::OnceLock::new();
5701    PROBE.get_or_init(|| std::sync::Mutex::new(None))
5702}
5703
5704#[cfg(test)]
5705fn emit_set_name_deadline_probe(session_id: &str, deadline: Option<asupersync::Time>) {
5706    let probe = set_name_deadline_probe();
5707    let guard = probe.lock().expect("lock set_name deadline probe");
5708    if let Some((target_session_id, tx)) = guard.as_ref() {
5709        if target_session_id.eq(session_id) {
5710            let _ = tx.send(deadline);
5711        }
5712    }
5713}
5714
5715#[cfg(test)]
5716mod tests {
5717    use super::*;
5718    use crate::model::{Cost, StopReason, Usage};
5719    use asupersync::runtime::RuntimeBuilder;
5720    use asupersync::sync::Mutex as AsyncMutex;
5721    use clap::Parser;
5722    use std::env;
5723    use std::future::Future;
5724    use std::path::{Path, PathBuf};
5725    use std::sync::{Mutex as StdMutex, OnceLock};
5726    use std::time::Duration;
5727
5728    macro_rules! test_fail {
5729        ($message:literal $(,)?) => {
5730            std::panic::panic_any($message)
5731        };
5732        ($fmt:literal, $($arg:tt)+) => {
5733            std::panic::panic_any(format!($fmt, $($arg)+))
5734        };
5735    }
5736
5737    fn make_test_message(text: &str) -> SessionMessage {
5738        SessionMessage::User {
5739            content: UserContent::Text(text.to_string()),
5740            timestamp: Some(0),
5741        }
5742    }
5743
5744    fn make_test_assistant_message(text: &str, total_tokens: u64) -> SessionMessage {
5745        SessionMessage::Assistant {
5746            message: AssistantMessage {
5747                content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5748                api: "test-api".to_string(),
5749                provider: "test-provider".to_string(),
5750                model: "test-model".to_string(),
5751                usage: Usage {
5752                    input: total_tokens / 2,
5753                    output: total_tokens.saturating_sub(total_tokens / 2),
5754                    total_tokens,
5755                    ..Usage::default()
5756                },
5757                stop_reason: StopReason::Stop,
5758                error_message: None,
5759                timestamp: 0,
5760            },
5761        }
5762    }
5763
5764    fn make_test_tool_call_message(tool_call_id: &str) -> SessionMessage {
5765        SessionMessage::Assistant {
5766            message: AssistantMessage {
5767                content: vec![ContentBlock::ToolCall(crate::model::ToolCall {
5768                    id: tool_call_id.to_string(),
5769                    name: "read".to_string(),
5770                    arguments: serde_json::json!({ "path": "src/session.rs" }),
5771                    thought_signature: None,
5772                })],
5773                api: "test-api".to_string(),
5774                provider: "test-provider".to_string(),
5775                model: "test-model".to_string(),
5776                usage: Usage {
5777                    input: 24,
5778                    output: 16,
5779                    total_tokens: 40,
5780                    ..Usage::default()
5781                },
5782                stop_reason: StopReason::ToolUse,
5783                error_message: None,
5784                timestamp: 0,
5785            },
5786        }
5787    }
5788
5789    fn make_test_tool_result_message(tool_call_id: &str) -> SessionMessage {
5790        SessionMessage::ToolResult {
5791            tool_call_id: tool_call_id.to_string(),
5792            tool_name: "read".to_string(),
5793            content: vec![ContentBlock::Text(TextContent::new(
5794                "read output for replay harness".to_string(),
5795            ))],
5796            details: Some(serde_json::json!({
5797                "bytes": 31,
5798                "truncated": false,
5799            })),
5800            is_error: false,
5801            timestamp: Some(0),
5802        }
5803    }
5804
5805    fn make_test_aborted_assistant_message(text: &str) -> SessionMessage {
5806        SessionMessage::Assistant {
5807            message: AssistantMessage {
5808                content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5809                api: "test-api".to_string(),
5810                provider: "test-provider".to_string(),
5811                model: "test-model".to_string(),
5812                usage: Usage {
5813                    input: 10,
5814                    output: 6,
5815                    total_tokens: 16,
5816                    ..Usage::default()
5817                },
5818                stop_reason: StopReason::Aborted,
5819                error_message: Some("interrupted by local abort".to_string()),
5820                timestamp: 0,
5821            },
5822        }
5823    }
5824
5825    fn run_async<T>(future: impl Future<Output = T>) -> T {
5826        let runtime = RuntimeBuilder::current_thread()
5827            .build()
5828            .expect("build runtime");
5829        runtime.block_on(future)
5830    }
5831
5832    fn tempdir_under_tmpdir(prefix: &str) -> tempfile::TempDir {
5833        let tmp_root = env::var_os("TMPDIR").map_or_else(env::temp_dir, PathBuf::from);
5834        std::fs::create_dir_all(&tmp_root).expect("create TMPDIR root");
5835        tempfile::Builder::new()
5836            .prefix(prefix)
5837            .tempdir_in(&tmp_root)
5838            .expect("create tempdir under TMPDIR")
5839    }
5840
5841    fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
5842        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
5843        LOCK.get_or_init(|| StdMutex::new(())).lock().expect("lock")
5844    }
5845
5846    struct CurrentDirGuard {
5847        previous: PathBuf,
5848    }
5849
5850    impl CurrentDirGuard {
5851        fn new(path: &Path) -> Self {
5852            let previous = env::current_dir().expect("current dir");
5853            env::set_current_dir(path).expect("set current dir");
5854            Self { previous }
5855        }
5856    }
5857
5858    impl Drop for CurrentDirGuard {
5859        fn drop(&mut self) {
5860            let _ = env::set_current_dir(&self.previous);
5861        }
5862    }
5863
5864    #[test]
5865    fn v2_open_mode_parser_supports_expected_values() {
5866        assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
5867        assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
5868        assert_eq!(
5869            parse_v2_open_mode("active_path"),
5870            Some(V2OpenMode::ActivePath)
5871        );
5872        assert_eq!(
5873            parse_v2_open_mode("active-path"),
5874            Some(V2OpenMode::ActivePath)
5875        );
5876        assert_eq!(
5877            parse_v2_open_mode("tail"),
5878            Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
5879        );
5880        assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
5881        assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
5882        assert_eq!(parse_v2_open_mode("bad-mode"), None);
5883        assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
5884    }
5885
5886    #[test]
5887    fn v2_open_mode_selection_prefers_env_override_then_threshold() {
5888        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
5889        assert_eq!(mode, V2OpenMode::Full);
5890        assert_eq!(reason, "env_override");
5891        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5892
5893        let (mode, reason, threshold) =
5894            select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
5895        assert_eq!(
5896            mode,
5897            V2OpenMode::ActivePath,
5898            "invalid threshold falls back to default threshold"
5899        );
5900        assert_eq!(reason, "entry_count_above_lazy_threshold");
5901        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5902
5903        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
5904        assert_eq!(mode, V2OpenMode::ActivePath);
5905        assert_eq!(reason, "entry_count_above_lazy_threshold");
5906        assert_eq!(threshold, 500);
5907
5908        let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
5909        assert_eq!(mode, V2OpenMode::Full);
5910        assert_eq!(reason, "default_full");
5911        assert_eq!(threshold, 500);
5912    }
5913
5914    #[test]
5915    fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
5916        let temp_dir = tempfile::tempdir().unwrap();
5917        let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
5918
5919        // Build a branching session:
5920        // root -> a -> b
5921        //           \-> c (active leaf)
5922        let mut seed = Session::create();
5923        seed.path = Some(path.clone());
5924        let _id_root = seed.append_message(make_test_message("root"));
5925        let id_a = seed.append_message(make_test_message("a"));
5926        let id_b = seed.append_message(make_test_message("main-branch"));
5927        assert!(seed.create_branch_from(&id_a));
5928        let id_c = seed.append_message(make_test_message("side-branch"));
5929        run_async(async { seed.save().await }).unwrap();
5930
5931        // Build sidecar and reopen in ActivePath mode.
5932        create_v2_sidecar_from_jsonl(&path).unwrap();
5933        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5934        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5935        let (mut loaded, _) =
5936            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5937        loaded.path = Some(path.clone());
5938        loaded.v2_sidecar_root = Some(v2_root);
5939        loaded.v2_partial_hydration = true;
5940        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5941
5942        let active_ids: Vec<String> = loaded
5943            .entries
5944            .iter()
5945            .filter_map(|entry| entry.base().id.clone())
5946            .collect();
5947        assert!(
5948            !active_ids.contains(&id_b),
5949            "active path intentionally excludes non-leaf sibling branch"
5950        );
5951        assert!(active_ids.contains(&id_c));
5952        assert_eq!(
5953            loaded.cached_message_count, seed.cached_message_count,
5954            "active-path resume should retain total message count metadata"
5955        );
5956        assert!(
5957            loaded.v2_message_count_offset > 0,
5958            "active-path resume should track hidden messages outside the active path"
5959        );
5960
5961        // Force full rewrite path (header dirty). Save must rehydrate first so b survives.
5962        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
5963        run_async(async { loaded.save().await }).unwrap();
5964
5965        let (reopened, _) =
5966            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
5967        let reopened_ids: Vec<String> = reopened
5968            .entries
5969            .iter()
5970            .filter_map(|entry| entry.base().id.clone())
5971            .collect();
5972        assert!(
5973            reopened_ids.contains(&id_b),
5974            "non-active branch entry must survive full rewrite after lazy hydration"
5975        );
5976        assert!(reopened_ids.contains(&id_c));
5977        assert_eq!(reopened_ids.len(), 4);
5978    }
5979
5980    #[test]
5981    fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
5982        let temp_dir = tempfile::tempdir().unwrap();
5983        let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
5984
5985        let mut seed = Session::create();
5986        seed.path = Some(path.clone());
5987        let _id_root = seed.append_message(make_test_message("root"));
5988        let id_a = seed.append_message(make_test_message("a"));
5989        let id_b = seed.append_message(make_test_message("main-branch"));
5990        assert!(seed.create_branch_from(&id_a));
5991        let _id_c = seed.append_message(make_test_message("side-branch"));
5992        run_async(async { seed.save().await }).unwrap();
5993
5994        create_v2_sidecar_from_jsonl(&path).unwrap();
5995        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5996        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5997        let (mut loaded, _) =
5998            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5999        loaded.path = Some(path.clone());
6000        loaded.v2_sidecar_root = Some(v2_root);
6001        loaded.v2_partial_hydration = true;
6002        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6003
6004        let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
6005        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6006        run_async(async { loaded.save().await }).unwrap();
6007
6008        let (reopened, _) =
6009            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6010        let reopened_ids: Vec<String> = reopened
6011            .entries
6012            .iter()
6013            .filter_map(|entry| entry.base().id.clone())
6014            .collect();
6015        assert!(
6016            reopened_ids.contains(&id_b),
6017            "non-active branch entry must survive rehydration+save"
6018        );
6019        assert!(
6020            reopened_ids.contains(&new_id),
6021            "pending entry appended on partial session must be preserved"
6022        );
6023        assert_eq!(reopened_ids.len(), 5);
6024    }
6025
6026    #[test]
6027    fn v2_partial_hydration_full_rewrite_uses_newer_jsonl_when_sidecar_is_stale() {
6028        let temp_dir = tempfile::tempdir().unwrap();
6029        let path = temp_dir.path().join("lazy_hydration_stale_sidecar.jsonl");
6030
6031        let mut seed = Session::create();
6032        seed.path = Some(path.clone());
6033        let _id_root = seed.append_message(make_test_message("root"));
6034        let id_a = seed.append_message(make_test_message("a"));
6035        let id_b = seed.append_message(make_test_message("main-branch"));
6036        assert!(seed.create_branch_from(&id_a));
6037        let _id_c = seed.append_message(make_test_message("side-branch"));
6038        run_async(async { seed.save().await }).unwrap();
6039
6040        create_v2_sidecar_from_jsonl(&path).unwrap();
6041        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
6042        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
6043        let (mut loaded, _) =
6044            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
6045        loaded.path = Some(path.clone());
6046        loaded.v2_sidecar_root = Some(v2_root.clone());
6047        loaded.v2_partial_hydration = true;
6048        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6049
6050        std::thread::sleep(std::time::Duration::from_secs(1));
6051        let new_id = loaded.append_message(make_test_message("saved-before-full-rewrite"));
6052        run_async(async { loaded.save().await }).unwrap();
6053        assert!(
6054            is_v2_sidecar_stale(&path, &v2_root),
6055            "incremental JSONL save should make sidecar stale"
6056        );
6057
6058        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6059        run_async(async { loaded.save().await }).unwrap();
6060
6061        let (reopened, _) =
6062            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6063        let reopened_ids: Vec<String> = reopened
6064            .entries
6065            .iter()
6066            .filter_map(|entry| entry.base().id.clone())
6067            .collect();
6068        assert!(
6069            reopened_ids.contains(&id_b),
6070            "non-active branch entry must survive full rewrite after stale sidecar"
6071        );
6072        assert!(
6073            reopened_ids.contains(&new_id),
6074            "entry already saved to JSONL must not be dropped during rehydrate"
6075        );
6076        assert_eq!(reopened_ids.len(), 5);
6077    }
6078
6079    #[test]
6080    fn verify_v2_against_jsonl_detects_payload_mismatch_with_matching_ids() {
6081        let temp_dir = tempfile::tempdir().unwrap();
6082        let path = temp_dir.path().join("verify_v2_payload_mismatch.jsonl");
6083
6084        let mut session = Session::create();
6085        session.path = Some(path.clone());
6086        session.append_message(make_test_message("alpha"));
6087        session.append_message(make_test_message("beta"));
6088        run_async(async { session.save().await }).unwrap();
6089
6090        let contents = std::fs::read_to_string(&path).unwrap();
6091        let mut lines = contents.lines();
6092        let _header_line = lines.next().expect("header");
6093        let mut tampered_entries: Vec<SessionEntry> = lines
6094            .filter(|line| !line.trim().is_empty())
6095            .map(|line| serde_json::from_str(line).expect("parse session entry"))
6096            .collect();
6097
6098        let SessionEntry::Message(message_entry) = tampered_entries
6099            .first_mut()
6100            .expect("first tampered entry should exist")
6101        else {
6102            test_fail!("expected message entry");
6103        };
6104        let SessionMessage::User {
6105            content: UserContent::Text(text),
6106            ..
6107        } = &mut message_entry.message
6108        else {
6109            test_fail!("expected user text message");
6110        };
6111        *text = "alpha-tampered".to_string();
6112
6113        let tampered_root = temp_dir.path().join("verify_v2_payload_mismatch.v2");
6114        let mut tampered_store = SessionStoreV2::create(&tampered_root, 64 * 1024 * 1024).unwrap();
6115        for entry in &tampered_entries {
6116            let (entry_id, parent_entry_id, entry_type, payload) =
6117                session_store_v2::session_entry_to_frame_args(entry).unwrap();
6118            tampered_store
6119                .append_entry(entry_id, parent_entry_id, entry_type, payload)
6120                .unwrap();
6121        }
6122
6123        let verification = verify_v2_against_jsonl(&path, &tampered_store).unwrap();
6124        assert!(verification.entry_count_match);
6125        assert!(verification.index_consistent);
6126        assert!(
6127            !verification.hash_chain_match,
6128            "payload divergence must fail migration verification even when entry ids match"
6129        );
6130    }
6131
6132    #[test]
6133    fn test_session_handle_mutations_defer_persistence_side_effects() {
6134        let temp_dir = tempfile::tempdir().expect("temp dir");
6135        let mut session = Session::create();
6136        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6137        // Point at a directory path so an eager save would fail with an IO error.
6138        session.path = Some(temp_dir.path().to_path_buf());
6139        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6140
6141        run_async(async { handle.set_name("deferred-save".to_string()).await })
6142            .expect("set_name should not trigger immediate save");
6143        run_async(async { handle.append_message(make_test_message("hello")).await })
6144            .expect("append_message should not trigger immediate save");
6145        run_async(async {
6146            handle
6147                .append_custom_entry(
6148                    "marker".to_string(),
6149                    Some(serde_json::json!({ "value": 42 })),
6150                )
6151                .await
6152        })
6153        .expect("append_custom_entry should not trigger immediate save");
6154        run_async(async {
6155            handle
6156                .set_model("prov".to_string(), "model".to_string())
6157                .await
6158        })
6159        .expect("set_model should not trigger immediate save");
6160        run_async(async { handle.set_thinking_level("high".to_string()).await })
6161            .expect("set_thinking_level should not trigger immediate save");
6162
6163        let branch = run_async(async { handle.get_branch().await });
6164        let message_id = branch
6165            .iter()
6166            .find_map(|entry| {
6167                if entry
6168                    .get("type")
6169                    .and_then(Value::as_str)
6170                    .is_some_and(|entry_type| entry_type.eq("message"))
6171                {
6172                    entry
6173                        .get("id")
6174                        .and_then(Value::as_str)
6175                        .map(ToString::to_string)
6176                } else {
6177                    None
6178                }
6179            })
6180            .expect("message entry id in branch");
6181        run_async(async {
6182            handle
6183                .set_label(message_id, Some("hot-path".to_string()))
6184                .await
6185        })
6186        .expect("set_label should not trigger immediate save");
6187
6188        let state = run_async(async { handle.get_state().await });
6189        assert_eq!(
6190            state.get("sessionName").and_then(Value::as_str),
6191            Some("deferred-save")
6192        );
6193        assert_eq!(
6194            state.get("thinkingLevel").and_then(Value::as_str),
6195            Some("high")
6196        );
6197        assert_eq!(
6198            state.get("durabilityMode").and_then(Value::as_str),
6199            Some("throughput")
6200        );
6201        assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
6202        assert_eq!(
6203            state
6204                .get("model")
6205                .and_then(|model| model.get("provider"))
6206                .and_then(Value::as_str),
6207            Some("prov")
6208        );
6209        assert_eq!(
6210            state
6211                .get("model")
6212                .and_then(|model| model.get("id"))
6213                .and_then(Value::as_str),
6214            Some("model")
6215        );
6216
6217        let (provider, model_id) = run_async(async { handle.get_model().await });
6218        assert_eq!(provider.as_deref(), Some("prov"));
6219        assert_eq!(model_id.as_deref(), Some("model"));
6220    }
6221
6222    #[test]
6223    fn session_handle_set_name_inherits_cancelled_context_when_lock_is_held() {
6224        let runtime = RuntimeBuilder::current_thread()
6225            .build()
6226            .expect("build runtime");
6227
6228        runtime.block_on(async {
6229            let session = Arc::new(AsyncMutex::new(Session::in_memory()));
6230            let handle = SessionHandle(Arc::clone(&session));
6231
6232            let hold_cx = AgentCx::for_request();
6233            let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
6234
6235            let ambient_cx = asupersync::Cx::for_testing();
6236            ambient_cx.set_cancel_requested(true);
6237            let _current = asupersync::Cx::set_current(Some(ambient_cx));
6238            let inner = asupersync::time::timeout(
6239                asupersync::time::wall_now(),
6240                Duration::from_millis(100),
6241                handle.set_name("cancelled-name".to_string()),
6242            )
6243            .await;
6244            let outcome = inner.expect("cancelled helper should finish before timeout");
6245            let err = outcome.expect_err("lock acquisition should honor inherited cancellation");
6246            assert!(
6247                err.to_string().contains("Failed to lock session"),
6248                "unexpected error: {err}"
6249            );
6250
6251            drop(held_guard);
6252
6253            let state = SessionHandle(Arc::clone(&session)).get_state().await;
6254            assert!(
6255                state.get("sessionName").is_none_or(Value::is_null),
6256                "cancelled mutation should not update the session name: {state:?}"
6257            );
6258        });
6259    }
6260
6261    #[test]
6262    fn session_handle_set_name_inherits_deadline() {
6263        let runtime = RuntimeBuilder::current_thread()
6264            .build()
6265            .expect("build runtime");
6266
6267        runtime.block_on(async {
6268            struct ProbeReset;
6269            impl Drop for ProbeReset {
6270                fn drop(&mut self) {
6271                    let mut probe = set_name_deadline_probe()
6272                        .lock()
6273                        .expect("lock set_name deadline probe");
6274                    *probe = None;
6275                }
6276            }
6277
6278            let session_state = Session::in_memory();
6279            let probe_session_id = session_state.header.id.clone();
6280            let session = Arc::new(AsyncMutex::new(session_state));
6281            let handle = SessionHandle(Arc::clone(&session));
6282
6283            let (probe_tx, probe_rx) = std::sync::mpsc::channel();
6284            {
6285                let mut probe = set_name_deadline_probe()
6286                    .lock()
6287                    .expect("lock set_name deadline probe");
6288                assert!(probe.is_none(), "set_name deadline probe already installed");
6289                *probe = Some((probe_session_id, probe_tx));
6290            }
6291            let _probe_reset = ProbeReset;
6292
6293            let expected_deadline = asupersync::time::wall_now() + Duration::from_secs(30);
6294            let ambient_cx = AgentCx::for_request_with_budget(asupersync::Budget {
6295                deadline: Some(expected_deadline),
6296                ..asupersync::Budget::INFINITE
6297            });
6298            let _current = asupersync::Cx::set_current(Some(ambient_cx.cx().clone()));
6299            handle
6300                .set_name("deadline-name".to_string())
6301                .await
6302                .expect("set_name should succeed with inherited deadline");
6303
6304            let recorded = probe_rx
6305                .recv_timeout(Duration::from_secs(1))
6306                .expect("set_name deadline probe");
6307            assert_eq!(recorded, Some(expected_deadline));
6308
6309            let state = SessionHandle(Arc::clone(&session)).get_state().await;
6310            assert_eq!(
6311                state.get("sessionName").and_then(Value::as_str),
6312                Some("deadline-name")
6313            );
6314        });
6315    }
6316
6317    #[test]
6318    fn test_session_handle_set_model_and_thinking_level_dedupe_history() {
6319        let handle = SessionHandle(Arc::new(AsyncMutex::new(Session::in_memory())));
6320
6321        run_async(async {
6322            handle
6323                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6324                .await
6325        })
6326        .expect("set model");
6327        run_async(async {
6328            handle
6329                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6330                .await
6331        })
6332        .expect("repeat model");
6333        run_async(async { handle.set_thinking_level("high".to_string()).await })
6334            .expect("set thinking");
6335        run_async(async { handle.set_thinking_level("high".to_string()).await })
6336            .expect("repeat thinking");
6337
6338        let branch = run_async(async { handle.get_branch().await });
6339        let model_changes = branch
6340            .iter()
6341            .filter(|entry| {
6342                entry
6343                    .get("type")
6344                    .and_then(Value::as_str)
6345                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6346            })
6347            .count();
6348        let thinking_changes = branch
6349            .iter()
6350            .filter(|entry| {
6351                entry
6352                    .get("type")
6353                    .and_then(Value::as_str)
6354                    .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6355            })
6356            .count();
6357        assert_eq!(model_changes, 1);
6358        assert_eq!(thinking_changes, 1);
6359    }
6360
6361    #[test]
6362    fn test_session_handle_preserves_alias_equivalent_model_state() {
6363        let mut session = Session::in_memory();
6364        session.append_model_change("google".to_string(), "gemini-2.5-pro".to_string());
6365        session.set_model_header(
6366            Some("google".to_string()),
6367            Some("gemini-2.5-pro".to_string()),
6368            None,
6369        );
6370        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6371
6372        run_async(async {
6373            handle
6374                .set_model("gemini".to_string(), "GEMINI-2.5-PRO".to_string())
6375                .await
6376        })
6377        .expect("alias-equivalent model should dedupe");
6378
6379        let branch = run_async(async { handle.get_branch().await });
6380        let model_changes: Vec<_> = branch
6381            .iter()
6382            .filter_map(|entry| {
6383                if entry
6384                    .get("type")
6385                    .and_then(Value::as_str)
6386                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6387                {
6388                    Some((
6389                        entry.get("provider").and_then(Value::as_str),
6390                        entry.get("modelId").and_then(Value::as_str),
6391                    ))
6392                } else {
6393                    None
6394                }
6395            })
6396            .collect();
6397        assert_eq!(
6398            model_changes,
6399            vec![(Some("google"), Some("gemini-2.5-pro"))],
6400            "alias-equivalent set_model should not append duplicate history"
6401        );
6402
6403        let (provider, model_id) = run_async(async { handle.get_model().await });
6404        assert_eq!(provider.as_deref(), Some("google"));
6405        assert_eq!(model_id.as_deref(), Some("gemini-2.5-pro"));
6406
6407        let state = run_async(async { handle.get_state().await });
6408        assert_eq!(state["model"]["provider"], "google");
6409        assert_eq!(state["model"]["id"], "gemini-2.5-pro");
6410    }
6411
6412    #[test]
6413    fn session_handle_reports_branch_local_model_and_thinking_state() {
6414        let mut session = Session::in_memory();
6415        let root_id = session.append_message(make_test_message("root"));
6416
6417        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6418        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6419        session.set_model_header(
6420            Some("openai".to_string()),
6421            Some("gpt-4o".to_string()),
6422            Some("low".to_string()),
6423        );
6424
6425        assert!(session.create_branch_from(&root_id));
6426        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6427        session.append_thinking_level_change("high".to_string());
6428        session.set_model_header(
6429            Some("anthropic".to_string()),
6430            Some("claude-sonnet-4-5".to_string()),
6431            Some("high".to_string()),
6432        );
6433
6434        assert!(session.navigate_to(&branch_a_thinking));
6435
6436        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6437        let state = run_async(async { handle.get_state().await });
6438        let (provider, model_id) = run_async(async { handle.get_model().await });
6439        let thinking_level = run_async(async { handle.get_thinking_level().await });
6440
6441        assert_eq!(provider.as_deref(), Some("openai"));
6442        assert_eq!(model_id.as_deref(), Some("gpt-4o"));
6443        assert_eq!(thinking_level.as_deref(), Some("low"));
6444        assert_eq!(
6445            state
6446                .get("model")
6447                .and_then(|model| model.get("provider"))
6448                .and_then(Value::as_str),
6449            Some("openai")
6450        );
6451        assert_eq!(
6452            state
6453                .get("model")
6454                .and_then(|model| model.get("id"))
6455                .and_then(Value::as_str),
6456            Some("gpt-4o")
6457        );
6458        assert_eq!(
6459            state.get("thinkingLevel").and_then(Value::as_str),
6460            Some("low")
6461        );
6462    }
6463
6464    #[test]
6465    fn session_handle_set_model_and_thinking_level_dedupe_on_switched_branch() {
6466        let mut session = Session::in_memory();
6467        let root_id = session.append_message(make_test_message("root"));
6468
6469        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6470        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6471        session.set_model_header(
6472            Some("openai".to_string()),
6473            Some("gpt-4o".to_string()),
6474            Some("low".to_string()),
6475        );
6476
6477        assert!(session.create_branch_from(&root_id));
6478        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6479        session.append_thinking_level_change("high".to_string());
6480        session.set_model_header(
6481            Some("anthropic".to_string()),
6482            Some("claude-sonnet-4-5".to_string()),
6483            Some("high".to_string()),
6484        );
6485
6486        assert!(session.navigate_to(&branch_a_thinking));
6487
6488        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6489
6490        run_async(async {
6491            handle
6492                .set_model("openai".to_string(), "gpt-4o".to_string())
6493                .await
6494        })
6495        .expect("same-branch model should dedupe");
6496        run_async(async { handle.set_thinking_level("low".to_string()).await })
6497            .expect("same-branch thinking should dedupe");
6498
6499        let branch = run_async(async { handle.get_branch().await });
6500        let model_changes = branch
6501            .iter()
6502            .filter(|entry| {
6503                entry
6504                    .get("type")
6505                    .and_then(Value::as_str)
6506                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6507            })
6508            .count();
6509        let thinking_changes = branch
6510            .iter()
6511            .filter(|entry| {
6512                entry
6513                    .get("type")
6514                    .and_then(Value::as_str)
6515                    .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6516            })
6517            .count();
6518
6519        assert_eq!(model_changes, 1, "expected one branch-local model_change");
6520        assert_eq!(
6521            thinking_changes, 1,
6522            "expected one branch-local thinking_level_change"
6523        );
6524    }
6525
6526    #[test]
6527    fn test_autosave_queue_coalesces_mutations_per_flush() {
6528        let temp_dir = tempfile::tempdir().expect("temp dir");
6529        let mut session = Session::create();
6530        session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
6531
6532        session.append_message(make_test_message("one"));
6533        session.append_custom_entry("marker".to_string(), None);
6534        session.append_message(make_test_message("two"));
6535
6536        let before = session.autosave_metrics();
6537        assert_eq!(before.pending_mutations, 3);
6538        assert!(before.coalesced_mutations >= 2);
6539        assert_eq!(before.flush_succeeded, 0);
6540
6541        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
6542            .expect("periodic flush");
6543
6544        let after = session.autosave_metrics();
6545        assert_eq!(after.pending_mutations, 0);
6546        assert_eq!(after.flush_started, 1);
6547        assert_eq!(after.flush_succeeded, 1);
6548        assert_eq!(after.last_flush_batch_size, 3);
6549        assert_eq!(
6550            after.last_flush_trigger,
6551            Some(AutosaveFlushTrigger::Periodic)
6552        );
6553    }
6554
6555    #[test]
6556    fn test_autosave_queue_backpressure_is_bounded() {
6557        let mut session = Session::create();
6558        session.set_autosave_queue_limit_for_test(2);
6559
6560        for i in 0..5 {
6561            session.append_message(make_test_message(&format!("message-{i}")));
6562        }
6563
6564        let metrics = session.autosave_metrics();
6565        assert_eq!(metrics.max_pending_mutations, 2);
6566        assert_eq!(metrics.pending_mutations, 2);
6567        assert_eq!(metrics.backpressure_events, 3);
6568        assert!(metrics.coalesced_mutations >= 4);
6569    }
6570
6571    #[test]
6572    fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
6573        let temp_dir = tempfile::tempdir().expect("temp dir");
6574
6575        let mut strict = Session::create();
6576        // Point at a directory path so strict shutdown flush attempts fail.
6577        strict.path = Some(temp_dir.path().to_path_buf());
6578        strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
6579        strict.append_message(make_test_message("strict"));
6580
6581        run_async(async { strict.flush_autosave_on_shutdown().await })
6582            .expect_err("strict mode should propagate shutdown flush failure");
6583        let strict_metrics = strict.autosave_metrics();
6584        assert_eq!(strict_metrics.flush_failed, 1);
6585        assert!(strict_metrics.pending_mutations > 0);
6586
6587        let mut throughput = Session::create();
6588        throughput.path = Some(temp_dir.path().to_path_buf());
6589        throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6590        throughput.append_message(make_test_message("throughput"));
6591
6592        run_async(async { throughput.flush_autosave_on_shutdown().await })
6593            .expect("throughput mode skips shutdown flush");
6594        let throughput_metrics = throughput.autosave_metrics();
6595        assert_eq!(throughput_metrics.flush_started, 0);
6596        assert_eq!(throughput_metrics.pending_mutations, 1);
6597    }
6598
6599    #[test]
6600    fn test_session_new_prefers_cli_durability_mode_over_config() {
6601        let cli =
6602            crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
6603        let config: Config =
6604            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6605        let session =
6606            run_async(async { Session::new(&cli, &config).await }).expect("create session");
6607        assert_eq!(
6608            session.autosave_durability_mode(),
6609            AutosaveDurabilityMode::Strict
6610        );
6611    }
6612
6613    #[test]
6614    fn test_session_new_uses_config_durability_mode_when_cli_unset() {
6615        let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
6616        let config: Config =
6617            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6618        let session =
6619            run_async(async { Session::new(&cli, &config).await }).expect("create session");
6620        assert_eq!(
6621            session.autosave_durability_mode(),
6622            AutosaveDurabilityMode::Throughput
6623        );
6624    }
6625
6626    #[test]
6627    fn test_resolve_autosave_durability_mode_precedence() {
6628        assert_eq!(
6629            resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
6630            AutosaveDurabilityMode::Strict
6631        );
6632        assert_eq!(
6633            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
6634            AutosaveDurabilityMode::Throughput
6635        );
6636        assert_eq!(
6637            resolve_autosave_durability_mode(None, None, Some("strict")),
6638            AutosaveDurabilityMode::Strict
6639        );
6640        assert_eq!(
6641            resolve_autosave_durability_mode(None, None, None),
6642            AutosaveDurabilityMode::Balanced
6643        );
6644    }
6645
6646    #[test]
6647    fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
6648        assert_eq!(
6649            resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
6650            AutosaveDurabilityMode::Throughput
6651        );
6652        assert_eq!(
6653            resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
6654            AutosaveDurabilityMode::Strict
6655        );
6656        assert_eq!(
6657            resolve_autosave_durability_mode(None, None, Some("bad")),
6658            AutosaveDurabilityMode::Balanced
6659        );
6660    }
6661
6662    #[test]
6663    fn test_get_share_viewer_url_matches_legacy() {
6664        assert_eq!(
6665            build_share_viewer_url(None, "gist-123"),
6666            "https://buildwithpi.ai/session/#gist-123"
6667        );
6668        assert_eq!(
6669            build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
6670            "https://example.com/session/#gist-123"
6671        );
6672        assert_eq!(
6673            build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
6674            "https://example.com/session#gist-123"
6675        );
6676        // Legacy JS uses `process.env.PI_SHARE_VIEWER_URL || DEFAULT`, so empty-string should
6677        // fall back to default.
6678        assert_eq!(
6679            build_share_viewer_url(Some(""), "gist-123"),
6680            "https://buildwithpi.ai/session/#gist-123"
6681        );
6682    }
6683
6684    #[test]
6685    fn test_session_linear_history() {
6686        let mut session = Session::in_memory();
6687
6688        let id1 = session.append_message(make_test_message("Hello"));
6689        let id2 = session.append_message(make_test_message("World"));
6690        let id3 = session.append_message(make_test_message("Test"));
6691
6692        // Check leaf is the last entry
6693        assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
6694
6695        // Check path from last entry
6696        let path = session.get_path_to_entry(&id3);
6697        assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
6698
6699        // Check only one leaf
6700        let leaves = session.list_leaves();
6701        assert_eq!(leaves.len(), 1);
6702        assert_eq!(leaves[0], id3);
6703    }
6704
6705    #[test]
6706    fn test_session_branching() {
6707        let mut session = Session::in_memory();
6708
6709        // Create linear history: A -> B -> C
6710        let id_a = session.append_message(make_test_message("A"));
6711        let id_b = session.append_message(make_test_message("B"));
6712        let id_c = session.append_message(make_test_message("C"));
6713
6714        // Now branch from B: A -> B -> D
6715        assert!(session.create_branch_from(&id_b));
6716        let id_d = session.append_message(make_test_message("D"));
6717
6718        // Should have 2 leaves: C and D
6719        let leaves = session.list_leaves();
6720        assert_eq!(leaves.len(), 2);
6721        assert!(leaves.contains(&id_c));
6722        assert!(leaves.contains(&id_d));
6723
6724        // Path to D should be A -> B -> D
6725        let path_to_d = session.get_path_to_entry(&id_d);
6726        assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
6727
6728        // Path to C should be A -> B -> C
6729        let path_to_c = session.get_path_to_entry(&id_c);
6730        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6731    }
6732
6733    #[test]
6734    fn test_session_navigation() {
6735        let mut session = Session::in_memory();
6736
6737        let id1 = session.append_message(make_test_message("First"));
6738        let id2 = session.append_message(make_test_message("Second"));
6739
6740        // Navigate to first entry
6741        assert!(session.navigate_to(&id1));
6742        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6743
6744        // Navigate to non-existent entry
6745        assert!(!session.navigate_to("nonexistent"));
6746        // leaf_id unchanged
6747        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6748
6749        // Navigate back to second
6750        assert!(session.navigate_to(&id2));
6751        assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
6752    }
6753
6754    #[test]
6755    fn test_navigation_syncs_header_to_current_branch_metadata() {
6756        let mut session = Session::in_memory();
6757
6758        let root_id = session.append_message(make_test_message("root"));
6759        let openai_id = session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
6760        let high_id = session.append_thinking_level_change("high".to_string());
6761        let _tip_a = session.append_message(make_test_message("branch-a"));
6762
6763        assert!(session.create_branch_from(&root_id));
6764        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6765        let minimal_id = session.append_thinking_level_change("minimal".to_string());
6766        let _tip_b = session.append_message(make_test_message("branch-b"));
6767
6768        assert!(session.navigate_to(&high_id));
6769        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6770        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6771        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6772
6773        assert!(session.navigate_to(&minimal_id));
6774        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6775        assert_eq!(session.header.model_id.as_deref(), Some("claude-sonnet-4"));
6776        assert_eq!(session.header.thinking_level.as_deref(), Some("minimal"));
6777
6778        assert!(session.navigate_to(&openai_id));
6779        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6780        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6781    }
6782
6783    #[test]
6784    fn test_navigation_clears_stale_header_metadata_when_target_branch_has_no_override() {
6785        let mut session = Session::in_memory();
6786
6787        let root_id = session.append_message(make_test_message("root"));
6788        let branch_a_tip = session.append_message(make_test_message("branch-a"));
6789
6790        assert!(session.create_branch_from(&root_id));
6791        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6792        session.append_thinking_level_change("high".to_string());
6793        session.set_model_header(
6794            Some("anthropic".to_string()),
6795            Some("claude-sonnet-4".to_string()),
6796            Some("high".to_string()),
6797        );
6798
6799        assert!(session.navigate_to(&branch_a_tip));
6800        assert!(session.header.provider.is_none());
6801        assert!(session.header.model_id.is_none());
6802        assert!(session.header.thinking_level.is_none());
6803    }
6804
6805    #[test]
6806    fn test_open_materializes_header_fallback_for_historyless_branch_navigation() {
6807        let temp = tempfile::tempdir().expect("temp dir");
6808        let path = temp.path().join("legacy-historyless-branch.jsonl");
6809
6810        let mut legacy = Session::in_memory();
6811        legacy.header.provider = Some("openai".to_string());
6812        legacy.header.model_id = Some("gpt-5.4".to_string());
6813        legacy.header.thinking_level = Some("low".to_string());
6814
6815        let root_id = legacy.append_message(make_test_message("root"));
6816        let branch_b_tip = legacy.append_message(make_test_message("branch-b"));
6817
6818        assert!(legacy.create_branch_from(&root_id));
6819        legacy.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6820        legacy.append_thinking_level_change("high".to_string());
6821        let branch_a_tip = legacy.append_message(make_test_message("branch-a"));
6822
6823        legacy.header.current_leaf = Some(branch_b_tip.clone());
6824
6825        let mut jsonl = serde_json::to_string(&legacy.header).expect("serialize legacy header");
6826        jsonl.push('\n');
6827        for entry in &legacy.entries {
6828            jsonl.push_str(&serde_json::to_string(entry).expect("serialize session entry"));
6829            jsonl.push('\n');
6830        }
6831        std::fs::write(&path, jsonl).expect("write legacy session");
6832
6833        let mut loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6834            .expect("open legacy session");
6835
6836        assert_eq!(loaded.leaf_id.as_deref(), Some(branch_b_tip.as_str()));
6837        assert_eq!(loaded.header.fallback_provider.as_deref(), Some("openai"));
6838        assert_eq!(loaded.header.fallback_model_id.as_deref(), Some("gpt-5.4"));
6839        assert_eq!(
6840            loaded.header.fallback_thinking_level.as_deref(),
6841            Some("low")
6842        );
6843
6844        assert!(loaded.navigate_to(&branch_a_tip));
6845        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
6846        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-sonnet-4"));
6847        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
6848
6849        assert!(loaded.navigate_to(&branch_b_tip));
6850        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
6851        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
6852        assert_eq!(loaded.header.thinking_level.as_deref(), Some("low"));
6853    }
6854
6855    #[test]
6856    fn test_session_get_children() {
6857        let mut session = Session::in_memory();
6858
6859        // A -> B -> C
6860        //   -> D
6861        let id_a = session.append_message(make_test_message("A"));
6862        let id_b = session.append_message(make_test_message("B"));
6863        let _id_c = session.append_message(make_test_message("C"));
6864
6865        // Branch from A
6866        session.create_branch_from(&id_a);
6867        let id_d = session.append_message(make_test_message("D"));
6868
6869        // A should have 2 children: B and D
6870        let children_a = session.get_children(Some(&id_a));
6871        assert_eq!(children_a.len(), 2);
6872        assert!(children_a.contains(&id_b));
6873        assert!(children_a.contains(&id_d));
6874
6875        // Root (None) should have 1 child: A
6876        let root_children = session.get_children(None);
6877        assert_eq!(root_children.len(), 1);
6878        assert_eq!(root_children[0], id_a);
6879    }
6880
6881    #[test]
6882    fn test_branch_summary() {
6883        let mut session = Session::in_memory();
6884
6885        // Linear: A -> B
6886        let id_a = session.append_message(make_test_message("A"));
6887        let id_b = session.append_message(make_test_message("B"));
6888
6889        let info = session.branch_summary();
6890        assert_eq!(info.total_entries, 2);
6891        assert_eq!(info.leaf_count, 1);
6892        assert_eq!(info.branch_point_count, 0);
6893
6894        // Create branch: A -> B, A -> C
6895        session.create_branch_from(&id_a);
6896        let _id_c = session.append_message(make_test_message("C"));
6897
6898        let info = session.branch_summary();
6899        assert_eq!(info.total_entries, 3);
6900        assert_eq!(info.leaf_count, 2);
6901        assert_eq!(info.branch_point_count, 1);
6902        assert!(info.branch_points.contains(&id_a));
6903        assert!(info.leaves.contains(&id_b));
6904    }
6905
6906    fn build_branch_heavy_session(
6907        path: &Path,
6908        fork_count: usize,
6909        side_branch_len: usize,
6910    ) -> (Session, String) {
6911        let mut session = Session::create();
6912        session.path = Some(path.to_path_buf());
6913        let mut selected_tip = session.append_message(make_test_message("root"));
6914
6915        for fork_idx in 0..fork_count {
6916            assert!(
6917                session.navigate_to(&selected_tip),
6918                "navigate to selected tip before side branch {fork_idx}"
6919            );
6920            for side_idx in 0..side_branch_len {
6921                session.append_message(make_test_message(&format!("side-{fork_idx}-{side_idx}")));
6922            }
6923
6924            assert!(
6925                session.navigate_to(&selected_tip),
6926                "return to selected tip before active branch {fork_idx}"
6927            );
6928            selected_tip = session.append_message(make_test_message(&format!("active-{fork_idx}")));
6929        }
6930
6931        (session, selected_tip)
6932    }
6933
6934    const LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA: &str = "pi.session.large_replay_correctness.v1";
6935
6936    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6937    struct LargeReplayCorrectnessEvidence {
6938        schema: String,
6939        fixture: String,
6940        entry_count: usize,
6941        selected_depth: usize,
6942        replayed_entries: usize,
6943        skipped_sibling_entries: usize,
6944        index_initial_miss_files: usize,
6945        index_cached_hit_files: usize,
6946        index_cached_reused_files: usize,
6947        index_failed_files: usize,
6948        elapsed_budget_class: String,
6949        fallback_reason: Option<String>,
6950        baseline_message_count: usize,
6951        accelerated_message_count: usize,
6952        baseline_leaf: String,
6953        accelerated_leaf: String,
6954        verdict: String,
6955    }
6956
6957    fn cold_start_elapsed_budget_class(elapsed_us: u64) -> &'static str {
6958        match elapsed_us {
6959            0..=250_000 => "target",
6960            250_001..=1_000_000 => "bounded",
6961            _ => "observed_slow",
6962        }
6963    }
6964
6965    fn current_path_message_json(session: &Session) -> serde_json::Value {
6966        let mut value = serde_json::to_value(session.to_messages_for_current_path())
6967            .expect("serialize current-path messages");
6968        redact_json_timestamps(&mut value);
6969        value
6970    }
6971
6972    fn redact_json_timestamps(value: &mut serde_json::Value) {
6973        match value {
6974            serde_json::Value::Object(object) => {
6975                if object.contains_key("timestamp") {
6976                    object.insert("timestamp".to_string(), serde_json::json!(0));
6977                }
6978                for child in object.values_mut() {
6979                    redact_json_timestamps(child);
6980                }
6981            }
6982            serde_json::Value::Array(items) => {
6983                for item in items {
6984                    redact_json_timestamps(item);
6985                }
6986            }
6987            serde_json::Value::Null
6988            | serde_json::Value::Bool(_)
6989            | serde_json::Value::Number(_)
6990            | serde_json::Value::String(_) => {}
6991        }
6992    }
6993
6994    fn append_large_replay_mixed_turns(session: &mut Session, selected_tip: &str) -> String {
6995        assert!(
6996            session.navigate_to(selected_tip),
6997            "return to active branch before mixed replay fixture"
6998        );
6999        let first_kept_id = selected_tip.to_string();
7000        session.append_model_change(
7001            "openai-responses".to_string(),
7002            "gpt-5.2-replay-harness".to_string(),
7003        );
7004        let tool_call_id = "call_large_replay";
7005        session.append_message(make_test_tool_call_message(tool_call_id));
7006        session.append_message(make_test_tool_result_message(tool_call_id));
7007        let aborted_id =
7008            session.append_message(make_test_aborted_assistant_message("interrupted assistant"));
7009        session.append_bash_execution(
7010            "cargo test session replay index".to_string(),
7011            "cancelled by operator".to_string(),
7012            130,
7013            true,
7014            false,
7015            None,
7016        );
7017        session.append_compaction(
7018            "large replay harness compaction".to_string(),
7019            first_kept_id,
7020            42_000,
7021            Some(serde_json::json!({
7022                "reason": "large_replay_correctness_harness",
7023            })),
7024            Some(false),
7025        );
7026        session.append_branch_summary(
7027            aborted_id,
7028            "interrupted turn branch summary".to_string(),
7029            Some(serde_json::json!({
7030                "turn_state": "interrupted",
7031            })),
7032            Some(false),
7033        );
7034        session.append_message(make_test_message("active-after-interrupted-turn"))
7035    }
7036
7037    #[allow(clippy::too_many_lines)]
7038    #[test]
7039    fn cold_start_replay_minimization_bounds_branch_heavy_v2_resume() {
7040        const FORKS: usize = 700;
7041        const SIDE_BRANCH_LEN: usize = 15;
7042        const MIXED_ACTIVE_ENTRIES: usize = 8;
7043        const MIXED_ACTIVE_REPLAYED_ENTRIES: usize = 7;
7044        const MIXED_ACTIVE_PROJECTED_MESSAGES: usize = 5;
7045
7046        let temp = tempdir_under_tmpdir("branch-heavy-v2-resume");
7047        let path = temp.path().join("branch-heavy.jsonl");
7048        let (mut session, mut selected_tip) =
7049            build_branch_heavy_session(&path, FORKS, SIDE_BRANCH_LEN);
7050        selected_tip = append_large_replay_mixed_turns(&mut session, &selected_tip);
7051        session.header.current_leaf = Some("stale-missing-leaf".to_string());
7052        run_async(async { session.save().await }).expect("save branch-heavy session");
7053
7054        let baseline_loaded =
7055            run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7056                .expect("open JSONL baseline session");
7057        let baseline_messages = current_path_message_json(&baseline_loaded);
7058
7059        create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7060
7061        let first_trace = run_async(async {
7062            Session::cold_start_trace_bundle(&path, temp.path())
7063                .await
7064                .expect("initial cold-start trace")
7065        });
7066        let trace = run_async(async {
7067            Session::cold_start_trace_bundle(&path, temp.path())
7068                .await
7069                .expect("cached cold-start trace")
7070        });
7071
7072        let accelerated_loaded =
7073            run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7074                .expect("open accelerated session");
7075        let accelerated_messages = current_path_message_json(&accelerated_loaded);
7076
7077        let expected_entries = 1 + (FORKS * (SIDE_BRANCH_LEN + 1)) + MIXED_ACTIVE_ENTRIES;
7078        let expected_depth = 1 + FORKS + MIXED_ACTIVE_ENTRIES;
7079        let expected_replayed_entries = 1 + FORKS + MIXED_ACTIVE_REPLAYED_ENTRIES;
7080        let expected_projected_messages = 1 + FORKS + MIXED_ACTIVE_PROJECTED_MESSAGES;
7081        assert_eq!(trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7082        assert_eq!(
7083            trace.replay_minimization.schema,
7084            SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA
7085        );
7086        assert_eq!(trace.storage.selected_backend, "v2_sidecar");
7087        assert_eq!(trace.storage.opened_backend, "v2_sidecar");
7088        assert_eq!(trace.input.total_entries, expected_depth);
7089        assert_eq!(trace.replay_minimization.entry_count, expected_entries);
7090        assert_eq!(trace.replay_minimization.branch_count, FORKS);
7091        assert_eq!(trace.replay_minimization.selected_depth, expected_depth);
7092        assert_eq!(
7093            trace.replay_minimization.replayed_entries,
7094            expected_replayed_entries
7095        );
7096        assert_eq!(
7097            trace.replay_minimization.skipped_sibling_entries,
7098            expected_entries - expected_depth
7099        );
7100        assert!(trace.replay_minimization.scanned_files >= 1);
7101        assert_eq!(trace.replay_minimization.fallback_behavior, None);
7102        assert_eq!(trace.replay_minimization.verdict, "bounded_selected_branch");
7103        assert_eq!(trace.compaction_scan.scanned_entries, expected_depth);
7104        assert_eq!(trace.compaction_scan.compaction_entries, 1);
7105        assert!(trace.compaction_scan.latest_compaction_present);
7106        assert_eq!(trace.compaction_scan.first_kept_entry_found, Some(true));
7107        assert_eq!(trace.first_render.current_path_entries, expected_depth);
7108        assert_eq!(
7109            trace.first_render.projected_messages,
7110            expected_projected_messages
7111        );
7112        assert_eq!(trace.first_render.tool_messages, 2);
7113        assert_eq!(trace.first_render.assistant_messages, 2);
7114        assert_eq!(trace.first_render.user_messages, 1 + FORKS + 1);
7115        assert_eq!(trace.first_render.system_messages, 0);
7116        assert!(first_trace.index_refresh.refreshed_files >= 1);
7117        assert_eq!(first_trace.index_refresh.failed_files, 0);
7118        assert!(trace.index_refresh.cache_hit_files >= 1);
7119        assert_eq!(
7120            trace.index_refresh.cache_hit_files,
7121            trace.index_refresh.reused_files
7122        );
7123        assert_eq!(trace.index_refresh.failed_files, 0);
7124
7125        assert_eq!(baseline_loaded.leaf_id(), Some(selected_tip.as_str()));
7126        assert_eq!(accelerated_loaded.leaf_id(), Some(selected_tip.as_str()));
7127        assert!(accelerated_loaded.v2_partial_hydration);
7128        assert_eq!(
7129            accelerated_loaded.v2_resume_mode,
7130            Some(V2OpenMode::ActivePath)
7131        );
7132        assert_eq!(
7133            accelerated_loaded.entries_for_current_path().len(),
7134            baseline_loaded.entries_for_current_path().len()
7135        );
7136        assert_eq!(
7137            accelerated_messages, baseline_messages,
7138            "accelerated V2 replay must match full JSONL replay"
7139        );
7140
7141        let evidence = LargeReplayCorrectnessEvidence {
7142            schema: LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA.to_string(),
7143            fixture: "branch-heavy-v2-resume".to_string(),
7144            entry_count: trace.replay_minimization.entry_count,
7145            selected_depth: trace.replay_minimization.selected_depth,
7146            replayed_entries: trace.replay_minimization.replayed_entries,
7147            skipped_sibling_entries: trace.replay_minimization.skipped_sibling_entries,
7148            index_initial_miss_files: first_trace.index_refresh.refreshed_files,
7149            index_cached_hit_files: trace.index_refresh.cache_hit_files,
7150            index_cached_reused_files: trace.index_refresh.reused_files,
7151            index_failed_files: trace.index_refresh.failed_files,
7152            elapsed_budget_class: cold_start_elapsed_budget_class(trace.total_elapsed_us)
7153                .to_string(),
7154            fallback_reason: trace.replay_minimization.fallback_behavior.clone(),
7155            baseline_message_count: baseline_loaded.to_messages_for_current_path().len(),
7156            accelerated_message_count: accelerated_loaded.to_messages_for_current_path().len(),
7157            baseline_leaf: baseline_loaded.leaf_id().unwrap_or_default().to_string(),
7158            accelerated_leaf: accelerated_loaded.leaf_id().unwrap_or_default().to_string(),
7159            verdict: trace.replay_minimization.verdict,
7160        };
7161        assert_eq!(evidence.fallback_reason, None);
7162        assert_eq!(
7163            evidence.baseline_message_count,
7164            evidence.accelerated_message_count
7165        );
7166        assert!(matches!(
7167            evidence.elapsed_budget_class.as_str(),
7168            "target" | "bounded" | "observed_slow"
7169        ));
7170        let serialized = serde_json::to_string(&evidence).expect("serialize evidence");
7171        assert!(!serialized.contains("side-0-0"));
7172        let parsed: LargeReplayCorrectnessEvidence =
7173            serde_json::from_str(&serialized).expect("parse evidence");
7174        assert_eq!(parsed, evidence);
7175    }
7176
7177    #[test]
7178    fn cold_start_replay_minimization_reports_missing_and_stale_sidecar_fallbacks() {
7179        let temp = tempdir_under_tmpdir("branch-heavy-fallbacks");
7180        let path = temp.path().join("branch-fallback.jsonl");
7181        let (mut session, _selected_tip) = build_branch_heavy_session(&path, 12, 3);
7182        run_async(async { session.save().await }).expect("save branch-heavy session");
7183
7184        let missing_sidecar_trace = run_async(async {
7185            Session::cold_start_trace_bundle(&path, temp.path())
7186                .await
7187                .expect("missing sidecar trace")
7188        });
7189        assert_eq!(missing_sidecar_trace.storage.selected_backend, "jsonl");
7190        assert_eq!(missing_sidecar_trace.storage.opened_backend, "jsonl");
7191        assert_eq!(
7192            missing_sidecar_trace
7193                .replay_minimization
7194                .fallback_behavior
7195                .as_deref(),
7196            Some("jsonl_full_scan_without_sidecar")
7197        );
7198        assert_eq!(
7199            missing_sidecar_trace.replay_minimization.verdict,
7200            "fallback_explicit"
7201        );
7202        assert!(
7203            missing_sidecar_trace
7204                .replay_minimization
7205                .skipped_sibling_entries
7206                > 0
7207        );
7208
7209        let corrupt_path = temp.path().join("branch-corrupt-tail.jsonl");
7210        let (mut corrupt_session, _selected_tip) = build_branch_heavy_session(&corrupt_path, 8, 2);
7211        run_async(async { corrupt_session.save().await }).expect("save corrupt-tail fixture");
7212        {
7213            use std::io::Write as _;
7214
7215            let mut file = std::fs::OpenOptions::new()
7216                .append(true)
7217                .open(&corrupt_path)
7218                .expect("open corrupt-tail fixture");
7219            writeln!(file, "{{invalid-tail-frame").expect("append corrupt tail frame");
7220        }
7221
7222        let corrupt_tail_trace = run_async(async {
7223            Session::cold_start_trace_bundle(&corrupt_path, temp.path())
7224                .await
7225                .expect("corrupt tail trace")
7226        });
7227        assert_eq!(
7228            corrupt_tail_trace.open_diagnostics.skipped_entries, 1,
7229            "corrupt tail frames must be surfaced in cold-start diagnostics"
7230        );
7231        assert_eq!(
7232            corrupt_tail_trace
7233                .replay_minimization
7234                .fallback_behavior
7235                .as_deref(),
7236            Some("corrupt_jsonl_entries_skipped")
7237        );
7238        assert_eq!(
7239            corrupt_tail_trace.replay_minimization.verdict,
7240            "fallback_explicit"
7241        );
7242
7243        create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7244        std::thread::sleep(Duration::from_millis(25));
7245        session.append_message(make_test_message("jsonl-tail-after-sidecar"));
7246        run_async(async { session.save().await }).expect("save stale jsonl tail");
7247
7248        let stale_sidecar_trace = run_async(async {
7249            Session::cold_start_trace_bundle(&path, temp.path())
7250                .await
7251                .expect("stale sidecar trace")
7252        });
7253        assert!(stale_sidecar_trace.storage.v2_sidecar_present);
7254        assert!(stale_sidecar_trace.storage.v2_sidecar_stale);
7255        assert_eq!(stale_sidecar_trace.storage.selected_backend, "jsonl");
7256        assert_eq!(stale_sidecar_trace.storage.opened_backend, "jsonl");
7257        assert_eq!(
7258            stale_sidecar_trace
7259                .replay_minimization
7260                .fallback_behavior
7261                .as_deref(),
7262            Some("v2_sidecar_stale")
7263        );
7264        assert_eq!(
7265            stale_sidecar_trace.replay_minimization.verdict,
7266            "fallback_explicit"
7267        );
7268    }
7269
7270    #[test]
7271    fn test_session_jsonl_serialization() {
7272        let temp = tempfile::tempdir().unwrap();
7273        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7274        session.header.provider = Some("anthropic".to_string());
7275        session.header.model_id = Some("claude-test".to_string());
7276        session.header.thinking_level = Some("medium".to_string());
7277
7278        let user_id = session.append_message(make_test_message("Hello"));
7279        let assistant = AssistantMessage {
7280            content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
7281            api: "anthropic".to_string(),
7282            provider: "anthropic".to_string(),
7283            model: "claude-test".to_string(),
7284            usage: Usage::default(),
7285            stop_reason: StopReason::Stop,
7286            error_message: None,
7287            timestamp: 0,
7288        };
7289        session.append_message(SessionMessage::Assistant { message: assistant });
7290        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
7291        session.append_thinking_level_change("high".to_string());
7292        session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
7293        session.append_branch_summary(user_id, "branch".to_string(), None, None);
7294        session.append_session_info(Some("my-session".to_string()));
7295
7296        run_async(async { session.save().await }).unwrap();
7297
7298        let path = session.path.clone().unwrap();
7299        let contents = std::fs::read_to_string(path).unwrap();
7300        let mut lines = contents.lines();
7301
7302        let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
7303        assert_eq!(header["type"], "session");
7304        assert_eq!(header["version"], SESSION_VERSION);
7305
7306        let mut types = Vec::new();
7307        for line in lines {
7308            let value: serde_json::Value = serde_json::from_str(line).unwrap();
7309            let entry_type = value["type"].as_str().unwrap_or_default().to_string();
7310            types.push(entry_type);
7311        }
7312
7313        assert!(types.contains(&"message".to_string()));
7314        assert!(types.contains(&"model_change".to_string()));
7315        assert!(types.contains(&"thinking_level_change".to_string()));
7316        assert!(types.contains(&"compaction".to_string()));
7317        assert!(types.contains(&"branch_summary".to_string()));
7318        assert!(types.contains(&"session_info".to_string()));
7319    }
7320
7321    #[test]
7322    fn cold_start_trace_bundle_is_bounded_redacted_and_cache_aware() {
7323        let temp = tempdir_under_tmpdir("pi-session-cold-start-");
7324        if let Some(tmpdir) = env::var_os("TMPDIR") {
7325            assert!(temp.path().starts_with(PathBuf::from(tmpdir)));
7326        }
7327
7328        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7329        session.header.cwd = "/private/project/secret-cwd".to_string();
7330        session.header.provider = Some("test-provider".to_string());
7331        session.header.model_id = Some("test-model".to_string());
7332
7333        let mut first_kept_entry_id = None;
7334        for idx in 0..640 {
7335            let text = if idx == 7 {
7336                "secret-user-message should not appear in trace".to_string()
7337            } else {
7338                format!("large-history-message-{idx}")
7339            };
7340            let id = session.append_message(make_test_message(&text));
7341            if idx == 512 {
7342                first_kept_entry_id = Some(id);
7343            }
7344            if idx % 128 == 0 {
7345                session.append_message(make_test_assistant_message(
7346                    &format!("secret-assistant-message-{idx}"),
7347                    32,
7348                ));
7349            }
7350        }
7351
7352        session.append_compaction(
7353            "secret compaction summary should not appear in trace".to_string(),
7354            first_kept_entry_id.expect("first kept entry id"),
7355            12_345,
7356            None,
7357            None,
7358        );
7359        for idx in 0..16 {
7360            session.append_message(make_test_message(&format!("tail-message-{idx}")));
7361        }
7362
7363        run_async(async { session.save().await }).expect("save large session");
7364        let path = session.path.clone().expect("session path");
7365
7366        let first_trace = run_async(async {
7367            Session::cold_start_trace_bundle(&path, temp.path())
7368                .await
7369                .expect("first cold-start trace")
7370        });
7371        let second_trace = run_async(async {
7372            Session::cold_start_trace_bundle(&path, temp.path())
7373                .await
7374                .expect("second cold-start trace")
7375        });
7376
7377        assert_eq!(first_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7378        assert_eq!(second_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7379        assert_eq!(second_trace.storage.selected_backend, "jsonl");
7380        assert_eq!(second_trace.storage.opened_backend, "jsonl");
7381        assert!(second_trace.index_refresh.scanned_files >= 1);
7382        assert!(second_trace.index_refresh.cache_hit_files >= 1);
7383        assert_eq!(
7384            second_trace.index_refresh.cache_hit_files,
7385            second_trace.index_refresh.reused_files
7386        );
7387        assert_eq!(
7388            second_trace.open_diagnostics,
7389            SessionColdStartOpenDiagnosticsTrace {
7390                skipped_entries: 0,
7391                orphaned_parent_links: 0,
7392            }
7393        );
7394        assert!(second_trace.compaction_scan.latest_compaction_present);
7395        assert_eq!(
7396            second_trace.compaction_scan.first_kept_entry_found,
7397            Some(true)
7398        );
7399        assert!(second_trace.first_render.ready);
7400        assert!(second_trace.first_render.current_path_entries >= 640);
7401        assert!(second_trace.first_render.projected_messages >= 640);
7402        assert!(second_trace.first_render.total_tokens >= 32);
7403
7404        let phase_names = second_trace
7405            .phases
7406            .iter()
7407            .map(|phase| phase.name.as_str())
7408            .collect::<Vec<_>>();
7409        assert_eq!(
7410            phase_names,
7411            vec![
7412                "session_open",
7413                "session_index_refresh",
7414                "compaction_scan",
7415                "first_render_ready",
7416            ]
7417        );
7418        assert!(second_trace.phases.len() <= second_trace.bounds.max_phase_count);
7419        assert!(!second_trace.bounds.raw_path_included);
7420        assert!(!second_trace.bounds.raw_cwd_included);
7421        assert!(!second_trace.bounds.raw_message_content_included);
7422
7423        let serialized = serde_json::to_string(&second_trace).expect("serialize trace");
7424        assert!(!serialized.contains("secret-user-message"));
7425        assert!(!serialized.contains("secret-assistant-message"));
7426        assert!(!serialized.contains("secret compaction summary"));
7427        assert!(!serialized.contains("secret-cwd"));
7428        assert!(!serialized.contains(&path.display().to_string()));
7429        assert!(!serialized.contains(&temp.path().display().to_string()));
7430        assert_eq!(second_trace.session_path_hash.len(), 16);
7431    }
7432
7433    #[test]
7434    fn test_save_handles_short_or_empty_session_id() {
7435        let temp = tempfile::tempdir().unwrap();
7436        let project_cwd = temp.path().join("project");
7437        std::fs::create_dir(&project_cwd).expect("create project cwd");
7438        let project_cwd = project_cwd.display().to_string();
7439
7440        let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7441        short_id_session.header.cwd.clone_from(&project_cwd);
7442        short_id_session.header.id = "x".to_string();
7443        run_async(async { short_id_session.save().await }).expect("save with short id");
7444        let short_name = short_id_session
7445            .path
7446            .as_ref()
7447            .and_then(|p| p.file_name())
7448            .and_then(|n| n.to_str())
7449            .expect("short id filename");
7450        assert!(short_name.contains("_x."));
7451
7452        let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7453        empty_id_session.header.cwd.clone_from(&project_cwd);
7454        empty_id_session.header.id.clear();
7455        run_async(async { empty_id_session.save().await }).expect("save with empty id");
7456        let empty_name = empty_id_session
7457            .path
7458            .as_ref()
7459            .and_then(|p| p.file_name())
7460            .and_then(|n| n.to_str())
7461            .expect("empty id filename");
7462        assert!(empty_name.contains("_session."));
7463
7464        let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7465        unsafe_id_session.header.cwd.clone_from(&project_cwd);
7466        unsafe_id_session.header.id = "../etc/passwd".to_string();
7467        run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
7468        let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
7469        let unsafe_name = unsafe_path
7470            .file_name()
7471            .and_then(|n| n.to_str())
7472            .expect("unsafe id filename");
7473        assert!(unsafe_name.contains("____etc_p."));
7474        let expected_dir = temp.path().join(encode_cwd(Path::new(&project_cwd)));
7475        assert_eq!(
7476            unsafe_path.parent().expect("unsafe id parent"),
7477            expected_dir.as_path()
7478        );
7479    }
7480
7481    #[test]
7482    fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
7483        let temp = tempfile::tempdir().unwrap();
7484        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7485
7486        let first_id = session.append_message(make_test_message("Hello"));
7487        let second_id = session.append_message(make_test_message("World"));
7488        assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
7489
7490        run_async(async { session.save().await }).unwrap();
7491        let path = session.path.clone().expect("session path set");
7492
7493        let mut lines = std::fs::read_to_string(&path)
7494            .expect("read session")
7495            .lines()
7496            .map(str::to_string)
7497            .collect::<Vec<_>>();
7498        assert!(lines.len() >= 3, "expected header + 2 entries");
7499
7500        let corrupted_line_number = lines.len(); // 1-based
7501        let last_index = lines.len() - 1;
7502        lines[last_index] = "{ this is not json }".to_string();
7503
7504        let corrupted_path = temp.path().join("corrupted.jsonl");
7505        std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
7506            .expect("write corrupted session");
7507
7508        let (loaded, diagnostics) = run_async(async {
7509            Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
7510        })
7511        .expect("open corrupted session");
7512
7513        assert_eq!(diagnostics.skipped_entries.len(), 1);
7514        assert_eq!(
7515            diagnostics.skipped_entries[0].line_number,
7516            corrupted_line_number
7517        );
7518
7519        let warnings = diagnostics.warning_lines();
7520        assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
7521        assert!(
7522            warnings[0].starts_with(&format!(
7523                "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
7524            )),
7525            "unexpected warning: {}",
7526            warnings[0]
7527        );
7528        assert_eq!(
7529            warnings[1],
7530            "Warning: Skipped 1 corrupted entries while loading session"
7531        );
7532
7533        assert_eq!(
7534            loaded.entries.len(),
7535            session.entries.len() - 1,
7536            "expected last entry to be dropped"
7537        );
7538        assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
7539    }
7540
7541    #[test]
7542    fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
7543        let temp = tempfile::tempdir().unwrap();
7544        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7545
7546        let root_id = session.append_message(make_test_message("Hello"));
7547        session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
7548        session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
7549
7550        run_async(async { session.save().await }).unwrap();
7551        let path = session.path.clone().expect("session path set");
7552
7553        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7554            .expect("reopen session");
7555
7556        assert!(loaded.entries.iter().any(|entry| {
7557            matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary.eq("compacted") && compaction.tokens_before.eq(&123))
7558        }));
7559        assert!(loaded.entries.iter().any(|entry| {
7560            matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary.eq("branch summary"))
7561        }));
7562
7563        let html = loaded.to_html();
7564        assert!(html.contains("compacted"));
7565        assert!(html.contains("branch summary"));
7566    }
7567
7568    #[test]
7569    fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
7570        let temp = tempfile::tempdir().unwrap();
7571        let base_dir = temp.path().join("sessions");
7572
7573        let mut session = Session::create_with_dir(Some(base_dir));
7574        session.append_message(make_test_message("Hello"));
7575
7576        run_async(async { session.save().await }).expect("initial save");
7577        let path = session.path.clone().expect("session path set");
7578
7579        let path1 = path.clone();
7580        let path2 = path.clone();
7581
7582        let t1 = std::thread::spawn(move || {
7583            let runtime = RuntimeBuilder::current_thread()
7584                .build()
7585                .expect("build runtime");
7586            runtime.block_on(async move {
7587                let mut s = Session::open(path1.to_string_lossy().as_ref())
7588                    .await
7589                    .expect("open session");
7590                s.append_message(make_test_message("From thread 1"));
7591                s.save().await
7592            })
7593        });
7594
7595        let t2 = std::thread::spawn(move || {
7596            let runtime = RuntimeBuilder::current_thread()
7597                .build()
7598                .expect("build runtime");
7599            runtime.block_on(async move {
7600                let mut s = Session::open(path2.to_string_lossy().as_ref())
7601                    .await
7602                    .expect("open session");
7603                s.append_message(make_test_message("From thread 2"));
7604                s.save().await
7605            })
7606        });
7607
7608        let r1 = t1.join().expect("thread 1 join");
7609        let r2 = t2.join().expect("thread 2 join");
7610        assert!(
7611            r1.is_ok() || r2.is_ok(),
7612            "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
7613        );
7614
7615        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7616            .expect("open after concurrent saves");
7617        assert!(!loaded.entries.is_empty());
7618    }
7619
7620    #[test]
7621    fn test_to_messages_for_current_path() {
7622        let mut session = Session::in_memory();
7623
7624        // Tree structure:
7625        // A -> B -> C
7626        //       \-> D  (D branches from B)
7627        let _id_a = session.append_message(make_test_message("A"));
7628        let id_b = session.append_message(make_test_message("B"));
7629        let _id_c = session.append_message(make_test_message("C"));
7630
7631        // Navigate to B and add D
7632        session.create_branch_from(&id_b);
7633        let id_d = session.append_message(make_test_message("D"));
7634
7635        // Current path should be A -> B -> D
7636        session.navigate_to(&id_d);
7637        let messages = session.to_messages_for_current_path();
7638        assert_eq!(messages.len(), 3);
7639
7640        // Verify content
7641        if let Message::User(user) = &messages[0] {
7642            if let UserContent::Text(text) = &user.content {
7643                assert_eq!(text, "A");
7644            }
7645        }
7646        if let Message::User(user) = &messages[2] {
7647            if let UserContent::Text(text) = &user.content {
7648                assert_eq!(text, "D");
7649            }
7650        }
7651    }
7652
7653    #[test]
7654    fn test_reset_leaf_produces_empty_current_path() {
7655        let mut session = Session::in_memory();
7656
7657        let _id_a = session.append_message(make_test_message("A"));
7658        let _id_b = session.append_message(make_test_message("B"));
7659
7660        session.reset_leaf();
7661        assert!(session.entries_for_current_path().is_empty());
7662        assert!(session.to_messages_for_current_path().is_empty());
7663
7664        // After reset, the next entry becomes a new root.
7665        let id_root = session.append_message(make_test_message("Root"));
7666        let entry = session.get_entry(&id_root).expect("entry");
7667        assert!(entry.base().parent_id.is_none());
7668    }
7669
7670    #[test]
7671    fn test_encode_cwd() {
7672        let path = std::path::Path::new("/home/user/project");
7673        let encoded = encode_cwd(path);
7674        assert!(encoded.starts_with("--"));
7675        assert!(encoded.ends_with("--"));
7676        assert!(encoded.contains("home-user-project"));
7677    }
7678
7679    // ======================================================================
7680    // Session creation and header validation
7681    // ======================================================================
7682
7683    #[test]
7684    fn test_session_header_defaults() {
7685        let header = SessionHeader::new();
7686        assert_eq!(header.r#type, "session");
7687        assert_eq!(header.version, Some(SESSION_VERSION));
7688        assert!(!header.id.is_empty());
7689        assert!(!header.timestamp.is_empty());
7690        assert!(header.provider.is_none());
7691        assert!(header.model_id.is_none());
7692        assert!(header.thinking_level.is_none());
7693        assert!(header.parent_session.is_none());
7694    }
7695
7696    #[test]
7697    fn test_session_create_produces_unique_ids() {
7698        let s1 = Session::create();
7699        let s2 = Session::create();
7700        assert_ne!(s1.header.id, s2.header.id);
7701    }
7702
7703    #[test]
7704    fn test_in_memory_session_has_no_path() {
7705        let session = Session::in_memory();
7706        assert!(session.path.is_none());
7707        assert!(session.leaf_id.is_none());
7708        assert!(session.entries.is_empty());
7709    }
7710
7711    #[test]
7712    fn test_create_with_dir_stores_session_dir() {
7713        let temp = tempfile::tempdir().unwrap();
7714        let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7715        assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
7716    }
7717
7718    // ======================================================================
7719    // Message types: tool result, bash execution, custom
7720    // ======================================================================
7721
7722    #[test]
7723    fn test_append_tool_result_message() {
7724        let mut session = Session::in_memory();
7725        let user_id = session.append_message(make_test_message("Hello"));
7726
7727        let tool_msg = SessionMessage::ToolResult {
7728            tool_call_id: "call_123".to_string(),
7729            tool_name: "read".to_string(),
7730            content: vec![ContentBlock::Text(TextContent::new("file contents"))],
7731            details: None,
7732            is_error: false,
7733            timestamp: Some(1000),
7734        };
7735        let tool_id = session.append_message(tool_msg);
7736
7737        // Verify parent linking
7738        let entry = session.get_entry(&tool_id).unwrap();
7739        assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
7740
7741        // Verify it converts to model message
7742        let messages = session.to_messages();
7743        assert_eq!(messages.len(), 2);
7744        assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id.eq("call_123")));
7745    }
7746
7747    #[test]
7748    fn test_tool_result_artifact_metadata_round_trip_without_full_payload() {
7749        let temp = tempfile::tempdir().unwrap();
7750        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7751        session.append_message(make_test_message("list the huge directory"));
7752
7753        let omitted_payload = "x".repeat(1024);
7754        let preview = "entry-0000.txt\nentry-0001.txt\n\n[Full tool output artifact: /tmp/pi-tool-artifacts/call/abc.txt (5000000 bytes, 50000 lines, sha256 abc). Use read on this path to inspect more.]";
7755        session.append_message(SessionMessage::ToolResult {
7756            tool_call_id: "call_artifact".to_string(),
7757            tool_name: "ls".to_string(),
7758            content: vec![ContentBlock::Text(TextContent::new(preview))],
7759            details: Some(serde_json::json!({
7760                "artifact": {
7761                    "schema": "pi.tool_output_artifact.v1",
7762                    "id": "tool-artifact-abc",
7763                    "toolName": "ls",
7764                    "sourceKind": "directoryEntries",
7765                    "path": "/tmp/pi-tool-artifacts/call/abc.txt",
7766                    "metadataPath": "/tmp/pi-tool-artifacts/call/abc.json",
7767                    "sha256": "abc",
7768                    "byteCount": 5_000_000_u64,
7769                    "lineCount": 50_000,
7770                    "previewBytes": preview.len(),
7771                    "contentType": "text/plain; charset=utf-8"
7772                }
7773            })),
7774            is_error: false,
7775            timestamp: Some(12346),
7776        });
7777
7778        run_async(async { session.save().await }).unwrap();
7779        let path = session.path.clone().unwrap();
7780        let jsonl = std::fs::read_to_string(&path).unwrap();
7781        assert!(jsonl.contains("\"schema\":\"pi.tool_output_artifact.v1\""));
7782        assert!(!jsonl.contains(&omitted_payload));
7783
7784        let loaded =
7785            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7786        let tool_result = loaded
7787            .to_messages()
7788            .into_iter()
7789            .find_map(|message| match message {
7790                Message::ToolResult(result) if result.tool_call_id.eq("call_artifact") => {
7791                    Some(result)
7792                }
7793                _ => None,
7794            })
7795            .expect("artifact tool result");
7796
7797        assert_eq!(
7798            tool_result
7799                .details
7800                .as_ref()
7801                .and_then(|details| details.pointer("/artifact/schema"))
7802                .and_then(Value::as_str),
7803            Some("pi.tool_output_artifact.v1")
7804        );
7805        assert!(tool_result.content.iter().all(|block| match block {
7806            ContentBlock::Text(text) => !text.text.contains(&omitted_payload),
7807            _ => true,
7808        }));
7809    }
7810
7811    #[test]
7812    fn test_append_tool_result_error() {
7813        let mut session = Session::in_memory();
7814        session.append_message(make_test_message("Hello"));
7815
7816        let tool_msg = SessionMessage::ToolResult {
7817            tool_call_id: "call_err".to_string(),
7818            tool_name: "bash".to_string(),
7819            content: vec![ContentBlock::Text(TextContent::new("command not found"))],
7820            details: None,
7821            is_error: true,
7822            timestamp: Some(2000),
7823        };
7824        let tool_id = session.append_message(tool_msg);
7825
7826        let entry = session.get_entry(&tool_id).expect("should find tool entry");
7827        if let SessionEntry::Message(msg) = entry {
7828            if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
7829                assert!(is_error);
7830            } else {
7831                test_fail!("Expected SessionMessage::ToolResult, got {:?}", msg.message);
7832            }
7833        } else {
7834            test_fail!("Expected SessionEntry::Message");
7835        }
7836    }
7837
7838    #[test]
7839    fn test_append_bash_execution() {
7840        let mut session = Session::in_memory();
7841        session.append_message(make_test_message("run something"));
7842
7843        let bash_id = session.append_bash_execution(
7844            "echo hello".to_string(),
7845            "hello\n".to_string(),
7846            0,
7847            false,
7848            false,
7849            None,
7850        );
7851
7852        let entry = session.get_entry(&bash_id).expect("should find bash entry");
7853        if let SessionEntry::Message(msg) = entry {
7854            if let SessionMessage::BashExecution {
7855                command, exit_code, ..
7856            } = &msg.message
7857            {
7858                assert_eq!(command, "echo hello");
7859                assert_eq!(*exit_code, 0);
7860            } else {
7861                test_fail!(
7862                    "Expected SessionMessage::BashExecution, got {:?}",
7863                    msg.message
7864                );
7865            }
7866        } else {
7867            test_fail!("Expected SessionEntry::Message");
7868        }
7869
7870        // BashExecution converts to User message for model context
7871        let messages = session.to_messages();
7872        assert_eq!(messages.len(), 2);
7873        assert!(matches!(&messages[1], Message::User(_)));
7874    }
7875
7876    #[test]
7877    fn test_bash_execution_exclude_from_context() {
7878        let mut session = Session::in_memory();
7879        session.append_message(make_test_message("run something"));
7880
7881        let id = session.next_entry_id();
7882        let base = EntryBase::new(session.leaf_id.clone(), id.clone());
7883        let mut extra = HashMap::new();
7884        extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
7885        let entry = SessionEntry::Message(MessageEntry {
7886            base,
7887            message: SessionMessage::BashExecution {
7888                command: "secret".to_string(),
7889                output: "hidden".to_string(),
7890                exit_code: 0,
7891                cancelled: None,
7892                truncated: None,
7893                full_output_path: None,
7894                timestamp: Some(0),
7895                extra,
7896            },
7897        });
7898        session.leaf_id = Some(id);
7899        session.entries.push(entry);
7900        session.entry_ids = entry_id_set(&session.entries);
7901
7902        // The excluded bash execution should not appear in model messages
7903        let messages = session.to_messages();
7904        assert_eq!(messages.len(), 1); // only the user message
7905    }
7906
7907    #[test]
7908    fn test_append_custom_message() {
7909        let mut session = Session::in_memory();
7910        session.append_message(make_test_message("Hello"));
7911
7912        let custom_msg = SessionMessage::Custom {
7913            custom_type: "extension_state".to_string(),
7914            content: "some state".to_string(),
7915            display: false,
7916            details: Some(serde_json::json!({"key": "value"})),
7917            timestamp: Some(0),
7918        };
7919        let custom_id = session.append_message(custom_msg);
7920
7921        let entry = session
7922            .get_entry(&custom_id)
7923            .expect("should find custom entry");
7924        if let SessionEntry::Message(msg) = entry {
7925            if let SessionMessage::Custom {
7926                custom_type,
7927                display,
7928                ..
7929            } = &msg.message
7930            {
7931                assert_eq!(custom_type, "extension_state");
7932                assert!(!display);
7933            } else {
7934                test_fail!("Expected SessionMessage::Custom, got {:?}", msg.message);
7935            }
7936        } else {
7937            test_fail!("Expected SessionEntry::Message");
7938        }
7939    }
7940
7941    #[test]
7942    fn test_append_custom_entry() {
7943        let mut session = Session::in_memory();
7944        let root_id = session.append_message(make_test_message("Hello"));
7945
7946        let custom_id =
7947            session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
7948
7949        let entry = session
7950            .get_entry(&custom_id)
7951            .expect("should find custom entry");
7952        if let SessionEntry::Custom(custom) = entry {
7953            assert_eq!(custom.custom_type, "my_type");
7954            assert_eq!(custom.data, Some(serde_json::json!(42)));
7955            assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
7956        } else {
7957            test_fail!("Expected SessionEntry::Custom, got {:?}", entry);
7958        }
7959    }
7960
7961    // ======================================================================
7962    // Parent linking / tree structure
7963    // ======================================================================
7964
7965    #[test]
7966    fn test_parent_linking_chain() {
7967        let mut session = Session::in_memory();
7968
7969        let id1 = session.append_message(make_test_message("A"));
7970        let id2 = session.append_message(make_test_message("B"));
7971        let id3 = session.append_message(make_test_message("C"));
7972
7973        // First entry has no parent
7974        let e1 = session.get_entry(&id1).unwrap();
7975        assert!(e1.base().parent_id.is_none());
7976
7977        // Second entry's parent is first
7978        let e2 = session.get_entry(&id2).unwrap();
7979        assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
7980
7981        // Third entry's parent is second
7982        let e3 = session.get_entry(&id3).unwrap();
7983        assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
7984    }
7985
7986    #[test]
7987    fn test_model_change_updates_leaf() {
7988        let mut session = Session::in_memory();
7989
7990        let msg_id = session.append_message(make_test_message("Hello"));
7991        let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
7992
7993        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
7994
7995        let entry = session
7996            .get_entry(&change_id)
7997            .expect("should find change entry");
7998        assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
7999
8000        if let SessionEntry::ModelChange(mc) = entry {
8001            assert_eq!(mc.provider, "openai");
8002            assert_eq!(mc.model_id, "gpt-4");
8003        } else {
8004            test_fail!("Expected SessionEntry::ModelChange, got {:?}", entry);
8005        }
8006    }
8007
8008    #[test]
8009    fn test_thinking_level_change_updates_leaf() {
8010        let mut session = Session::in_memory();
8011        session.append_message(make_test_message("Hello"));
8012
8013        let change_id = session.append_thinking_level_change("high".to_string());
8014        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
8015
8016        let entry = session
8017            .get_entry(&change_id)
8018            .expect("should find change entry");
8019        if let SessionEntry::ThinkingLevelChange(tlc) = entry {
8020            assert_eq!(tlc.thinking_level, "high");
8021        } else {
8022            test_fail!(
8023                "Expected SessionEntry::ThinkingLevelChange, got {:?}",
8024                entry
8025            );
8026        }
8027    }
8028
8029    // ======================================================================
8030    // Session name get/set
8031    // ======================================================================
8032
8033    #[test]
8034    fn test_get_name_returns_latest() {
8035        let mut session = Session::in_memory();
8036
8037        assert!(session.get_name().is_none());
8038
8039        session.set_name("first");
8040        assert_eq!(session.get_name().as_deref(), Some("first"));
8041
8042        session.set_name("second");
8043        assert_eq!(session.get_name().as_deref(), Some("second"));
8044    }
8045
8046    #[test]
8047    fn test_set_name_returns_entry_id() {
8048        let mut session = Session::in_memory();
8049        let id = session.set_name("test-name");
8050        assert!(!id.is_empty());
8051        let entry = session.get_entry(&id).unwrap();
8052        assert!(matches!(entry, SessionEntry::SessionInfo(_)));
8053    }
8054
8055    // ======================================================================
8056    // Label
8057    // ======================================================================
8058
8059    #[test]
8060    fn test_add_label_to_existing_entry() {
8061        let mut session = Session::in_memory();
8062        let msg_id = session.append_message(make_test_message("Hello"));
8063
8064        let label_id = session.add_label(&msg_id, Some("important".to_string()));
8065        assert!(label_id.is_some());
8066
8067        let entry = session
8068            .get_entry(&label_id.unwrap())
8069            .expect("should find label entry");
8070        if let SessionEntry::Label(label) = entry {
8071            assert_eq!(label.target_id, msg_id);
8072            assert_eq!(label.label.as_deref(), Some("important"));
8073        } else {
8074            test_fail!("Expected SessionEntry::Label, got {:?}", entry);
8075        }
8076    }
8077
8078    #[test]
8079    fn test_add_label_to_nonexistent_entry_returns_none() {
8080        let mut session = Session::in_memory();
8081        let result = session.add_label("nonexistent", Some("label".to_string()));
8082        assert!(result.is_none());
8083    }
8084
8085    // ======================================================================
8086    // JSONL round-trip (save + reload)
8087    // ======================================================================
8088
8089    #[test]
8090    fn test_round_trip_preserves_all_message_types() {
8091        let temp = tempfile::tempdir().unwrap();
8092        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8093
8094        // Append diverse message types
8095        session.append_message(make_test_message("user text"));
8096
8097        let assistant = AssistantMessage {
8098            content: vec![ContentBlock::Text(TextContent::new("response"))],
8099            api: "anthropic".to_string(),
8100            provider: "anthropic".to_string(),
8101            model: "claude-test".to_string(),
8102            usage: Usage::default(),
8103            stop_reason: StopReason::Stop,
8104            error_message: None,
8105            timestamp: 0,
8106        };
8107        session.append_message(SessionMessage::Assistant { message: assistant });
8108
8109        session.append_message(SessionMessage::ToolResult {
8110            tool_call_id: "call_1".to_string(),
8111            tool_name: "read".to_string(),
8112            content: vec![ContentBlock::Text(TextContent::new("result"))],
8113            details: None,
8114            is_error: false,
8115            timestamp: Some(100),
8116        });
8117
8118        session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
8119
8120        session.append_custom_entry(
8121            "ext_data".to_string(),
8122            Some(serde_json::json!({"foo": "bar"})),
8123        );
8124
8125        run_async(async { session.save().await }).unwrap();
8126        let path = session.path.clone().unwrap();
8127
8128        let loaded =
8129            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8130
8131        assert_eq!(loaded.entries.len(), session.entries.len());
8132        assert_eq!(loaded.header.id, session.header.id);
8133        assert_eq!(loaded.header.version, Some(SESSION_VERSION));
8134
8135        // Verify specific entry types survived the round-trip
8136        let has_tool_result = loaded.entries.iter().any(|e| {
8137            matches!(
8138                e,
8139                SessionEntry::Message(m) if matches!(
8140                    &m.message,
8141                    SessionMessage::ToolResult { tool_name, .. } if tool_name.eq("read")
8142                )
8143            )
8144        });
8145        assert!(has_tool_result, "tool result should survive round-trip");
8146
8147        let has_bash = loaded.entries.iter().any(|e| {
8148            matches!(
8149                e,
8150                SessionEntry::Message(m) if matches!(
8151                    &m.message,
8152                    SessionMessage::BashExecution { command, .. } if command.eq("ls")
8153                )
8154            )
8155        });
8156        assert!(has_bash, "bash execution should survive round-trip");
8157
8158        let has_custom = loaded.entries.iter().any(|e| {
8159            matches!(
8160                e,
8161                SessionEntry::Custom(c) if c.custom_type.eq("ext_data")
8162            )
8163        });
8164        assert!(has_custom, "custom entry should survive round-trip");
8165    }
8166
8167    #[test]
8168    fn test_round_trip_preserves_leaf_id() {
8169        let temp = tempfile::tempdir().unwrap();
8170        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8171
8172        let _id1 = session.append_message(make_test_message("A"));
8173        let id2 = session.append_message(make_test_message("B"));
8174
8175        run_async(async { session.save().await }).unwrap();
8176        let path = session.path.clone().unwrap();
8177
8178        let loaded =
8179            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8180
8181        assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
8182    }
8183
8184    #[test]
8185    fn test_round_trip_preserves_selected_branch_leaf_and_header_state() {
8186        let temp = tempfile::tempdir().unwrap();
8187        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8188
8189        let root_id = session.append_message(make_test_message("root"));
8190        let _openai_model =
8191            session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
8192        session.set_model_header(
8193            Some("openai".to_string()),
8194            Some("gpt-5.4".to_string()),
8195            None,
8196        );
8197        let high_id = session.append_thinking_level_change("high".to_string());
8198        session.set_model_header(None, None, Some("high".to_string()));
8199
8200        assert!(session.create_branch_from(&root_id));
8201        let _anthropic_model =
8202            session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
8203        session.set_model_header(
8204            Some("anthropic".to_string()),
8205            Some("claude-sonnet-4".to_string()),
8206            None,
8207        );
8208        session.append_thinking_level_change("medium".to_string());
8209        session.set_model_header(None, None, Some("medium".to_string()));
8210
8211        assert!(session.navigate_to(&high_id));
8212
8213        run_async(async { session.save().await }).unwrap();
8214        let path = session.path.clone().unwrap();
8215
8216        let loaded =
8217            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8218
8219        assert_eq!(loaded.leaf_id.as_deref(), Some(high_id.as_str()));
8220        assert_eq!(
8221            loaded.header.current_leaf.as_deref(),
8222            Some(high_id.as_str())
8223        );
8224        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
8225        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
8226        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8227    }
8228
8229    #[test]
8230    fn test_append_after_branch_navigation_clears_persisted_leaf_override() {
8231        let temp = tempfile::tempdir().unwrap();
8232        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8233
8234        let id_a = session.append_message(make_test_message("A"));
8235        let id_b = session.append_message(make_test_message("B"));
8236        session.append_message(make_test_message("C"));
8237
8238        assert!(session.create_branch_from(&id_a));
8239        session.append_message(make_test_message("D"));
8240
8241        assert!(session.navigate_to(&id_b));
8242        let id_e = session.append_message(make_test_message("E"));
8243
8244        run_async(async { session.save().await }).unwrap();
8245        let path = session.path.clone().unwrap();
8246
8247        let loaded =
8248            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8249
8250        assert_eq!(loaded.leaf_id.as_deref(), Some(id_e.as_str()));
8251        assert!(loaded.header.current_leaf.is_none());
8252    }
8253
8254    #[test]
8255    fn test_round_trip_preserves_header_fields() {
8256        let temp = tempfile::tempdir().unwrap();
8257        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8258        session.header.provider = Some("anthropic".to_string());
8259        session.header.model_id = Some("claude-opus".to_string());
8260        session.header.thinking_level = Some("high".to_string());
8261        session.header.parent_session = Some("/old/session.jsonl".to_string());
8262
8263        session.append_message(make_test_message("Hello"));
8264        run_async(async { session.save().await }).unwrap();
8265        let path = session.path.clone().unwrap();
8266
8267        let loaded =
8268            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8269
8270        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
8271        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
8272        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8273        assert_eq!(
8274            loaded.header.parent_session.as_deref(),
8275            Some("/old/session.jsonl")
8276        );
8277    }
8278
8279    #[test]
8280    fn test_empty_session_save_and_reload() {
8281        let temp = tempfile::tempdir().unwrap();
8282        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8283
8284        run_async(async { session.save().await }).unwrap();
8285        let path = session.path.clone().unwrap();
8286
8287        let loaded =
8288            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8289
8290        assert!(loaded.entries.is_empty());
8291        assert!(loaded.leaf_id.is_none());
8292        assert_eq!(loaded.header.id, session.header.id);
8293    }
8294
8295    // ======================================================================
8296    // Corrupted JSONL recovery
8297    // ======================================================================
8298
8299    #[test]
8300    fn test_corrupted_middle_entry_preserves_surrounding_entries() {
8301        let temp = tempfile::tempdir().unwrap();
8302        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8303
8304        let id1 = session.append_message(make_test_message("First"));
8305        let id2 = session.append_message(make_test_message("Second"));
8306        let id3 = session.append_message(make_test_message("Third"));
8307
8308        run_async(async { session.save().await }).unwrap();
8309        let path = session.path.clone().unwrap();
8310
8311        // Corrupt the middle entry (line 3, 1-indexed: header=1, first=2, second=3)
8312        let mut lines: Vec<String> = std::fs::read_to_string(&path)
8313            .unwrap()
8314            .lines()
8315            .map(str::to_string)
8316            .collect();
8317        assert!(lines.len() >= 4);
8318        lines[2] = "GARBAGE JSON".to_string();
8319        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8320
8321        let (loaded, diagnostics) = run_async(async {
8322            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8323        })
8324        .unwrap();
8325
8326        let diag = serde_json::json!({
8327            "fixture_id": "session-corrupted-middle-entry-replay-integrity",
8328            "path": path.display().to_string(),
8329            "seed": "deterministic-static",
8330            "env": {
8331                "os": std::env::consts::OS,
8332                "arch": std::env::consts::ARCH,
8333            },
8334            "expected": {
8335                "skipped_entries": 1,
8336                "orphaned_parent_links": 1,
8337            },
8338            "actual": {
8339                "skipped_entries": diagnostics.skipped_entries.len(),
8340                "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
8341                "leaf_id": loaded.leaf_id,
8342            },
8343        })
8344        .to_string();
8345
8346        assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
8347        assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
8348        assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
8349        assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
8350        assert_eq!(
8351            diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
8352            "{diag}"
8353        );
8354        assert!(
8355            diagnostics.warning_lines().iter().any(|line| {
8356                line.contains("references missing parent")
8357                    && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
8358            }),
8359            "{diag}"
8360        );
8361
8362        // First and third entries should survive
8363        assert_eq!(loaded.entries.len(), 2, "{diag}");
8364        assert!(loaded.get_entry(&id1).is_some(), "{diag}");
8365        assert!(loaded.get_entry(&id3).is_some(), "{diag}");
8366    }
8367
8368    #[test]
8369    fn test_multiple_corrupted_entries_recovery() {
8370        let temp = tempfile::tempdir().unwrap();
8371        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8372
8373        session.append_message(make_test_message("A"));
8374        session.append_message(make_test_message("B"));
8375        session.append_message(make_test_message("C"));
8376        session.append_message(make_test_message("D"));
8377
8378        run_async(async { session.save().await }).unwrap();
8379        let path = session.path.clone().unwrap();
8380
8381        let mut lines: Vec<String> = std::fs::read_to_string(&path)
8382            .unwrap()
8383            .lines()
8384            .map(str::to_string)
8385            .collect();
8386        // Corrupt entries B (line 3) and D (line 5)
8387        lines[2] = "BAD".to_string();
8388        lines[4] = "ALSO BAD".to_string();
8389        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8390
8391        let (loaded, diagnostics) = run_async(async {
8392            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8393        })
8394        .unwrap();
8395
8396        assert_eq!(diagnostics.skipped_entries.len(), 2);
8397        assert_eq!(loaded.entries.len(), 2); // A and C survive
8398    }
8399
8400    #[test]
8401    fn test_corrupted_header_fails_to_open() {
8402        let temp = tempfile::tempdir().unwrap();
8403        let path = temp.path().join("bad_header.jsonl");
8404        std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
8405
8406        let result = run_async(async {
8407            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8408        });
8409        assert!(
8410            result.is_err(),
8411            "corrupted header should cause open failure"
8412        );
8413    }
8414
8415    // ======================================================================
8416    // Branching and navigation
8417    // ======================================================================
8418
8419    #[test]
8420    fn test_create_branch_from_nonexistent_returns_false() {
8421        let mut session = Session::in_memory();
8422        session.append_message(make_test_message("A"));
8423        assert!(!session.create_branch_from("nonexistent"));
8424    }
8425
8426    #[test]
8427    fn test_deep_branching() {
8428        let mut session = Session::in_memory();
8429
8430        // Create A -> B -> C
8431        let id_a = session.append_message(make_test_message("A"));
8432        let id_b = session.append_message(make_test_message("B"));
8433        let _id_c = session.append_message(make_test_message("C"));
8434
8435        // Branch from A: A -> D
8436        session.create_branch_from(&id_a);
8437        let _id_d = session.append_message(make_test_message("D"));
8438
8439        // Branch from B: A -> B -> E
8440        session.create_branch_from(&id_b);
8441        let id_e = session.append_message(make_test_message("E"));
8442
8443        // Should have 3 leaves: C, D, E
8444        let leaves = session.list_leaves();
8445        assert_eq!(leaves.len(), 3);
8446
8447        // Path to E is A -> B -> E
8448        let path = session.get_path_to_entry(&id_e);
8449        assert_eq!(path.len(), 3);
8450        assert_eq!(path[0], id_a);
8451        assert_eq!(path[1], id_b);
8452        assert_eq!(path[2], id_e);
8453    }
8454
8455    #[test]
8456    fn test_sibling_branches_at_fork() {
8457        let mut session = Session::in_memory();
8458
8459        // Create A -> B -> C
8460        let id_a = session.append_message(make_test_message("A"));
8461        let _id_b = session.append_message(make_test_message("B"));
8462        let _id_c = session.append_message(make_test_message("C"));
8463
8464        // Branch from A: A -> D
8465        session.create_branch_from(&id_a);
8466        let id_d = session.append_message(make_test_message("D"));
8467
8468        // Navigate to D to make it current
8469        session.navigate_to(&id_d);
8470
8471        let siblings = session.sibling_branches();
8472        assert!(siblings.is_some());
8473        let (fork_point, branches) = siblings.unwrap();
8474        assert!(
8475            fork_point.is_none()
8476                || fork_point
8477                    .as_deref()
8478                    .is_some_and(|fork_point_id| fork_point_id.eq(id_a.as_str()))
8479        );
8480        assert_eq!(branches.len(), 2);
8481
8482        // One should be current, one not
8483        let current_count = branches.iter().filter(|b| b.is_current).count();
8484        assert_eq!(current_count, 1);
8485    }
8486
8487    #[test]
8488    fn test_sibling_branches_no_fork() {
8489        let mut session = Session::in_memory();
8490        session.append_message(make_test_message("A"));
8491        session.append_message(make_test_message("B"));
8492
8493        // No fork points, so sibling_branches returns None
8494        assert!(session.sibling_branches().is_none());
8495    }
8496
8497    // ======================================================================
8498    // Plan fork
8499    // ======================================================================
8500
8501    #[test]
8502    fn test_plan_fork_from_user_message() {
8503        let mut session = Session::in_memory();
8504
8505        let _id_a = session.append_message(make_test_message("First question"));
8506        let assistant = AssistantMessage {
8507            content: vec![ContentBlock::Text(TextContent::new("Answer"))],
8508            api: "anthropic".to_string(),
8509            provider: "anthropic".to_string(),
8510            model: "test".to_string(),
8511            usage: Usage::default(),
8512            stop_reason: StopReason::Stop,
8513            error_message: None,
8514            timestamp: 0,
8515        };
8516        let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
8517        let id_c = session.append_message(make_test_message("Second question"));
8518
8519        // Fork from the second user message
8520        let plan = session.plan_fork_from_user_message(&id_c).unwrap();
8521        assert_eq!(plan.selected_text, "Second question");
8522        // Entries should be the path up to (but not including) the forked message
8523        assert_eq!(plan.entries.len(), 2); // A and B
8524    }
8525
8526    #[test]
8527    fn test_plan_fork_from_root_message() {
8528        let mut session = Session::in_memory();
8529        let id_a = session.append_message(make_test_message("Root question"));
8530
8531        let plan = session.plan_fork_from_user_message(&id_a).unwrap();
8532        assert_eq!(plan.selected_text, "Root question");
8533        assert!(plan.entries.is_empty()); // No entries before root
8534        assert!(plan.leaf_id.is_none());
8535    }
8536
8537    #[test]
8538    fn test_plan_fork_from_nonexistent_fails() {
8539        let session = Session::in_memory();
8540        assert!(session.plan_fork_from_user_message("nonexistent").is_err());
8541    }
8542
8543    #[test]
8544    fn test_plan_fork_from_assistant_message_fails() {
8545        let mut session = Session::in_memory();
8546        session.append_message(make_test_message("Q"));
8547        let assistant = AssistantMessage {
8548            content: vec![ContentBlock::Text(TextContent::new("A"))],
8549            api: "anthropic".to_string(),
8550            provider: "anthropic".to_string(),
8551            model: "test".to_string(),
8552            usage: Usage::default(),
8553            stop_reason: StopReason::Stop,
8554            error_message: None,
8555            timestamp: 0,
8556        };
8557        let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
8558
8559        assert!(session.plan_fork_from_user_message(&asst_id).is_err());
8560    }
8561
8562    // ======================================================================
8563    // Compaction in message context
8564    // ======================================================================
8565
8566    #[test]
8567    fn test_compaction_truncates_model_context() {
8568        let mut session = Session::in_memory();
8569
8570        let _id_a = session.append_message(make_test_message("old message A"));
8571        let _id_b = session.append_message(make_test_message("old message B"));
8572        let id_c = session.append_message(make_test_message("kept message C"));
8573
8574        // Compact: keep from id_c onwards
8575        session.append_compaction(
8576            "Summary of old messages".to_string(),
8577            id_c,
8578            5000,
8579            None,
8580            None,
8581        );
8582
8583        let id_d = session.append_message(make_test_message("new message D"));
8584
8585        // Ensure we're at the right leaf
8586        session.navigate_to(&id_d);
8587
8588        let messages = session.to_messages_for_current_path();
8589        // Should have: compaction summary + kept message C + new message D
8590        // (old messages A and B should be omitted)
8591        assert!(messages.len() <= 4); // compaction summary + C + compaction entry + D
8592
8593        // Verify old messages are not in context
8594        let all_text: String = messages
8595            .iter()
8596            .filter_map(|m| match m {
8597                Message::User(u) => match &u.content {
8598                    UserContent::Text(t) => Some(t.clone()),
8599                    UserContent::Blocks(blocks) => {
8600                        let texts: Vec<String> = blocks
8601                            .iter()
8602                            .filter_map(|b| {
8603                                if let ContentBlock::Text(t) = b {
8604                                    Some(t.text.clone())
8605                                } else {
8606                                    None
8607                                }
8608                            })
8609                            .collect();
8610                        Some(texts.join(" "))
8611                    }
8612                },
8613                _ => None,
8614            })
8615            .collect::<Vec<_>>()
8616            .join(" ");
8617
8618        assert!(
8619            !all_text.contains("old message A"),
8620            "compacted message A should not appear in context"
8621        );
8622        assert!(
8623            !all_text.contains("old message B"),
8624            "compacted message B should not appear in context"
8625        );
8626        assert!(
8627            all_text.contains("kept message C") || all_text.contains("new message D"),
8628            "kept messages should appear in context"
8629        );
8630    }
8631
8632    // ======================================================================
8633    // Large session handling
8634    // ======================================================================
8635
8636    #[test]
8637    fn test_large_session_append_and_path() {
8638        let mut session = Session::in_memory();
8639
8640        let mut last_id = String::new();
8641        for i in 0..500 {
8642            last_id = session.append_message(make_test_message(&format!("msg-{i}")));
8643        }
8644
8645        assert_eq!(session.entries.len(), 500);
8646        assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
8647
8648        // Path from root to leaf should include all 500 entries
8649        let path = session.get_path_to_entry(&last_id);
8650        assert_eq!(path.len(), 500);
8651
8652        // Entries for current path should also be 500
8653        let current = session.entries_for_current_path();
8654        assert_eq!(current.len(), 500);
8655    }
8656
8657    #[test]
8658    fn test_large_session_save_and_reload() {
8659        let temp = tempfile::tempdir().unwrap();
8660        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8661
8662        for i in 0..200 {
8663            session.append_message(make_test_message(&format!("message {i}")));
8664        }
8665
8666        run_async(async { session.save().await }).unwrap();
8667        let path = session.path.clone().unwrap();
8668
8669        let loaded =
8670            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8671
8672        assert_eq!(loaded.entries.len(), 200);
8673        assert_eq!(loaded.header.id, session.header.id);
8674    }
8675
8676    // ======================================================================
8677    // Entry ID generation
8678    // ======================================================================
8679
8680    #[test]
8681    fn test_ensure_entry_ids_fills_missing() {
8682        let mut entries = vec![
8683            SessionEntry::Message(MessageEntry {
8684                base: EntryBase {
8685                    id: None,
8686                    parent_id: None,
8687                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8688                },
8689                message: SessionMessage::User {
8690                    content: UserContent::Text("test".to_string()),
8691                    timestamp: Some(0),
8692                },
8693            }),
8694            SessionEntry::Message(MessageEntry {
8695                base: EntryBase {
8696                    id: Some("existing".to_string()),
8697                    parent_id: None,
8698                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8699                },
8700                message: SessionMessage::User {
8701                    content: UserContent::Text("test2".to_string()),
8702                    timestamp: Some(0),
8703                },
8704            }),
8705        ];
8706
8707        ensure_entry_ids(&mut entries);
8708
8709        // First entry should now have an ID
8710        assert!(entries[0].base().id.is_some());
8711        // Second entry should keep its existing ID
8712        assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
8713        // IDs should be unique
8714        assert_ne!(entries[0].base().id, entries[1].base().id);
8715    }
8716
8717    #[test]
8718    fn test_generate_entry_id_produces_8_char_hex() {
8719        let existing = HashSet::new();
8720        let id = generate_entry_id(&existing);
8721        assert_eq!(id.len(), 8);
8722        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
8723    }
8724
8725    // ======================================================================
8726    // set_model_header / set_branched_from
8727    // ======================================================================
8728
8729    #[test]
8730    fn test_set_model_header() {
8731        let mut session = Session::in_memory();
8732        session.set_model_header(
8733            Some("anthropic".to_string()),
8734            Some("claude-opus".to_string()),
8735            Some("high".to_string()),
8736        );
8737        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
8738        assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
8739        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
8740    }
8741
8742    #[test]
8743    fn test_effective_model_and_thinking_use_current_header_without_change_entries() {
8744        let mut session = Session::in_memory();
8745        session.set_model_header(
8746            Some("openai".to_string()),
8747            Some("gpt-5.4".to_string()),
8748            Some("medium".to_string()),
8749        );
8750
8751        assert_eq!(
8752            session.effective_model_for_current_path(),
8753            Some(("openai".to_string(), "gpt-5.4".to_string()))
8754        );
8755        assert_eq!(
8756            session
8757                .effective_thinking_level_for_current_path()
8758                .as_deref(),
8759            Some("medium")
8760        );
8761    }
8762
8763    #[test]
8764    fn test_set_branched_from() {
8765        let mut session = Session::in_memory();
8766        assert!(session.header.parent_session.is_none());
8767
8768        session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
8769        assert_eq!(
8770            session.header.parent_session.as_deref(),
8771            Some("/path/to/parent.jsonl")
8772        );
8773    }
8774
8775    // ======================================================================
8776    // to_html rendering
8777    // ======================================================================
8778
8779    #[test]
8780    fn test_to_html_contains_all_message_types() {
8781        let mut session = Session::in_memory();
8782
8783        session.append_message(make_test_message("user question"));
8784
8785        let assistant = AssistantMessage {
8786            content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
8787            api: "anthropic".to_string(),
8788            provider: "anthropic".to_string(),
8789            model: "test".to_string(),
8790            usage: Usage::default(),
8791            stop_reason: StopReason::Stop,
8792            error_message: None,
8793            timestamp: 0,
8794        };
8795        session.append_message(SessionMessage::Assistant { message: assistant });
8796        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
8797        session.set_name("test-session-html");
8798
8799        let html = session.to_html();
8800        assert!(html.contains("<!doctype html>"));
8801        assert!(html.contains("user question"));
8802        assert!(html.contains("assistant answer"));
8803        assert!(html.contains("anthropic"));
8804        assert!(html.contains("test-session-html"));
8805    }
8806
8807    // ======================================================================
8808    // to_messages conversion
8809    // ======================================================================
8810
8811    #[test]
8812    fn test_to_messages_includes_all_message_entries() {
8813        let mut session = Session::in_memory();
8814
8815        session.append_message(make_test_message("Q1"));
8816        let assistant = AssistantMessage {
8817            content: vec![ContentBlock::Text(TextContent::new("A1"))],
8818            api: "anthropic".to_string(),
8819            provider: "anthropic".to_string(),
8820            model: "test".to_string(),
8821            usage: Usage::default(),
8822            stop_reason: StopReason::Stop,
8823            error_message: None,
8824            timestamp: 0,
8825        };
8826        session.append_message(SessionMessage::Assistant { message: assistant });
8827        session.append_message(SessionMessage::ToolResult {
8828            tool_call_id: "c1".to_string(),
8829            tool_name: "edit".to_string(),
8830            content: vec![ContentBlock::Text(TextContent::new("edited"))],
8831            details: None,
8832            is_error: false,
8833            timestamp: Some(0),
8834        });
8835
8836        // Non-message entries should NOT appear in to_messages()
8837        session.append_model_change("openai".to_string(), "gpt-4".to_string());
8838        session.append_session_info(Some("name".to_string()));
8839
8840        let messages = session.to_messages();
8841        assert_eq!(messages.len(), 3); // user + assistant + tool_result
8842    }
8843
8844    // ======================================================================
8845    // JSONL format validation
8846    // ======================================================================
8847
8848    #[test]
8849    fn test_jsonl_header_is_first_line() {
8850        let temp = tempfile::tempdir().unwrap();
8851        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8852        session.append_message(make_test_message("test"));
8853
8854        run_async(async { session.save().await }).unwrap();
8855        let path = session.path.clone().unwrap();
8856
8857        let contents = std::fs::read_to_string(path).unwrap();
8858        let first_line = contents.lines().next().unwrap();
8859        let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
8860
8861        assert_eq!(header["type"], "session");
8862        assert_eq!(header["version"], SESSION_VERSION);
8863        assert!(!header["id"].as_str().unwrap().is_empty());
8864        assert!(!header["timestamp"].as_str().unwrap().is_empty());
8865    }
8866
8867    #[test]
8868    fn test_jsonl_entries_have_camelcase_fields() {
8869        let temp = tempfile::tempdir().unwrap();
8870        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8871
8872        session.append_message(make_test_message("test"));
8873        session.append_model_change("provider".to_string(), "model".to_string());
8874
8875        run_async(async { session.save().await }).unwrap();
8876        let path = session.path.clone().unwrap();
8877
8878        let contents = std::fs::read_to_string(path).unwrap();
8879        let lines: Vec<&str> = contents.lines().collect();
8880
8881        // Check message entry (line 2)
8882        let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
8883        assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
8884
8885        // Check model change entry (line 3)
8886        let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
8887        assert!(mc_value.get("modelId").is_some());
8888    }
8889
8890    // ======================================================================
8891    // Session open errors
8892    // ======================================================================
8893
8894    #[test]
8895    fn test_open_nonexistent_file_returns_error() {
8896        let result =
8897            run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
8898        assert!(result.is_err());
8899    }
8900
8901    #[test]
8902    fn test_open_empty_file_returns_error() {
8903        let temp = tempfile::tempdir().unwrap();
8904        let path = temp.path().join("empty.jsonl");
8905        std::fs::write(&path, "").unwrap();
8906
8907        let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
8908        assert!(result.is_err());
8909    }
8910
8911    #[test]
8912    fn test_open_rejects_semantically_invalid_header() {
8913        let temp = tempfile::tempdir().unwrap();
8914        let path = temp.path().join("invalid_header.jsonl");
8915        std::fs::write(
8916            &path,
8917            r#"{"type":"note","version":3,"id":"bad","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
8918        )
8919        .unwrap();
8920
8921        let err = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
8922            .expect_err("invalid header should fail");
8923        let message = err.to_string();
8924        assert!(
8925            message.contains("Invalid session header"),
8926            "expected invalid session header error, got {message}"
8927        );
8928    }
8929
8930    #[test]
8931    fn test_save_rejects_semantically_invalid_header() {
8932        let temp = tempfile::tempdir().unwrap();
8933        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8934        session.header.r#type = "note".to_string();
8935
8936        let err =
8937            run_async(async { session.save().await }).expect_err("invalid header should fail");
8938        let message = err.to_string();
8939        assert!(
8940            message.contains("Invalid session header"),
8941            "expected invalid session header error, got {message}"
8942        );
8943    }
8944
8945    // ======================================================================
8946    // get_entry / get_entry_mut
8947    // ======================================================================
8948
8949    #[test]
8950    fn test_get_entry_returns_correct_entry() {
8951        let mut session = Session::in_memory();
8952        let id = session.append_message(make_test_message("Hello"));
8953
8954        let entry = session.get_entry(&id);
8955        assert!(entry.is_some());
8956        assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
8957    }
8958
8959    #[test]
8960    fn test_get_entry_mut_allows_modification() {
8961        let mut session = Session::in_memory();
8962        let id = session.append_message(make_test_message("Original"));
8963
8964        let entry = session.get_entry_mut(&id).unwrap();
8965        if let SessionEntry::Message(msg) = entry {
8966            msg.message = SessionMessage::User {
8967                content: UserContent::Text("Modified".to_string()),
8968                timestamp: Some(0),
8969            };
8970        }
8971
8972        // Verify modification persisted
8973        let entry = session.get_entry(&id).unwrap();
8974        if let SessionEntry::Message(msg) = entry {
8975            if let SessionMessage::User { content, .. } = &msg.message {
8976                match content {
8977                    UserContent::Text(t) => assert_eq!(t, "Modified"),
8978                    UserContent::Blocks(_) => test_fail!("Expected UserContent::Text, got Blocks"),
8979                }
8980            } else {
8981                test_fail!("Expected SessionMessage::User, got {:?}", msg.message);
8982            }
8983        }
8984    }
8985
8986    #[test]
8987    fn test_get_entry_nonexistent_returns_none() {
8988        let session = Session::in_memory();
8989        assert!(session.get_entry("nonexistent").is_none());
8990    }
8991
8992    // ======================================================================
8993    // Branching round-trip (save with branches, reload, verify)
8994    // ======================================================================
8995
8996    #[test]
8997    fn test_branching_round_trip_preserves_tree_structure() {
8998        let temp = tempfile::tempdir().unwrap();
8999        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9000
9001        // Create: A -> B -> C, then branch from A: A -> D
9002        let id_a = session.append_message(make_test_message("A"));
9003        let id_b = session.append_message(make_test_message("B"));
9004        let id_c = session.append_message(make_test_message("C"));
9005
9006        session.create_branch_from(&id_a);
9007        let id_d = session.append_message(make_test_message("D"));
9008
9009        // Verify pre-save state
9010        let leaves = session.list_leaves();
9011        assert_eq!(leaves.len(), 2);
9012
9013        run_async(async { session.save().await }).unwrap();
9014        let path = session.path.clone().unwrap();
9015
9016        let loaded =
9017            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9018
9019        // Verify tree structure survived round-trip
9020        assert_eq!(loaded.entries.len(), 4);
9021        let loaded_leaves = loaded.list_leaves();
9022        assert_eq!(loaded_leaves.len(), 2);
9023        assert!(loaded_leaves.contains(&id_c));
9024        assert!(loaded_leaves.contains(&id_d));
9025
9026        // Verify parent linking
9027        let path_to_c = loaded.get_path_to_entry(&id_c);
9028        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
9029
9030        let path_to_d = loaded.get_path_to_entry(&id_d);
9031        assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
9032    }
9033
9034    // ======================================================================
9035    // Session directory resolution from CWD
9036    // ======================================================================
9037
9038    #[test]
9039    fn test_encode_cwd_strips_leading_separators() {
9040        let path = std::path::Path::new("/home/user/my-project");
9041        let encoded = encode_cwd(path);
9042        assert_eq!(encoded, "--home-user-my-project--");
9043        assert!(!encoded.contains('/'));
9044    }
9045
9046    #[test]
9047    fn test_encode_cwd_handles_deeply_nested_path() {
9048        let path = std::path::Path::new("/a/b/c/d/e/f");
9049        let encoded = encode_cwd(path);
9050        assert_eq!(encoded, "--a-b-c-d-e-f--");
9051    }
9052
9053    #[test]
9054    fn test_save_creates_project_session_dir_from_cwd() {
9055        let temp = tempfile::tempdir().unwrap();
9056        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9057        session.append_message(make_test_message("test"));
9058
9059        run_async(async { session.save().await }).unwrap();
9060        let path = session.path.clone().unwrap();
9061
9062        // The saved path should be inside a CWD-encoded subdirectory
9063        let parent = path.parent().unwrap();
9064        let dir_name = parent.file_name().unwrap().to_string_lossy();
9065        assert!(
9066            dir_name.starts_with("--"),
9067            "session dir should start with --"
9068        );
9069        assert!(dir_name.ends_with("--"), "session dir should end with --");
9070
9071        // The file should have .jsonl extension
9072        assert_eq!(path.extension().unwrap(), "jsonl");
9073    }
9074
9075    #[test]
9076    fn test_save_uses_session_header_cwd_for_project_session_dir() {
9077        let _lock = current_dir_lock();
9078        let process_cwd = tempfile::tempdir().unwrap();
9079        let _guard = CurrentDirGuard::new(process_cwd.path());
9080
9081        let sessions_root = tempfile::tempdir().unwrap();
9082        let session_cwd = tempfile::tempdir().unwrap();
9083        let mut session = Session::create_with_dir(Some(sessions_root.path().to_path_buf()));
9084        session.header.cwd = session_cwd.path().display().to_string();
9085        session.append_message(make_test_message("test"));
9086
9087        run_async(async { session.save().await }).unwrap();
9088        let path = session.path.clone().expect("session path");
9089        let expected_dir = sessions_root.path().join(encode_cwd(session_cwd.path()));
9090        let process_dir = sessions_root.path().join(encode_cwd(process_cwd.path()));
9091
9092        assert_eq!(path.parent(), Some(expected_dir.as_path()));
9093        assert_ne!(path.parent(), Some(process_dir.as_path()));
9094    }
9095
9096    #[test]
9097    fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
9098        let known_entry = SessionPickEntry {
9099            path: PathBuf::from("session.jsonl"),
9100            id: "session-id".to_string(),
9101            cwd: "/work".to_string(),
9102            timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9103            message_count: 4,
9104            name: Some("cached".to_string()),
9105            last_modified_ms: 1234,
9106            size_bytes: 4096,
9107        };
9108
9109        assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
9110        assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
9111        assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
9112    }
9113
9114    #[test]
9115    fn read_capped_utf8_line_with_limit_rejects_oversized_line_without_newline() {
9116        let oversized = "x".repeat(5);
9117        let mut reader = std::io::Cursor::new(oversized.into_bytes());
9118
9119        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9120        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9121        assert!(err.to_string().contains("JSONL line exceeds 4 bytes"));
9122    }
9123
9124    #[test]
9125    fn read_capped_utf8_line_with_limit_allows_exact_limit_before_newline() {
9126        let mut reader = std::io::Cursor::new(b"abcd\n".to_vec());
9127
9128        let line = read_capped_utf8_line_with_limit(&mut reader, 4)
9129            .expect("read line")
9130            .expect("line present");
9131        assert_eq!(line, "abcd\n");
9132        assert!(
9133            read_capped_utf8_line_with_limit(&mut reader, 4)
9134                .expect("read eof")
9135                .is_none()
9136        );
9137    }
9138
9139    #[test]
9140    fn read_capped_utf8_line_with_limit_drains_oversized_line_remainder() {
9141        let mut reader = std::io::Cursor::new(b"xxxxx\ny\n".to_vec());
9142
9143        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9144        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9145
9146        let next_line = read_capped_utf8_line_with_limit(&mut reader, 4)
9147            .expect("read next line")
9148            .expect("next line present");
9149        assert_eq!(next_line, "y\n");
9150    }
9151
9152    #[test]
9153    fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
9154        let temp = tempfile::tempdir().unwrap();
9155        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9156        session.append_message(make_test_message("first"));
9157        session.append_message(make_test_message("second"));
9158
9159        run_async(async { session.save().await }).unwrap();
9160        let path = session.path.clone().expect("session path");
9161        let metadata = std::fs::metadata(&path).expect("session metadata");
9162        let disk_size = metadata.len();
9163        #[allow(clippy::cast_possible_truncation)]
9164        let disk_ms = metadata
9165            .modified()
9166            .unwrap_or(SystemTime::UNIX_EPOCH)
9167            .duration_since(UNIX_EPOCH)
9168            .unwrap_or_default()
9169            .as_millis() as i64;
9170
9171        let stale_known_entry = SessionPickEntry {
9172            path: path.clone(),
9173            id: session.header.id.clone(),
9174            cwd: session.header.cwd.clone(),
9175            timestamp: session.header.timestamp.clone(),
9176            message_count: 999,
9177            name: Some("stale".to_string()),
9178            last_modified_ms: disk_ms,
9179            size_bytes: disk_size.saturating_add(1),
9180        };
9181
9182        let session_dir = path.parent().expect("session parent").to_path_buf();
9183        let scanned =
9184            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9185                .expect("scan sessions");
9186        assert!(scanned.failed_paths.is_empty());
9187        assert_eq!(scanned.entries.len(), 1);
9188        assert_eq!(scanned.refreshed_entries.len(), 1);
9189        assert_eq!(scanned.entries[0].path, path);
9190        assert_eq!(scanned.entries[0].message_count, 2);
9191        assert_eq!(scanned.entries[0].size_bytes, disk_size);
9192    }
9193
9194    #[test]
9195    fn test_merge_scanned_session_entries_replaces_cached_entry_when_size_changes() {
9196        let path = PathBuf::from("session.jsonl");
9197        let mut by_path = HashMap::from([(
9198            path.clone(),
9199            SessionPickEntry {
9200                path: path.clone(),
9201                id: "session-id".to_string(),
9202                cwd: "/work".to_string(),
9203                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9204                message_count: 1,
9205                name: Some("cached".to_string()),
9206                last_modified_ms: 1234,
9207                size_bytes: 4096,
9208            },
9209        )]);
9210
9211        merge_scanned_session_entries(
9212            &mut by_path,
9213            vec![SessionPickEntry {
9214                path: path.clone(),
9215                id: "session-id".to_string(),
9216                cwd: "/work".to_string(),
9217                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9218                message_count: 2,
9219                name: Some("disk".to_string()),
9220                last_modified_ms: 1234,
9221                size_bytes: 8192,
9222            }],
9223        );
9224
9225        let merged = by_path.get(&path).expect("merged entry");
9226        assert_eq!(merged.message_count, 2);
9227        assert_eq!(merged.name.as_deref(), Some("disk"));
9228        assert_eq!(merged.size_bytes, 8192);
9229    }
9230
9231    #[test]
9232    fn test_merge_scanned_session_entries_replaces_cached_entry_even_if_disk_mtime_regresses() {
9233        let path = PathBuf::from("session.jsonl");
9234        let mut by_path = HashMap::from([(
9235            path.clone(),
9236            SessionPickEntry {
9237                path: path.clone(),
9238                id: "session-id".to_string(),
9239                cwd: "/work".to_string(),
9240                timestamp: "2026-01-02T00:00:00.000Z".to_string(),
9241                message_count: 9,
9242                name: Some("cached".to_string()),
9243                last_modified_ms: 2000,
9244                size_bytes: 4096,
9245            },
9246        )]);
9247
9248        merge_scanned_session_entries(
9249            &mut by_path,
9250            vec![SessionPickEntry {
9251                path: path.clone(),
9252                id: "session-id".to_string(),
9253                cwd: "/work".to_string(),
9254                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9255                message_count: 3,
9256                name: Some("disk".to_string()),
9257                last_modified_ms: 1000,
9258                size_bytes: 2048,
9259            }],
9260        );
9261
9262        let merged = by_path.get(&path).expect("merged entry");
9263        assert_eq!(merged.message_count, 3);
9264        assert_eq!(merged.name.as_deref(), Some("disk"));
9265        assert_eq!(merged.last_modified_ms, 1000);
9266        assert_eq!(merged.size_bytes, 2048);
9267    }
9268
9269    #[test]
9270    fn test_scan_sessions_on_disk_reports_failed_paths_for_corrupt_changed_session() {
9271        let temp = tempfile::tempdir().unwrap();
9272        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9273        session.append_message(make_test_message("first"));
9274        session.append_message(make_test_message("second"));
9275
9276        run_async(async { session.save().await }).unwrap();
9277        let path = session.path.clone().expect("session path");
9278        let metadata = std::fs::metadata(&path).expect("session metadata");
9279        let disk_size = metadata.len();
9280        #[allow(clippy::cast_possible_truncation)]
9281        let disk_ms = metadata
9282            .modified()
9283            .unwrap_or(SystemTime::UNIX_EPOCH)
9284            .duration_since(UNIX_EPOCH)
9285            .unwrap_or_default()
9286            .as_millis() as i64;
9287
9288        let stale_known_entry = SessionPickEntry {
9289            path: path.clone(),
9290            id: session.header.id.clone(),
9291            cwd: session.header.cwd.clone(),
9292            timestamp: session.header.timestamp.clone(),
9293            message_count: 999,
9294            name: Some("stale".to_string()),
9295            last_modified_ms: disk_ms,
9296            size_bytes: disk_size,
9297        };
9298
9299        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9300
9301        let session_dir = path.parent().expect("session parent").to_path_buf();
9302        let scanned =
9303            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9304                .expect("scan sessions");
9305
9306        assert!(scanned.entries.is_empty());
9307        assert!(scanned.refreshed_entries.is_empty());
9308        assert_eq!(scanned.failed_paths, vec![path]);
9309    }
9310
9311    #[test]
9312    fn test_continue_recent_in_dir_prunes_corrupt_stale_index_entry() {
9313        let _lock = current_dir_lock();
9314        let process_cwd = tempfile::tempdir().unwrap();
9315        let _guard = CurrentDirGuard::new(process_cwd.path());
9316
9317        let temp = tempfile::tempdir().unwrap();
9318        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9319        session.append_message(make_test_message("first"));
9320        session.append_message(make_test_message("second"));
9321
9322        run_async(async { session.save().await }).expect("save session");
9323        let path = session.path.clone().expect("session path");
9324
9325        let index = SessionIndex::for_sessions_root(temp.path());
9326        index.index_session(&session).expect("index session");
9327        let cwd_display = session.header.cwd.clone();
9328        let expected_path = path.display().to_string();
9329        let has_indexed_path = index
9330            .list_sessions(Some(&cwd_display))
9331            .expect("list indexed sessions")
9332            .into_iter()
9333            .any(|meta| meta.path.eq(&expected_path));
9334        assert!(
9335            has_indexed_path,
9336            "expected indexed session before corruption"
9337        );
9338
9339        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9340
9341        let resumed = run_async(async {
9342            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9343        })
9344        .expect("continue recent");
9345
9346        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9347        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9348
9349        let still_indexed = index
9350            .list_sessions(Some(&cwd_display))
9351            .expect("list indexed sessions after cleanup")
9352            .into_iter()
9353            .any(|meta| meta.path.eq(&expected_path));
9354        assert!(
9355            !still_indexed,
9356            "corrupt session should be pruned from the recent-session index"
9357        );
9358    }
9359
9360    #[test]
9361    fn test_continue_recent_in_dir_prunes_missing_stale_index_entry() {
9362        let _lock = current_dir_lock();
9363        let process_cwd = tempfile::tempdir().unwrap();
9364        let _guard = CurrentDirGuard::new(process_cwd.path());
9365
9366        let temp = tempfile::tempdir().unwrap();
9367        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9368        session.append_message(make_test_message("first"));
9369
9370        run_async(async { session.save().await }).expect("save session");
9371        let path = session.path.clone().expect("session path");
9372
9373        let index = SessionIndex::for_sessions_root(temp.path());
9374        index.index_session(&session).expect("index session");
9375        let cwd_display = session.header.cwd.clone();
9376        let expected_path = path.display().to_string();
9377        let has_indexed_path = index
9378            .list_sessions(Some(&cwd_display))
9379            .expect("list indexed sessions")
9380            .into_iter()
9381            .any(|meta| meta.path.eq(&expected_path));
9382        assert!(
9383            has_indexed_path,
9384            "expected indexed session before moving file"
9385        );
9386
9387        let moved_path = path.with_extension("bak");
9388        std::fs::rename(&path, &moved_path).expect("move session away from indexed path");
9389
9390        let resumed = run_async(async {
9391            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9392        })
9393        .expect("continue recent");
9394
9395        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9396        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9397
9398        let still_indexed = index
9399            .list_sessions(Some(&cwd_display))
9400            .expect("list indexed sessions after cleanup")
9401            .into_iter()
9402            .any(|meta| meta.path.eq(&expected_path));
9403        assert!(
9404            !still_indexed,
9405            "missing session should be pruned from the recent-session index"
9406        );
9407    }
9408
9409    #[test]
9410    fn test_continue_recent_in_dir_prunes_index_when_project_dir_is_missing() {
9411        let _lock = current_dir_lock();
9412        let process_cwd = tempfile::tempdir().unwrap();
9413        let _guard = CurrentDirGuard::new(process_cwd.path());
9414
9415        let temp = tempfile::tempdir().unwrap();
9416        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9417        session.append_message(make_test_message("first"));
9418
9419        run_async(async { session.save().await }).expect("save session");
9420        let path = session.path.clone().expect("session path");
9421
9422        let index = SessionIndex::for_sessions_root(temp.path());
9423        index.index_session(&session).expect("index session");
9424        let cwd_display = session.header.cwd.clone();
9425        let expected_path = path.display().to_string();
9426        let cwd = std::path::Path::new(&cwd_display);
9427        let project_session_dir = temp.path().join(encode_cwd(cwd));
9428        let moved_project_dir = temp.path().join("moved-project-dir");
9429
9430        std::fs::rename(&project_session_dir, &moved_project_dir)
9431            .expect("move project session dir away");
9432
9433        let resumed = run_async(async {
9434            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9435        })
9436        .expect("continue recent");
9437
9438        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9439        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9440
9441        let still_indexed = index
9442            .list_sessions(Some(&cwd_display))
9443            .expect("list indexed sessions after cleanup")
9444            .into_iter()
9445            .any(|meta| meta.path.eq(&expected_path));
9446        assert!(
9447            !still_indexed,
9448            "missing project dir should prune stale rows from the recent-session index"
9449        );
9450    }
9451
9452    #[cfg(unix)]
9453    #[test]
9454    fn split_indexed_session_entries_keeps_permission_denied_path_out_of_missing_bucket() {
9455        use crate::session_index::SessionMeta;
9456        use std::os::unix::fs::PermissionsExt;
9457
9458        let temp = tempfile::tempdir().unwrap();
9459        let guarded_dir = temp.path().join("guarded");
9460        std::fs::create_dir(&guarded_dir).expect("create guarded dir");
9461        let session_path = guarded_dir.join("session.jsonl");
9462        std::fs::write(&session_path, b"{\"version\":\"3\"}\n").expect("write session file");
9463
9464        let original_mode = std::fs::metadata(&guarded_dir)
9465            .expect("guarded dir metadata")
9466            .permissions()
9467            .mode();
9468        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(0o000))
9469            .expect("chmod guarded dir");
9470
9471        assert!(
9472            session_path.try_exists().is_err(),
9473            "expected permission-denied path probe for inaccessible parent directory"
9474        );
9475
9476        let meta = SessionMeta {
9477            path: session_path.display().to_string(),
9478            id: "session-id".to_string(),
9479            cwd: temp.path().display().to_string(),
9480            timestamp: "2026-03-15T00:00:00.000Z".to_string(),
9481            message_count: 1,
9482            last_modified_ms: 0,
9483            size_bytes: 16,
9484            name: Some("guarded".to_string()),
9485        };
9486
9487        let (entries, missing_paths) = split_indexed_session_entries(vec![meta]);
9488
9489        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(original_mode))
9490            .expect("restore guarded dir permissions");
9491
9492        assert!(
9493            missing_paths.is_empty(),
9494            "permission errors must not be classified as missing indexed sessions"
9495        );
9496        assert_eq!(entries.len(), 1);
9497        assert_eq!(entries[0].path, session_path);
9498    }
9499
9500    #[cfg(unix)]
9501    #[test]
9502    fn test_continue_recent_in_dir_prunes_unreadable_cached_entry_on_open_failure() {
9503        use std::os::unix::fs::PermissionsExt;
9504
9505        let temp = tempfile::tempdir().unwrap();
9506        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9507        session.append_message(make_test_message("first"));
9508
9509        run_async(async { session.save().await }).expect("save session");
9510        let path = session.path.clone().expect("session path");
9511
9512        let original_mode = std::fs::metadata(&path)
9513            .expect("session metadata")
9514            .permissions()
9515            .mode();
9516
9517        let index = SessionIndex::for_sessions_root(temp.path());
9518        index.index_session(&session).expect("index session");
9519        let expected_path = path.display().to_string();
9520        let cwd_display = std::env::current_dir()
9521            .expect("current dir")
9522            .display()
9523            .to_string();
9524
9525        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
9526            .expect("chmod unreadable");
9527
9528        let resumed = run_async(async {
9529            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9530        })
9531        .expect("continue recent");
9532
9533        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(original_mode))
9534            .expect("restore permissions");
9535
9536        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9537        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9538
9539        let still_indexed = index
9540            .list_sessions(Some(&cwd_display))
9541            .expect("list indexed sessions after cleanup")
9542            .into_iter()
9543            .any(|meta| meta.path.eq(&expected_path));
9544        assert!(
9545            !still_indexed,
9546            "unreadable session should be pruned from the recent-session index"
9547        );
9548    }
9549
9550    #[test]
9551    fn test_continue_recent_in_dir_refreshes_index_after_changed_disk_session() {
9552        let _lock = current_dir_lock();
9553        let process_cwd = tempfile::tempdir().unwrap();
9554        let _guard = CurrentDirGuard::new(process_cwd.path());
9555
9556        let temp = tempfile::tempdir().expect("tempdir");
9557        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9558        session.append_message(make_test_message("first"));
9559
9560        run_async(async { session.save().await }).expect("save session");
9561        let path = session.path.clone().expect("session path");
9562
9563        let index = SessionIndex::for_sessions_root(temp.path());
9564        index.index_session(&session).expect("index session");
9565        let cwd_display = session.header.cwd.clone();
9566
9567        std::fs::write(
9568            &path,
9569            format!(
9570                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9571                serde_json::to_string(&session.header).expect("serialize header"),
9572            ),
9573        )
9574        .expect("rewrite session");
9575
9576        let resumed = run_async(async {
9577            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9578        })
9579        .expect("continue recent");
9580
9581        assert_eq!(resumed.path.as_ref(), Some(&path));
9582
9583        let indexed = index
9584            .list_sessions(Some(&cwd_display))
9585            .expect("list indexed sessions");
9586        assert_eq!(indexed.len(), 1);
9587        assert_eq!(indexed[0].path, path.display().to_string());
9588        assert_eq!(indexed[0].message_count, 2);
9589        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9590    }
9591
9592    #[test]
9593    fn test_resume_with_picker_refreshes_index_after_changed_disk_session() {
9594        let _lock = current_dir_lock();
9595        let process_cwd = tempfile::tempdir().unwrap();
9596        let _guard = CurrentDirGuard::new(process_cwd.path());
9597
9598        let temp = tempfile::tempdir().expect("tempdir");
9599        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9600        session.append_message(make_test_message("first"));
9601
9602        run_async(async { session.save().await }).expect("save session");
9603        let path = session.path.clone().expect("session path");
9604
9605        let index = SessionIndex::for_sessions_root(temp.path());
9606        index.index_session(&session).expect("index session");
9607        let cwd_display = session.header.cwd.clone();
9608
9609        std::fs::write(
9610            &path,
9611            format!(
9612                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9613                serde_json::to_string(&session.header).expect("serialize header"),
9614            ),
9615        )
9616        .expect("rewrite session");
9617
9618        let resumed = run_async(async {
9619            Session::resume_with_picker(
9620                Some(temp.path()),
9621                &Config::default(),
9622                Some("1".to_string()),
9623            )
9624            .await
9625        })
9626        .expect("resume with picker");
9627
9628        assert_eq!(resumed.path.as_ref(), Some(&path));
9629
9630        let indexed = index
9631            .list_sessions(Some(&cwd_display))
9632            .expect("list indexed sessions");
9633        assert_eq!(indexed.len(), 1);
9634        assert_eq!(indexed[0].path, path.display().to_string());
9635        assert_eq!(indexed[0].message_count, 2);
9636        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9637    }
9638
9639    #[test]
9640    fn test_load_session_meta_jsonl_errors_on_invalid_utf8_entry_line() {
9641        use std::io::Write;
9642
9643        let temp = tempfile::tempdir().unwrap();
9644        let session_path = temp.path().join("invalid-utf8.jsonl");
9645
9646        let mut header = SessionHeader::new();
9647        header.id = "invalid-utf8".to_string();
9648        header.cwd = temp.path().display().to_string();
9649        header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
9650
9651        std::fs::write(
9652            &session_path,
9653            format!(
9654                "{}\n",
9655                serde_json::to_string(&header).expect("serialize header")
9656            ),
9657        )
9658        .expect("write header");
9659
9660        let mut file = std::fs::OpenOptions::new()
9661            .append(true)
9662            .open(&session_path)
9663            .expect("open session");
9664        file.write_all(b"{\"type\":\"message\"}\n")
9665            .expect("write valid entry");
9666        file.write_all(b"\xFF\xFE\n").expect("write invalid utf8");
9667        file.flush().expect("flush session");
9668        drop(file);
9669
9670        let err = load_session_meta_jsonl(&session_path).expect_err("invalid utf8 should error");
9671        assert!(
9672            err.to_string().contains("Failed to read session entry"),
9673            "{err}"
9674        );
9675    }
9676
9677    #[cfg(feature = "sqlite-sessions")]
9678    #[test]
9679    fn test_scan_sessions_on_disk_reloads_sqlite_when_wal_stats_change() {
9680        let temp = tempfile::tempdir().unwrap();
9681        let mut session = Session::create_with_dir_and_store(
9682            Some(temp.path().to_path_buf()),
9683            SessionStoreKind::Sqlite,
9684        );
9685        session.append_message(make_test_message("sqlite"));
9686
9687        run_async(async { session.save().await }).unwrap();
9688        let path = session.path.clone().expect("sqlite session path");
9689        let session_dir = path.parent().expect("session parent").to_path_buf();
9690        let (base_ms, base_size) = session_file_stats(&path).expect("base stats");
9691
9692        let mut wal_path = path.as_os_str().to_os_string();
9693        wal_path.push("-wal");
9694        let wal_path = PathBuf::from(wal_path);
9695        std::thread::sleep(std::time::Duration::from_millis(1_100));
9696        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9697
9698        let stale_known_entry = SessionPickEntry {
9699            path: path.clone(),
9700            id: session.header.id.clone(),
9701            cwd: session.header.cwd.clone(),
9702            timestamp: session.header.timestamp.clone(),
9703            message_count: 999,
9704            name: Some("stale".to_string()),
9705            last_modified_ms: base_ms,
9706            size_bytes: base_size,
9707        };
9708
9709        let scanned =
9710            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9711                .expect("scan sessions");
9712        let (updated_ms, updated_size) = session_file_stats(&path).expect("updated stats");
9713
9714        assert!(scanned.failed_paths.is_empty());
9715        assert_eq!(scanned.entries.len(), 1);
9716        assert_eq!(scanned.refreshed_entries.len(), 1);
9717        assert_eq!(scanned.entries[0].path, path);
9718        assert_eq!(scanned.entries[0].message_count, 1);
9719        assert_eq!(scanned.entries[0].size_bytes, updated_size);
9720        assert_eq!(scanned.entries[0].last_modified_ms, updated_ms);
9721    }
9722
9723    #[cfg(feature = "sqlite-sessions")]
9724    #[test]
9725    fn test_load_session_meta_sqlite_uses_wal_aware_stats() {
9726        let temp = tempfile::tempdir().unwrap();
9727        let mut session = Session::create_with_dir_and_store(
9728            Some(temp.path().to_path_buf()),
9729            SessionStoreKind::Sqlite,
9730        );
9731        session.append_message(make_test_message("sqlite"));
9732
9733        run_async(async { session.save().await }).unwrap();
9734        let path = session.path.clone().expect("sqlite session path");
9735
9736        let mut wal_path = path.as_os_str().to_os_string();
9737        wal_path.push("-wal");
9738        let wal_path = PathBuf::from(wal_path);
9739        std::thread::sleep(std::time::Duration::from_millis(1_100));
9740        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9741
9742        let meta = load_session_meta_sqlite(&path).expect("load sqlite meta");
9743        let (expected_ms, expected_size) = session_file_stats(&path).expect("sqlite file stats");
9744
9745        assert_eq!(meta.path, path);
9746        assert_eq!(meta.size_bytes, expected_size);
9747        assert_eq!(meta.last_modified_ms, expected_ms);
9748    }
9749
9750    // ======================================================================
9751    // All entries corrupted (only header valid)
9752    // ======================================================================
9753
9754    #[test]
9755    fn test_all_entries_corrupted_produces_empty_session() {
9756        let temp = tempfile::tempdir().unwrap();
9757        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9758        session.append_message(make_test_message("A"));
9759        session.append_message(make_test_message("B"));
9760
9761        run_async(async { session.save().await }).unwrap();
9762        let path = session.path.clone().unwrap();
9763
9764        let mut lines: Vec<String> = std::fs::read_to_string(&path)
9765            .unwrap()
9766            .lines()
9767            .map(str::to_string)
9768            .collect();
9769        // Corrupt all entry lines (keep header at index 0)
9770        for (i, line) in lines.iter_mut().enumerate().skip(1) {
9771            *line = format!("GARBAGE_{i}");
9772        }
9773        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
9774
9775        let (loaded, diagnostics) = run_async(async {
9776            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
9777        })
9778        .unwrap();
9779
9780        assert_eq!(diagnostics.skipped_entries.len(), 2);
9781        assert!(loaded.entries.is_empty());
9782        assert!(loaded.leaf_id.is_none());
9783        // Header should still be valid
9784        assert_eq!(loaded.header.id, session.header.id);
9785    }
9786
9787    // ======================================================================
9788    // Unicode and special character content
9789    // ======================================================================
9790
9791    #[test]
9792    fn test_unicode_content_round_trip() {
9793        let temp = tempfile::tempdir().unwrap();
9794        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9795
9796        let unicode_texts = [
9797            "Hello \u{1F600} World",    // emoji
9798            "\u{4F60}\u{597D}",         // Chinese
9799            "\u{0410}\u{0411}\u{0412}", // Cyrillic
9800            "caf\u{00E9}",              // accented
9801            "tab\there\nnewline",       // control chars
9802            "\"quoted\" and \\escaped", // JSON special chars
9803        ];
9804
9805        for text in &unicode_texts {
9806            session.append_message(make_test_message(text));
9807        }
9808
9809        run_async(async { session.save().await }).unwrap();
9810        let path = session.path.clone().unwrap();
9811
9812        let loaded =
9813            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9814
9815        assert_eq!(loaded.entries.len(), unicode_texts.len());
9816
9817        for (i, entry) in loaded.entries.iter().enumerate() {
9818            if let SessionEntry::Message(msg) = entry {
9819                if let SessionMessage::User { content, .. } = &msg.message {
9820                    match content {
9821                        UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
9822                        UserContent::Blocks(_) => {
9823                            test_fail!("Expected UserContent::Text, got Blocks")
9824                        }
9825                    }
9826                }
9827            }
9828        }
9829    }
9830
9831    // ======================================================================
9832    // Multiple compactions
9833    // ======================================================================
9834
9835    #[test]
9836    fn test_multiple_compactions_latest_wins() {
9837        let mut session = Session::in_memory();
9838
9839        let _id_a = session.append_message(make_test_message("old A"));
9840        let _id_b = session.append_message(make_test_message("old B"));
9841        let id_c = session.append_message(make_test_message("kept C"));
9842
9843        // First compaction: keep from C
9844        session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
9845
9846        let _id_d = session.append_message(make_test_message("new D"));
9847        let id_e = session.append_message(make_test_message("new E"));
9848
9849        // Second compaction: keep from E
9850        session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
9851
9852        let id_f = session.append_message(make_test_message("newest F"));
9853
9854        session.navigate_to(&id_f);
9855        let messages = session.to_messages_for_current_path();
9856
9857        // Old messages A, B should definitely not appear
9858        let all_text: String = messages
9859            .iter()
9860            .filter_map(|m| match m {
9861                Message::User(u) => match &u.content {
9862                    UserContent::Text(t) => Some(t.clone()),
9863                    UserContent::Blocks(_) => None,
9864                },
9865                _ => None,
9866            })
9867            .collect::<Vec<_>>()
9868            .join(" ");
9869
9870        assert!(!all_text.contains("old A"), "A should be compacted away");
9871        assert!(!all_text.contains("old B"), "B should be compacted away");
9872    }
9873
9874    // ======================================================================
9875    // Session with only metadata entries (no messages)
9876    // ======================================================================
9877
9878    #[test]
9879    fn test_session_with_only_metadata_entries() {
9880        let mut session = Session::in_memory();
9881
9882        session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
9883        session.append_thinking_level_change("high".to_string());
9884        session.set_name("metadata-only");
9885
9886        // to_messages should return empty (no actual messages)
9887        let messages = session.to_messages();
9888        assert!(messages.is_empty());
9889
9890        // entries_for_current_path should still return the metadata entries
9891        let entries = session.entries_for_current_path();
9892        assert_eq!(entries.len(), 3);
9893    }
9894
9895    #[test]
9896    fn test_metadata_only_session_round_trip() {
9897        let temp = tempfile::tempdir().unwrap();
9898        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9899
9900        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
9901        session.append_thinking_level_change("medium".to_string());
9902
9903        run_async(async { session.save().await }).unwrap();
9904        let path = session.path.clone().unwrap();
9905
9906        let loaded =
9907            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9908
9909        assert_eq!(loaded.entries.len(), 2);
9910        assert!(
9911            loaded
9912                .entries
9913                .iter()
9914                .any(|e| matches!(e, SessionEntry::ModelChange(_)))
9915        );
9916        assert!(
9917            loaded
9918                .entries
9919                .iter()
9920                .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
9921        );
9922    }
9923
9924    // ======================================================================
9925    // Session name round-trip persistence
9926    // ======================================================================
9927
9928    #[test]
9929    fn test_session_name_survives_round_trip() {
9930        let temp = tempfile::tempdir().unwrap();
9931        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9932
9933        session.append_message(make_test_message("Hello"));
9934        session.set_name("my-important-session");
9935
9936        run_async(async { session.save().await }).unwrap();
9937        let path = session.path.clone().unwrap();
9938
9939        let loaded =
9940            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9941
9942        assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
9943    }
9944
9945    // ======================================================================
9946    // Trailing newline / whitespace in JSONL
9947    // ======================================================================
9948
9949    #[test]
9950    fn test_trailing_whitespace_in_jsonl_ignored() {
9951        let temp = tempfile::tempdir().unwrap();
9952        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9953        session.append_message(make_test_message("test"));
9954
9955        run_async(async { session.save().await }).unwrap();
9956        let path = session.path.clone().unwrap();
9957
9958        // Append extra blank lines at the end
9959        let mut contents = std::fs::read_to_string(&path).unwrap();
9960        contents.push_str("\n\n\n");
9961        std::fs::write(&path, contents).unwrap();
9962
9963        let loaded =
9964            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9965
9966        assert_eq!(loaded.entries.len(), 1);
9967    }
9968
9969    // ======================================================================
9970    // Branching after compaction
9971    // ======================================================================
9972
9973    #[test]
9974    fn test_branching_after_compaction() {
9975        let mut session = Session::in_memory();
9976
9977        let _id_a = session.append_message(make_test_message("old A"));
9978        let id_b = session.append_message(make_test_message("kept B"));
9979
9980        session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
9981
9982        let id_c = session.append_message(make_test_message("C after compaction"));
9983
9984        // Branch from B (the compaction keep-point)
9985        session.create_branch_from(&id_b);
9986        let id_d = session.append_message(make_test_message("D branch after compaction"));
9987
9988        let leaves = session.list_leaves();
9989        assert_eq!(leaves.len(), 2);
9990        assert!(leaves.contains(&id_c));
9991        assert!(leaves.contains(&id_d));
9992    }
9993
9994    // ======================================================================
9995    // Assistant message with tool calls round-trip
9996    // ======================================================================
9997
9998    #[test]
9999    fn test_assistant_with_tool_calls_round_trip() {
10000        let temp = tempfile::tempdir().unwrap();
10001        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
10002
10003        session.append_message(make_test_message("read my file"));
10004
10005        let assistant = AssistantMessage {
10006            content: vec![
10007                ContentBlock::Text(TextContent::new("Let me read that for you.")),
10008                ContentBlock::ToolCall(crate::model::ToolCall {
10009                    id: "call_abc".to_string(),
10010                    name: "read".to_string(),
10011                    arguments: serde_json::json!({"path": "src/main.rs"}),
10012                    thought_signature: None,
10013                }),
10014            ],
10015            api: "anthropic".to_string(),
10016            provider: "anthropic".to_string(),
10017            model: "claude-test".to_string(),
10018            usage: Usage {
10019                input: 100,
10020                output: 50,
10021                cache_read: 0,
10022                cache_write: 0,
10023                total_tokens: 150,
10024                cost: Cost::default(),
10025            },
10026            stop_reason: StopReason::ToolUse,
10027            error_message: None,
10028            timestamp: 12345,
10029        };
10030        session.append_message(SessionMessage::Assistant { message: assistant });
10031
10032        session.append_message(SessionMessage::ToolResult {
10033            tool_call_id: "call_abc".to_string(),
10034            tool_name: "read".to_string(),
10035            content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
10036            details: Some(serde_json::json!({"lines": 1, "truncated": false})),
10037            is_error: false,
10038            timestamp: Some(12346),
10039        });
10040
10041        run_async(async { session.save().await }).unwrap();
10042        let path = session.path.clone().unwrap();
10043
10044        let loaded =
10045            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10046
10047        assert_eq!(loaded.entries.len(), 3);
10048
10049        // Verify tool call content survived
10050        let has_tool_call = loaded.entries.iter().any(|e| {
10051            if let SessionEntry::Message(msg) = e {
10052                if let SessionMessage::Assistant { message } = &msg.message {
10053                    return message
10054                        .content
10055                        .iter()
10056                        .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id.eq("call_abc")));
10057                }
10058            }
10059            false
10060        });
10061        assert!(has_tool_call, "tool call should survive round-trip");
10062
10063        // Verify tool result details survived
10064        let has_details = loaded.entries.iter().any(|e| {
10065            if let SessionEntry::Message(msg) = e {
10066                if let SessionMessage::ToolResult { details, .. } = &msg.message {
10067                    return details.is_some();
10068                }
10069            }
10070            false
10071        });
10072        assert!(has_details, "tool result details should survive round-trip");
10073    }
10074
10075    // ======================================================================
10076    // FUZZ-P1.4: Proptest coverage for Session JSONL parsing
10077    // ======================================================================
10078
10079    mod proptest_session {
10080        use super::*;
10081        use proptest::prelude::*;
10082        use serde_json::json;
10083
10084        /// Generate a random valid timestamp string.
10085        fn timestamp_strategy() -> impl Strategy<Value = String> {
10086            (
10087                2020u32..2030,
10088                1u32..13,
10089                1u32..29,
10090                0u32..24,
10091                0u32..60,
10092                0u32..60,
10093            )
10094                .prop_map(|(y, mo, d, h, mi, s)| {
10095                    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
10096                })
10097        }
10098
10099        /// Generate a random entry ID (8 hex chars).
10100        fn entry_id_strategy() -> impl Strategy<Value = String> {
10101            "[0-9a-f]{8}"
10102        }
10103
10104        /// Generate an arbitrary JSON value of bounded depth/size.
10105        fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
10106            if max_depth == 0 {
10107                prop_oneof![
10108                    Just(json!(null)),
10109                    any::<bool>().prop_map(|b| json!(b)),
10110                    any::<i64>().prop_map(|n| json!(n)),
10111                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10112                ]
10113                .boxed()
10114            } else {
10115                prop_oneof![
10116                    Just(json!(null)),
10117                    any::<bool>().prop_map(|b| json!(b)),
10118                    any::<i64>().prop_map(|n| json!(n)),
10119                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10120                    prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
10121                        .prop_map(serde_json::Value::Array),
10122                ]
10123                .boxed()
10124            }
10125        }
10126
10127        /// Generate a valid `SessionEntry` JSON object for one of the known types.
10128        #[allow(clippy::too_many_lines)]
10129        fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
10130            let ts = timestamp_strategy();
10131            let eid = entry_id_strategy();
10132            let parent = prop::option::of(entry_id_strategy());
10133
10134            (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
10135                let base = json!({
10136                    "id": eid,
10137                    "parentId": parent,
10138                    "timestamp": ts,
10139                });
10140
10141                match variant {
10142                    0 => {
10143                        // Message - User
10144                        "[a-zA-Z0-9 ]{1,64}"
10145                            .prop_map(move |text| {
10146                                let mut v = base.clone();
10147                                v["type"] = json!("message");
10148                                v["message"] = json!({
10149                                    "role": "user",
10150                                    "content": text,
10151                                });
10152                                v
10153                            })
10154                            .boxed()
10155                    }
10156                    1 => {
10157                        // Message - Assistant
10158                        "[a-zA-Z0-9 ]{1,64}"
10159                            .prop_map(move |text| {
10160                                let mut v = base.clone();
10161                                v["type"] = json!("message");
10162                                v["message"] = json!({
10163                                    "role": "assistant",
10164                                    "content": [{"type": "text", "text": text}],
10165                                    "api": "anthropic",
10166                                    "provider": "anthropic",
10167                                    "model": "test-model",
10168                                    "usage": {
10169                                        "input": 10,
10170                                        "output": 5,
10171                                        "cacheRead": 0,
10172                                        "cacheWrite": 0,
10173                                        "totalTokens": 15,
10174                                        "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
10175                                    },
10176                                    "stopReason": "end_turn",
10177                                    "timestamp": 12345,
10178                                });
10179                                v
10180                            })
10181                            .boxed()
10182                    }
10183                    2 => {
10184                        // ModelChange
10185                        ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
10186                            .prop_map(move |(provider, model)| {
10187                                let mut v = base.clone();
10188                                v["type"] = json!("model_change");
10189                                v["provider"] = json!(provider);
10190                                v["modelId"] = json!(model);
10191                                v
10192                            })
10193                            .boxed()
10194                    }
10195                    3 => {
10196                        // ThinkingLevelChange
10197                        prop_oneof![
10198                            Just("off".to_string()),
10199                            Just("low".to_string()),
10200                            Just("medium".to_string()),
10201                            Just("high".to_string()),
10202                        ]
10203                        .prop_map(move |level| {
10204                            let mut v = base.clone();
10205                            v["type"] = json!("thinking_level_change");
10206                            v["thinkingLevel"] = json!(level);
10207                            v
10208                        })
10209                        .boxed()
10210                    }
10211                    4 => {
10212                        // Compaction
10213                        ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
10214                            .prop_map(move |(summary, kept_id, tokens)| {
10215                                let mut v = base.clone();
10216                                v["type"] = json!("compaction");
10217                                v["summary"] = json!(summary);
10218                                v["firstKeptEntryId"] = json!(kept_id);
10219                                v["tokensBefore"] = json!(tokens);
10220                                v
10221                            })
10222                            .boxed()
10223                    }
10224                    5 => {
10225                        // Label
10226                        (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
10227                            .prop_map(move |(target, label)| {
10228                                let mut v = base.clone();
10229                                v["type"] = json!("label");
10230                                v["targetId"] = json!(target);
10231                                if let Some(l) = label {
10232                                    v["label"] = json!(l);
10233                                }
10234                                v
10235                            })
10236                            .boxed()
10237                    }
10238                    6 => {
10239                        // SessionInfo
10240                        prop::option::of("[a-zA-Z0-9 ]{1,32}")
10241                            .prop_map(move |name| {
10242                                let mut v = base.clone();
10243                                v["type"] = json!("session_info");
10244                                if let Some(n) = name {
10245                                    v["name"] = json!(n);
10246                                }
10247                                v
10248                            })
10249                            .boxed()
10250                    }
10251                    _ => {
10252                        // Custom
10253                        ("[a-z_]{3,12}", bounded_json_value(2))
10254                            .prop_map(move |(custom_type, data)| {
10255                                let mut v = base.clone();
10256                                v["type"] = json!("custom");
10257                                v["customType"] = json!(custom_type);
10258                                v["data"] = data;
10259                                v
10260                            })
10261                            .boxed()
10262                    }
10263                }
10264            })
10265        }
10266
10267        /// Generate a corrupted JSON line (valid JSON but wrong shape for `SessionEntry`).
10268        fn corrupted_entry_json() -> impl Strategy<Value = String> {
10269            prop_oneof![
10270                // Missing "type" field
10271                Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10272                // Unknown type
10273                Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10274                // Empty object
10275                Just(r"{}".to_string()),
10276                // Array instead of object
10277                Just(r"[1,2,3]".to_string()),
10278                // Scalar values
10279                Just(r"42".to_string()),
10280                Just(r#""just a string""#.to_string()),
10281                Just(r"null".to_string()),
10282                Just(r"true".to_string()),
10283                // Truncated JSON (simulating crash)
10284                Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
10285                // Valid JSON with wrong field types
10286                Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10287            ]
10288        }
10289
10290        /// Build a complete JSONL file string from header + entries.
10291        fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
10292            let mut lines = vec![header.to_string()];
10293            lines.extend(entry_lines.iter().cloned());
10294            lines.join("\n")
10295        }
10296
10297        // ------------------------------------------------------------------
10298        // Proptest 1: SessionEntry deserialization never panics
10299        // ------------------------------------------------------------------
10300        proptest! {
10301            #![proptest_config(ProptestConfig {
10302                cases: 256,
10303                max_shrink_iters: 200,
10304                .. ProptestConfig::default()
10305            })]
10306
10307            #[test]
10308            fn session_entry_deser_never_panics(
10309                entry_json in valid_session_entry_json()
10310            ) {
10311                let json_str = entry_json.to_string();
10312                // Must not panic — Ok or Err is fine
10313                let _ = serde_json::from_str::<SessionEntry>(&json_str);
10314            }
10315        }
10316
10317        // ------------------------------------------------------------------
10318        // Proptest 2: Corrupted/malformed input never panics
10319        // ------------------------------------------------------------------
10320        proptest! {
10321            #![proptest_config(ProptestConfig {
10322                cases: 256,
10323                max_shrink_iters: 200,
10324                .. ProptestConfig::default()
10325            })]
10326
10327            #[test]
10328            fn corrupted_entry_deser_never_panics(
10329                line in corrupted_entry_json()
10330            ) {
10331                let _ = serde_json::from_str::<SessionEntry>(&line);
10332            }
10333
10334            #[test]
10335            fn arbitrary_bytes_deser_never_panics(
10336                raw in prop::collection::vec(any::<u8>(), 0..512)
10337            ) {
10338                // Even random bytes must not panic serde
10339                if let Ok(s) = String::from_utf8(raw) {
10340                    let _ = serde_json::from_str::<SessionEntry>(&s);
10341                }
10342            }
10343        }
10344
10345        // ------------------------------------------------------------------
10346        // Proptest 3: Valid entries round-trip through serialization
10347        // ------------------------------------------------------------------
10348        proptest! {
10349            #![proptest_config(ProptestConfig {
10350                cases: 256,
10351                max_shrink_iters: 200,
10352                .. ProptestConfig::default()
10353            })]
10354
10355            #[test]
10356            fn valid_entry_round_trip(
10357                entry_json in valid_session_entry_json()
10358            ) {
10359                let json_str = entry_json.to_string();
10360                if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
10361                    // Serialize back
10362                    let reserialized = serde_json::to_string(&entry).unwrap();
10363                    // Deserialize again
10364                    let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
10365                    // Both should have the same entry ID
10366                    assert_eq!(entry.base_id(), re_entry.base_id());
10367                    // Both should have the same type tag
10368                    assert_eq!(
10369                        std::mem::discriminant(&entry),
10370                        std::mem::discriminant(&re_entry)
10371                    );
10372                }
10373            }
10374        }
10375
10376        // ------------------------------------------------------------------
10377        // Proptest 4: Full JSONL load with mixed valid/invalid lines
10378        //             recovers valid entries and reports diagnostics
10379        // ------------------------------------------------------------------
10380        proptest! {
10381            #![proptest_config(ProptestConfig {
10382                cases: 128,
10383                max_shrink_iters: 100,
10384                .. ProptestConfig::default()
10385            })]
10386
10387            #[test]
10388            fn jsonl_corrupted_recovery(
10389                valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
10390                corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
10391                interleave_seed in any::<u64>(),
10392            ) {
10393                let header_json = json!({
10394                    "type": "session",
10395                    "version": 3,
10396                    "id": "testid01",
10397                    "timestamp": "2024-01-01T00:00:00.000Z",
10398                    "cwd": "/tmp/test"
10399                }).to_string();
10400
10401                // Interleave valid and corrupted lines deterministically
10402                let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
10403                let total = valid_strs.len() + corrupted_lines.len();
10404                let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
10405                for s in &valid_strs {
10406                    all_lines.push((true, s.clone()));
10407                }
10408                for s in &corrupted_lines {
10409                    all_lines.push((false, s.clone()));
10410                }
10411
10412                // Deterministic shuffle based on seed
10413                let mut seed = interleave_seed;
10414                for i in (1..all_lines.len()).rev() {
10415                    seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
10416                    let j = (seed >> 33) as usize % (i + 1);
10417                    all_lines.swap(i, j);
10418                }
10419
10420                let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
10421                let content = build_jsonl(&header_json, &entry_lines);
10422
10423                // Write to temp file and load
10424                let temp_dir = tempfile::tempdir().unwrap();
10425                let file_path = temp_dir.path().join("test_session.jsonl");
10426                std::fs::write(&file_path, &content).unwrap();
10427
10428                let (session, diagnostics) = run_async(async {
10429                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10430                }).unwrap();
10431
10432                // Invariant: parsed + skipped == total lines (all non-empty)
10433                let total_parsed = session.entries.len();
10434                assert_eq!(
10435                    total_parsed + diagnostics.skipped_entries.len(),
10436                    total,
10437                    "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
10438                    diagnostics.skipped_entries.len()
10439                );
10440            }
10441        }
10442
10443        // ------------------------------------------------------------------
10444        // Proptest 5: Orphaned parent links are detected
10445        // ------------------------------------------------------------------
10446        proptest! {
10447            #![proptest_config(ProptestConfig {
10448                cases: 128,
10449                max_shrink_iters: 100,
10450                .. ProptestConfig::default()
10451            })]
10452
10453            #[test]
10454            fn orphaned_parent_links_detected(
10455                n_entries in 2usize..10,
10456                orphan_idx in 0usize..8,
10457            ) {
10458                let orphan_idx = orphan_idx % n_entries;
10459                let header_json = json!({
10460                    "type": "session",
10461                    "version": 3,
10462                    "id": "testid01",
10463                    "timestamp": "2024-01-01T00:00:00.000Z",
10464                    "cwd": "/tmp/test"
10465                }).to_string();
10466
10467                let mut entry_lines = Vec::new();
10468                let mut prev_id: Option<String> = None;
10469
10470                for i in 0..n_entries {
10471                    let eid = format!("{i:08x}");
10472                    let parent = if i.eq(&orphan_idx) {
10473                        // Point to a nonexistent parent
10474                        Some("deadbeef".to_string())
10475                    } else {
10476                        prev_id.clone()
10477                    };
10478
10479                    let entry = json!({
10480                        "type": "message",
10481                        "id": eid,
10482                        "parentId": parent,
10483                        "timestamp": "2024-01-01T00:00:00.000Z",
10484                        "message": {
10485                            "role": "user",
10486                            "content": format!("msg {i}"),
10487                        }
10488                    });
10489                    entry_lines.push(entry.to_string());
10490                    prev_id = Some(eid);
10491                }
10492
10493                let content = build_jsonl(&header_json, &entry_lines);
10494                let temp_dir = tempfile::tempdir().unwrap();
10495                let file_path = temp_dir.path().join("orphan_test.jsonl");
10496                std::fs::write(&file_path, &content).unwrap();
10497
10498                let (_session, diagnostics) = run_async(async {
10499                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10500                }).unwrap();
10501
10502                // The orphaned entry should be detected
10503                let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
10504                    o.missing_parent_id == "deadbeef"
10505                });
10506                assert!(
10507                    has_orphan,
10508                    "orphaned parent link to 'deadbeef' should be detected"
10509                );
10510            }
10511        }
10512
10513        // ------------------------------------------------------------------
10514        // Proptest 6: ensure_entry_ids assigns IDs to entries without them
10515        // ------------------------------------------------------------------
10516        proptest! {
10517            #![proptest_config(ProptestConfig {
10518                cases: 128,
10519                max_shrink_iters: 100,
10520                .. ProptestConfig::default()
10521            })]
10522
10523            #[test]
10524            fn ensure_entry_ids_fills_gaps(
10525                n_total in 1usize..20,
10526                missing_mask in prop::collection::vec(any::<bool>(), 1..20),
10527            ) {
10528                let n = n_total.min(missing_mask.len());
10529                let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
10530                    let id = if missing_mask[i] {
10531                        None
10532                    } else {
10533                        Some(format!("{i:08x}"))
10534                    };
10535                    SessionEntry::Message(MessageEntry {
10536                        base: EntryBase {
10537                            id,
10538                            parent_id: None,
10539                            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
10540                        },
10541                        message: SessionMessage::User {
10542                            content: UserContent::Text(format!("msg {i}")),
10543                            timestamp: Some(0),
10544                        },
10545                    })
10546                }).collect();
10547
10548                ensure_entry_ids(&mut entries);
10549
10550                // All entries must have IDs after the call
10551                for entry in &entries {
10552                    assert!(
10553                        entry.base_id().is_some(),
10554                        "all entries must have IDs after ensure_entry_ids"
10555                    );
10556                }
10557
10558                // All IDs must be unique
10559                let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
10560                let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
10561                assert_eq!(
10562                    ids.len(),
10563                    unique.len(),
10564                    "all entry IDs must be unique"
10565                );
10566            }
10567        }
10568
10569        // ------------------------------------------------------------------
10570        // Proptest 7: SessionHeader deserialization with boundary values
10571        // ------------------------------------------------------------------
10572        proptest! {
10573            #![proptest_config(ProptestConfig {
10574                cases: 256,
10575                max_shrink_iters: 200,
10576                .. ProptestConfig::default()
10577            })]
10578
10579            #[test]
10580            fn session_header_deser_never_panics(
10581                version in prop::option::of(0u8..255),
10582                id in "[a-zA-Z0-9-]{0,64}",
10583                ts in timestamp_strategy(),
10584                cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
10585                provider in prop::option::of("[a-z]{2,10}"),
10586                model_id in prop::option::of("[a-z0-9-]{2,20}"),
10587                thinking_level in prop::option::of("[a-z]{2,8}"),
10588            ) {
10589                let mut obj = json!({
10590                    "type": "session",
10591                    "id": id,
10592                    "timestamp": ts,
10593                    "cwd": cwd,
10594                });
10595                if let Some(v) = version {
10596                    obj["version"] = json!(v);
10597                }
10598                if let Some(p) = &provider {
10599                    obj["provider"] = json!(p);
10600                }
10601                if let Some(m) = &model_id {
10602                    obj["modelId"] = json!(m);
10603                }
10604                if let Some(t) = &thinking_level {
10605                    obj["thinkingLevel"] = json!(t);
10606                }
10607                let json_str = obj.to_string();
10608                let _ = serde_json::from_str::<SessionHeader>(&json_str);
10609            }
10610        }
10611
10612        // ------------------------------------------------------------------
10613        // Proptest 8: Edge-case JSONL files
10614        // ------------------------------------------------------------------
10615
10616        #[test]
10617        fn empty_file_returns_error() {
10618            let temp_dir = tempfile::tempdir().unwrap();
10619            let file_path = temp_dir.path().join("empty.jsonl");
10620            std::fs::write(&file_path, "").unwrap();
10621
10622            let result = run_async(async {
10623                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10624            });
10625            assert!(result.is_err(), "empty file should return error");
10626        }
10627
10628        #[test]
10629        fn header_only_file_produces_empty_session() {
10630            let header = json!({
10631                "type": "session",
10632                "version": 3,
10633                "id": "testid01",
10634                "timestamp": "2024-01-01T00:00:00.000Z",
10635                "cwd": "/tmp/test"
10636            })
10637            .to_string();
10638
10639            let temp_dir = tempfile::tempdir().unwrap();
10640            let file_path = temp_dir.path().join("header_only.jsonl");
10641            std::fs::write(&file_path, &header).unwrap();
10642
10643            let (session, diagnostics) = run_async(async {
10644                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10645            })
10646            .unwrap();
10647
10648            assert!(
10649                session.entries.is_empty(),
10650                "header-only file should have no entries"
10651            );
10652            assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
10653        }
10654
10655        #[test]
10656        fn file_with_only_invalid_lines_has_diagnostics() {
10657            let header = json!({
10658                "type": "session",
10659                "version": 3,
10660                "id": "testid01",
10661                "timestamp": "2024-01-01T00:00:00.000Z",
10662                "cwd": "/tmp/test"
10663            })
10664            .to_string();
10665
10666            let content = format!(
10667                "{}\n{}\n{}\n{}",
10668                header,
10669                r#"{"bad":"json","no":"type"}"#,
10670                r"not json at all",
10671                r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
10672            );
10673
10674            let temp_dir = tempfile::tempdir().unwrap();
10675            let file_path = temp_dir.path().join("all_invalid.jsonl");
10676            std::fs::write(&file_path, &content).unwrap();
10677
10678            let (session, diagnostics) = run_async(async {
10679                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10680            })
10681            .unwrap();
10682
10683            assert!(
10684                session.entries.is_empty(),
10685                "all-invalid file should have no entries"
10686            );
10687            assert_eq!(
10688                diagnostics.skipped_entries.len(),
10689                3,
10690                "should have 3 skipped entries"
10691            );
10692        }
10693
10694        #[test]
10695        fn duplicate_entry_ids_are_loaded_without_panic() {
10696            let header = json!({
10697                "type": "session",
10698                "version": 3,
10699                "id": "testid01",
10700                "timestamp": "2024-01-01T00:00:00.000Z",
10701                "cwd": "/tmp/test"
10702            })
10703            .to_string();
10704
10705            let entry1 = json!({
10706                "type": "message",
10707                "id": "deadbeef",
10708                "timestamp": "2024-01-01T00:00:00.000Z",
10709                "message": {"role": "user", "content": "first"}
10710            })
10711            .to_string();
10712
10713            let entry2 = json!({
10714                "type": "message",
10715                "id": "deadbeef",
10716                "timestamp": "2024-01-01T00:00:01.000Z",
10717                "message": {"role": "user", "content": "second (duplicate id)"}
10718            })
10719            .to_string();
10720
10721            let content = format!("{header}\n{entry1}\n{entry2}");
10722
10723            let temp_dir = tempfile::tempdir().unwrap();
10724            let file_path = temp_dir.path().join("dup_ids.jsonl");
10725            std::fs::write(&file_path, &content).unwrap();
10726
10727            // Must not panic
10728            let (session, _diagnostics) = run_async(async {
10729                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10730            })
10731            .unwrap();
10732
10733            assert_eq!(session.entries.len(), 2, "both entries should be loaded");
10734        }
10735    }
10736
10737    // ------------------------------------------------------------------
10738    // Incremental append tests
10739    // ------------------------------------------------------------------
10740
10741    #[test]
10742    fn test_incremental_append_writes_only_new_entries() {
10743        let temp_dir = tempfile::tempdir().expect("temp dir");
10744        let mut session = Session::create();
10745        session.session_dir = Some(temp_dir.path().to_path_buf());
10746
10747        // First save: full rewrite (persisted_entry_count == 0).
10748        session.append_message(make_test_message("msg A"));
10749        session.append_message(make_test_message("msg B"));
10750        run_async(async { session.save().await }).unwrap();
10751
10752        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10753        assert_eq!(session.appends_since_checkpoint, 0);
10754
10755        let path = session.path.clone().unwrap();
10756        let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
10757        // 1 header + 2 entries = 3 lines
10758        assert_eq!(lines_after_first, 3);
10759
10760        // Add more entries and save again (incremental append).
10761        session.append_message(make_test_message("msg C"));
10762        run_async(async { session.save().await }).unwrap();
10763
10764        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10765        assert_eq!(session.appends_since_checkpoint, 1);
10766
10767        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10768        // 1 header + 3 entries = 4 lines
10769        assert_eq!(lines_after_second, 4);
10770    }
10771
10772    #[test]
10773    fn test_header_change_forces_full_rewrite() {
10774        let temp_dir = tempfile::tempdir().expect("temp dir");
10775        let mut session = Session::create();
10776        session.session_dir = Some(temp_dir.path().to_path_buf());
10777
10778        session.append_message(make_test_message("msg A"));
10779        run_async(async { session.save().await }).unwrap();
10780        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10781        assert!(!session.header_dirty);
10782
10783        // Modify header.
10784        session.set_model_header(Some("new-provider".to_string()), None, None);
10785        assert!(session.header_dirty);
10786
10787        session.append_message(make_test_message("msg B"));
10788        run_async(async { session.save().await }).unwrap();
10789
10790        // Full rewrite resets all counters.
10791        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10792        assert!(!session.header_dirty);
10793        assert_eq!(session.appends_since_checkpoint, 0);
10794
10795        // Verify header on disk has the new provider.
10796        let path = session.path.clone().unwrap();
10797        let first_line = std::fs::read_to_string(&path)
10798            .unwrap()
10799            .lines()
10800            .next()
10801            .unwrap()
10802            .to_string();
10803        let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
10804        assert_eq!(header["provider"], "new-provider");
10805    }
10806
10807    #[test]
10808    fn test_compaction_entry_uses_incremental_append() {
10809        let temp_dir = tempfile::tempdir().expect("temp dir");
10810        let mut session = Session::create();
10811        session.session_dir = Some(temp_dir.path().to_path_buf());
10812
10813        let id_a = session.append_message(make_test_message("msg A"));
10814        run_async(async { session.save().await }).unwrap();
10815        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10816
10817        // Append a compaction entry. This should still be eligible for
10818        // incremental append; checkpoint rewrite cadence handles periodic
10819        // full rewrites for cleanup/corruption recovery.
10820        session.append_compaction("summary".to_string(), id_a, 100, None, None);
10821        session.append_message(make_test_message("msg B"));
10822
10823        run_async(async { session.save().await }).unwrap();
10824
10825        // Incremental append: persisted count advances and checkpoint counter increments.
10826        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10827        assert_eq!(session.appends_since_checkpoint, 1);
10828
10829        let path = session.path.clone().unwrap();
10830        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10831        // 1 header + 3 entries = 4 lines
10832        assert_eq!(lines_after_second, 4);
10833    }
10834
10835    #[test]
10836    fn test_checkpoint_interval_forces_full_rewrite() {
10837        let temp_dir = tempfile::tempdir().expect("temp dir");
10838        let mut session = Session::create();
10839        session.session_dir = Some(temp_dir.path().to_path_buf());
10840
10841        // First save (full rewrite).
10842        session.append_message(make_test_message("initial"));
10843        run_async(async { session.save().await }).unwrap();
10844
10845        // Simulate many incremental appends by setting the counter near threshold.
10846        let interval = compaction_checkpoint_interval();
10847        session.appends_since_checkpoint = interval;
10848
10849        // Next save should trigger full rewrite due to checkpoint.
10850        session.append_message(make_test_message("triggers checkpoint"));
10851        run_async(async { session.save().await }).unwrap();
10852
10853        // Full rewrite resets counters.
10854        assert_eq!(session.appends_since_checkpoint, 0);
10855        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10856    }
10857
10858    #[test]
10859    fn test_incremental_append_load_round_trip() {
10860        let temp_dir = tempfile::tempdir().expect("temp dir");
10861        let mut session = Session::create();
10862        session.session_dir = Some(temp_dir.path().to_path_buf());
10863
10864        // First save.
10865        session.append_message(make_test_message("msg A"));
10866        session.append_message(make_test_message("msg B"));
10867        run_async(async { session.save().await }).unwrap();
10868
10869        // Incremental append.
10870        session.append_message(make_test_message("msg C"));
10871        run_async(async { session.save().await }).unwrap();
10872
10873        let path = session.path.clone().unwrap();
10874
10875        // Reload and verify all entries present.
10876        let loaded =
10877            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10878
10879        assert_eq!(loaded.entries.len(), 3);
10880        // Verify the entry content by checking that we have messages A, B, C.
10881        let texts: Vec<&str> = loaded
10882            .entries
10883            .iter()
10884            .filter_map(|e| match e {
10885                SessionEntry::Message(m) => match &m.message {
10886                    SessionMessage::User {
10887                        content: UserContent::Text(t),
10888                        ..
10889                    } => Some(t.as_str()),
10890                    _ => None,
10891                },
10892                _ => None,
10893            })
10894            .collect();
10895        assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
10896    }
10897
10898    #[test]
10899    fn test_persisted_entry_count_set_on_open() {
10900        let temp_dir = tempfile::tempdir().expect("temp dir");
10901        let mut session = Session::create();
10902        session.session_dir = Some(temp_dir.path().to_path_buf());
10903
10904        session.append_message(make_test_message("msg A"));
10905        session.append_message(make_test_message("msg B"));
10906        session.append_message(make_test_message("msg C"));
10907        run_async(async { session.save().await }).unwrap();
10908
10909        let path = session.path.clone().unwrap();
10910        let loaded =
10911            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10912
10913        assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
10914        assert!(!loaded.header_dirty);
10915        assert_eq!(loaded.appends_since_checkpoint, 0);
10916    }
10917
10918    #[test]
10919    fn test_no_new_entries_is_noop() {
10920        let temp_dir = tempfile::tempdir().expect("temp dir");
10921        let mut session = Session::create();
10922        session.session_dir = Some(temp_dir.path().to_path_buf());
10923
10924        session.append_message(make_test_message("msg A"));
10925        run_async(async { session.save().await }).unwrap();
10926
10927        let path = session.path.clone().unwrap();
10928        let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
10929
10930        // Sleep briefly to ensure mtime would change if file was written.
10931        std::thread::sleep(std::time::Duration::from_millis(50));
10932
10933        // Save again with no changes.
10934        run_async(async { session.save().await }).unwrap();
10935
10936        let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
10937        assert_eq!(
10938            mtime_before, mtime_after,
10939            "file should not be modified on no-op save"
10940        );
10941        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10942    }
10943
10944    #[test]
10945    fn test_incremental_append_caches_stay_valid() {
10946        let temp_dir = tempfile::tempdir().expect("temp dir");
10947        let mut session = Session::create();
10948        session.session_dir = Some(temp_dir.path().to_path_buf());
10949
10950        session.append_message(make_test_message("msg A"));
10951        run_async(async { session.save().await }).unwrap();
10952
10953        // After full rewrite, caches rebuilt.
10954        assert_eq!(session.entry_index.len(), 1);
10955
10956        // Incremental append: add more entries.
10957        let id_b = session.append_message(make_test_message("msg B"));
10958        let id_c = session.append_message(make_test_message("msg C"));
10959        run_async(async { session.save().await }).unwrap();
10960
10961        // Caches should still be valid (not rebuilt, but maintained incrementally).
10962        assert_eq!(session.entry_index.len(), 3);
10963        assert!(session.entry_index.contains_key(&id_b));
10964        assert!(session.entry_index.contains_key(&id_c));
10965        assert_eq!(session.cached_message_count, 3);
10966    }
10967
10968    #[test]
10969    fn test_set_branched_from_marks_header_dirty() {
10970        let mut session = Session::create();
10971        assert!(!session.header_dirty);
10972
10973        session.set_branched_from(Some("/some/path".to_string()));
10974        assert!(session.header_dirty);
10975    }
10976
10977    // ====================================================================
10978    // Crash-consistency and recovery tests (bd-3ar8v.2.7)
10979    // ====================================================================
10980
10981    /// Helper: build a valid JSONL session file string with header + N entries.
10982    fn build_crash_test_session_file(num_entries: usize) -> String {
10983        let header = serde_json::json!({
10984            "type": "session",
10985            "version": 3,
10986            "id": "crash-test",
10987            "timestamp": "2024-06-01T00:00:00.000Z",
10988            "cwd": "/tmp/test"
10989        });
10990        let mut lines = vec![serde_json::to_string(&header).unwrap()];
10991        for i in 0..num_entries {
10992            let entry = serde_json::json!({
10993                "type": "message",
10994                "id": format!("entry-{i}"),
10995                "timestamp": "2024-06-01T00:00:00.000Z",
10996                "message": {"role": "user", "content": format!("message {i}")}
10997            });
10998            lines.push(serde_json::to_string(&entry).unwrap());
10999        }
11000        lines.join("\n")
11001    }
11002
11003    #[test]
11004    fn crash_empty_file_returns_error() {
11005        let temp_dir = tempfile::tempdir().unwrap();
11006        let file_path = temp_dir.path().join("empty.jsonl");
11007        std::fs::write(&file_path, "").unwrap();
11008
11009        let result = run_async(async {
11010            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11011        });
11012        assert!(result.is_err(), "empty file should fail to open");
11013    }
11014
11015    #[test]
11016    fn crash_corrupted_header_returns_error() {
11017        let temp_dir = tempfile::tempdir().unwrap();
11018        let file_path = temp_dir.path().join("bad_header.jsonl");
11019        std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
11020
11021        let result = run_async(async {
11022            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11023        });
11024        assert!(result.is_err(), "corrupted header should fail");
11025    }
11026
11027    #[test]
11028    fn crash_header_only_loads_empty() {
11029        let temp_dir = tempfile::tempdir().unwrap();
11030        let file_path = temp_dir.path().join("header_only.jsonl");
11031        let header = serde_json::json!({
11032            "type": "session",
11033            "version": 3,
11034            "id": "hdr-only",
11035            "timestamp": "2024-06-01T00:00:00.000Z",
11036            "cwd": "/tmp/test"
11037        });
11038        std::fs::write(
11039            &file_path,
11040            format!("{}\n", serde_json::to_string(&header).unwrap()),
11041        )
11042        .unwrap();
11043
11044        let (session, diagnostics) = run_async(async {
11045            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11046        })
11047        .unwrap();
11048
11049        assert!(session.entries.is_empty());
11050        assert!(diagnostics.skipped_entries.is_empty());
11051    }
11052
11053    #[test]
11054    fn crash_truncated_last_entry_recovers_preceding() {
11055        let temp_dir = tempfile::tempdir().unwrap();
11056        let file_path = temp_dir.path().join("truncated.jsonl");
11057
11058        let mut content = build_crash_test_session_file(3);
11059        let truncation_point = content.rfind('\n').unwrap();
11060        content.truncate(truncation_point);
11061        content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
11062
11063        std::fs::write(&file_path, &content).unwrap();
11064
11065        let (session, diagnostics) = run_async(async {
11066            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11067        })
11068        .unwrap();
11069
11070        assert_eq!(session.entries.len(), 2);
11071        assert_eq!(diagnostics.skipped_entries.len(), 1);
11072    }
11073
11074    #[test]
11075    fn crash_multiple_corrupted_entries_recovers_valid() {
11076        let temp_dir = tempfile::tempdir().unwrap();
11077        let file_path = temp_dir.path().join("multi_corrupt.jsonl");
11078
11079        let header = serde_json::json!({
11080            "type": "session",
11081            "version": 3,
11082            "id": "multi-corrupt",
11083            "timestamp": "2024-06-01T00:00:00.000Z",
11084            "cwd": "/tmp/test"
11085        });
11086
11087        let valid_entry = |id: &str, text: &str| {
11088            serde_json::json!({
11089                "type": "message",
11090                "id": id,
11091                "timestamp": "2024-06-01T00:00:00.000Z",
11092                "message": {"role": "user", "content": text}
11093            })
11094            .to_string()
11095        };
11096
11097        let lines = [
11098            serde_json::to_string(&header).unwrap(),
11099            valid_entry("v1", "first"),
11100            "GARBAGE LINE 1".to_string(),
11101            valid_entry("v2", "second"),
11102            "{incomplete json".to_string(),
11103            valid_entry("v3", "third"),
11104        ];
11105
11106        std::fs::write(&file_path, lines.join("\n")).unwrap();
11107
11108        let (session, diagnostics) = run_async(async {
11109            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11110        })
11111        .unwrap();
11112
11113        assert_eq!(session.entries.len(), 3, "3 valid entries survive");
11114        assert_eq!(diagnostics.skipped_entries.len(), 2);
11115    }
11116
11117    #[test]
11118    fn crash_incremental_append_survives_partial_write() {
11119        use std::io::Write;
11120
11121        let temp_dir = tempfile::tempdir().unwrap();
11122        let mut session = Session::create();
11123        session.session_dir = Some(temp_dir.path().to_path_buf());
11124
11125        session.append_message(make_test_message("msg A"));
11126        session.append_message(make_test_message("msg B"));
11127        run_async(async { session.save().await }).unwrap();
11128        let path = session.path.clone().unwrap();
11129
11130        // Simulate crash during append: write truncated entry.
11131        let mut file = std::fs::OpenOptions::new()
11132            .append(true)
11133            .open(&path)
11134            .unwrap();
11135        write!(
11136            file,
11137            "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
11138        )
11139        .unwrap();
11140        drop(file);
11141
11142        let (loaded, diagnostics) = run_async(async {
11143            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11144        })
11145        .unwrap();
11146
11147        assert_eq!(loaded.entries.len(), 2, "original entries recovered");
11148        assert_eq!(diagnostics.skipped_entries.len(), 1);
11149    }
11150
11151    #[test]
11152    fn crash_full_rewrite_atomic_persist() {
11153        let temp_dir = tempfile::tempdir().unwrap();
11154        let mut session = Session::create();
11155        session.session_dir = Some(temp_dir.path().to_path_buf());
11156
11157        session.append_message(make_test_message("original"));
11158        run_async(async { session.save().await }).unwrap();
11159        let path = session.path.clone().unwrap();
11160
11161        let original_content = std::fs::read_to_string(&path).unwrap();
11162
11163        session.set_model_header(Some("new-provider".to_string()), None, None);
11164        session.append_message(make_test_message("second"));
11165        run_async(async { session.save().await }).unwrap();
11166
11167        let new_content = std::fs::read_to_string(&path).unwrap();
11168        assert_ne!(original_content, new_content);
11169
11170        let loaded =
11171            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11172        assert_eq!(loaded.entries.len(), 2);
11173    }
11174
11175    #[cfg(unix)]
11176    #[test]
11177    fn crash_full_rewrite_preserves_existing_file_permissions() {
11178        use std::os::unix::fs::PermissionsExt;
11179
11180        let temp_dir = tempfile::tempdir().unwrap();
11181        let mut session = Session::create();
11182        session.session_dir = Some(temp_dir.path().to_path_buf());
11183
11184        session.append_message(make_test_message("original"));
11185        run_async(async { session.save().await }).unwrap();
11186        let path = session.path.clone().unwrap();
11187
11188        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11189
11190        session.set_model_header(Some("new-provider".to_string()), None, None);
11191        session.append_message(make_test_message("second"));
11192        run_async(async { session.save().await }).unwrap();
11193
11194        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
11195        assert_eq!(mode, 0o444, "full rewrite must preserve existing mode bits");
11196    }
11197
11198    #[test]
11199    fn full_rewrite_preserves_entries_appended_by_other_writer() {
11200        let temp_dir = tempfile::tempdir().unwrap();
11201        let mut session = Session::create();
11202        session.session_dir = Some(temp_dir.path().to_path_buf());
11203
11204        session.append_message(make_test_message("original"));
11205        run_async(async { session.save().await }).unwrap();
11206        let path = session.path.clone().unwrap();
11207
11208        let mut stale_rewriter =
11209            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11210        let mut appender =
11211            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11212
11213        appender.append_message(make_test_message("from appender"));
11214        run_async(async { appender.save().await }).unwrap();
11215
11216        stale_rewriter.set_model_header(Some("new-provider".to_string()), None, None);
11217        run_async(async { stale_rewriter.save().await }).unwrap();
11218
11219        let loaded =
11220            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11221        let entry_texts = loaded
11222            .entries
11223            .iter()
11224            .filter_map(|entry| match entry {
11225                SessionEntry::Message(message) => match &message.message {
11226                    SessionMessage::User { content, .. } => match content {
11227                        UserContent::Text(text) => Some(text.clone()),
11228                        UserContent::Blocks(_) => None,
11229                    },
11230                    SessionMessage::Assistant { message } => {
11231                        message.content.iter().find_map(|block| match block {
11232                            ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()),
11233                            _ => None,
11234                        })
11235                    }
11236                    SessionMessage::ToolResult { .. } => None,
11237                    SessionMessage::Custom { .. } => None,
11238                    SessionMessage::BashExecution { .. } => None,
11239                    SessionMessage::BranchSummary { .. } => None,
11240                    SessionMessage::CompactionSummary { .. } => None,
11241                },
11242                _ => None,
11243            })
11244            .collect::<Vec<_>>();
11245
11246        assert!(
11247            entry_texts.iter().any(|text| text.eq("from appender")),
11248            "full rewrite should preserve entries appended after this session was opened"
11249        );
11250        assert_eq!(loaded.header.provider.as_deref(), Some("new-provider"));
11251    }
11252
11253    #[test]
11254    fn crash_flush_failure_restores_pending_mutations() {
11255        let mut queue = AutosaveQueue::with_limit(10);
11256
11257        queue.enqueue_mutation(AutosaveMutationKind::Message);
11258        queue.enqueue_mutation(AutosaveMutationKind::Message);
11259        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11260        assert_eq!(queue.pending_mutations, 3);
11261
11262        let ticket = queue
11263            .begin_flush(AutosaveFlushTrigger::Periodic)
11264            .expect("should have ticket");
11265        assert_eq!(queue.pending_mutations, 0);
11266
11267        queue.finish_flush(ticket, false);
11268        assert_eq!(queue.pending_mutations, 3, "mutations restored");
11269        assert_eq!(queue.flush_failed, 1);
11270    }
11271
11272    #[test]
11273    fn crash_flush_failure_respects_queue_capacity() {
11274        let mut queue = AutosaveQueue::with_limit(3);
11275
11276        for _ in 0..3 {
11277            queue.enqueue_mutation(AutosaveMutationKind::Message);
11278        }
11279        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11280
11281        queue.enqueue_mutation(AutosaveMutationKind::Message);
11282        queue.enqueue_mutation(AutosaveMutationKind::Message);
11283        assert_eq!(queue.pending_mutations, 2);
11284
11285        queue.finish_flush(ticket, false);
11286        assert_eq!(queue.pending_mutations, 3, "capped at max");
11287        assert!(queue.backpressure_events >= 2);
11288    }
11289
11290    #[test]
11291    fn crash_shutdown_strict_propagates_error() {
11292        let temp_dir = tempfile::tempdir().unwrap();
11293        let mut session = Session::create();
11294        session.path = Some(
11295            temp_dir
11296                .path()
11297                .join("nonexistent_dir")
11298                .join("session.jsonl"),
11299        );
11300        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
11301        session.append_message(make_test_message("must save"));
11302        session
11303            .autosave_queue
11304            .enqueue_mutation(AutosaveMutationKind::Message);
11305
11306        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11307        assert!(result.is_err(), "strict mode propagates errors");
11308    }
11309
11310    #[test]
11311    fn crash_shutdown_balanced_swallows_error() {
11312        let temp_dir = tempfile::tempdir().unwrap();
11313        let mut session = Session::create();
11314        session.path = Some(
11315            temp_dir
11316                .path()
11317                .join("nonexistent_dir")
11318                .join("session.jsonl"),
11319        );
11320        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
11321        session.append_message(make_test_message("best effort"));
11322        session
11323            .autosave_queue
11324            .enqueue_mutation(AutosaveMutationKind::Message);
11325
11326        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11327        assert!(result.is_ok(), "balanced mode swallows errors");
11328    }
11329
11330    #[test]
11331    fn crash_shutdown_throughput_skips_flush() {
11332        let temp_dir = tempfile::tempdir().unwrap();
11333        let mut session = Session::create();
11334        session.path = Some(
11335            temp_dir
11336                .path()
11337                .join("nonexistent_dir")
11338                .join("session.jsonl"),
11339        );
11340        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
11341        session.append_message(make_test_message("no flush"));
11342        session
11343            .autosave_queue
11344            .enqueue_mutation(AutosaveMutationKind::Message);
11345
11346        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11347        assert!(result.is_ok());
11348        assert!(session.autosave_queue.pending_mutations > 0);
11349    }
11350
11351    #[test]
11352    fn crash_save_reload_preserves_all_entry_types() {
11353        let temp_dir = tempfile::tempdir().unwrap();
11354        let mut session = Session::create();
11355        session.session_dir = Some(temp_dir.path().to_path_buf());
11356
11357        let id_a = session.append_message(make_test_message("msg A"));
11358        session.append_model_change("provider-x".to_string(), "model-y".to_string());
11359        session.append_thinking_level_change("high".to_string());
11360        session.append_compaction("summary".to_string(), id_a, 500, None, None);
11361        session.append_message(make_test_message("msg B"));
11362
11363        run_async(async { session.save().await }).unwrap();
11364        let path = session.path.clone().unwrap();
11365
11366        let loaded =
11367            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11368        assert_eq!(loaded.entries.len(), session.entries.len());
11369    }
11370
11371    #[test]
11372    fn crash_checkpoint_rewrite_cleans_corruption() {
11373        let temp_dir = tempfile::tempdir().unwrap();
11374        let mut session = Session::create();
11375        session.session_dir = Some(temp_dir.path().to_path_buf());
11376
11377        session.append_message(make_test_message("initial"));
11378        run_async(async { session.save().await }).unwrap();
11379        let path = session.path.clone().unwrap();
11380
11381        for i in 0..5 {
11382            session.append_message(make_test_message(&format!("msg {i}")));
11383            run_async(async { session.save().await }).unwrap();
11384        }
11385
11386        // Corrupt an appended entry on disk.
11387        let content = std::fs::read_to_string(&path).unwrap();
11388        let mut lines: Vec<String> = content.lines().map(String::from).collect();
11389        lines[3] = "CORRUPTED_ENTRY".to_string();
11390        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11391
11392        // Force checkpoint: full rewrite replaces corrupted file with clean data.
11393        session.appends_since_checkpoint = compaction_checkpoint_interval();
11394        session.append_message(make_test_message("post checkpoint"));
11395        run_async(async { session.save().await }).unwrap();
11396        assert_eq!(session.appends_since_checkpoint, 0);
11397
11398        let (reloaded, diagnostics) = run_async(async {
11399            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11400        })
11401        .unwrap();
11402        assert!(diagnostics.skipped_entries.is_empty());
11403        assert_eq!(reloaded.entries.len(), 7);
11404    }
11405
11406    #[test]
11407    fn crash_trailing_newlines_loads_cleanly() {
11408        let temp_dir = tempfile::tempdir().unwrap();
11409        let file_path = temp_dir.path().join("trailing_nl.jsonl");
11410
11411        let mut content = build_crash_test_session_file(2);
11412        content.push_str("\n\n\n");
11413        std::fs::write(&file_path, &content).unwrap();
11414
11415        let (session, diagnostics) = run_async(async {
11416            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11417        })
11418        .unwrap();
11419
11420        assert_eq!(session.entries.len(), 2);
11421        assert!(diagnostics.skipped_entries.is_empty());
11422    }
11423
11424    #[test]
11425    fn crash_noop_save_after_reload_is_idempotent() {
11426        let temp_dir = tempfile::tempdir().unwrap();
11427        let mut session = Session::create();
11428        session.session_dir = Some(temp_dir.path().to_path_buf());
11429
11430        session.append_message(make_test_message("hello"));
11431        run_async(async { session.save().await }).unwrap();
11432        let path = session.path.clone().unwrap();
11433        let content_before = std::fs::read_to_string(&path).unwrap();
11434
11435        let mut loaded =
11436            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11437        run_async(async { loaded.save().await }).unwrap();
11438
11439        let content_after = std::fs::read_to_string(&path).unwrap();
11440        assert_eq!(content_before, content_after);
11441    }
11442
11443    #[test]
11444    fn crash_corrupt_then_continue_operation() {
11445        let temp_dir = tempfile::tempdir().unwrap();
11446        let mut session = Session::create();
11447        session.session_dir = Some(temp_dir.path().to_path_buf());
11448
11449        session.append_message(make_test_message("msg A"));
11450        session.append_message(make_test_message("msg B"));
11451        run_async(async { session.save().await }).unwrap();
11452        let path = session.path.clone().unwrap();
11453
11454        // Corrupt last entry.
11455        let content = std::fs::read_to_string(&path).unwrap();
11456        let mut lines: Vec<String> = content.lines().map(String::from).collect();
11457        *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
11458        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11459
11460        let (mut recovered, diagnostics) = run_async(async {
11461            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11462        })
11463        .unwrap();
11464        assert_eq!(diagnostics.skipped_entries.len(), 1);
11465        assert_eq!(recovered.entries.len(), 1);
11466
11467        // Continue: add and save.
11468        recovered.path = Some(path.clone());
11469        recovered.session_dir = Some(temp_dir.path().to_path_buf());
11470        recovered.append_message(make_test_message("msg C"));
11471        run_async(async { recovered.save().await }).unwrap();
11472
11473        let reloaded =
11474            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11475        assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
11476    }
11477
11478    #[test]
11479    fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
11480        let temp_dir = tempfile::tempdir().unwrap();
11481        let mut session = Session::create();
11482        session.session_dir = Some(temp_dir.path().to_path_buf());
11483
11484        session.append_message(make_test_message("msg A"));
11485        run_async(async { session.save().await }).unwrap();
11486
11487        session.persisted_entry_count.store(999, Ordering::SeqCst);
11488        assert!(session.should_full_rewrite());
11489
11490        session.append_message(make_test_message("msg B"));
11491        run_async(async { session.save().await }).unwrap();
11492        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11493        assert_eq!(session.appends_since_checkpoint, 0);
11494    }
11495
11496    #[test]
11497    fn crash_persisted_count_unchanged_on_append_failure() {
11498        let temp_dir = tempfile::tempdir().unwrap();
11499        let mut session = Session::create();
11500        session.session_dir = Some(temp_dir.path().to_path_buf());
11501
11502        session.append_message(make_test_message("msg A"));
11503        run_async(async { session.save().await }).unwrap();
11504        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11505
11506        let path = session.path.clone().unwrap();
11507        session.append_message(make_test_message("msg B"));
11508
11509        #[cfg(unix)]
11510        {
11511            use std::os::unix::fs::PermissionsExt;
11512            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11513            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11514                // Some environments (for example root-run test runners) bypass chmod restrictions.
11515                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11516                return;
11517            }
11518        }
11519        #[cfg(not(unix))]
11520        {
11521            return;
11522        }
11523
11524        let result = run_async(async { session.save().await });
11525
11526        #[cfg(unix)]
11527        {
11528            use std::os::unix::fs::PermissionsExt;
11529            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11530        }
11531
11532        assert!(result.is_err());
11533        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11534
11535        run_async(async { session.save().await }).unwrap();
11536        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11537    }
11538
11539    #[test]
11540    fn crash_missing_session_file_forces_full_rewrite_recovery() {
11541        let temp_dir = tempfile::tempdir().unwrap();
11542        let mut session = Session::create();
11543        session.session_dir = Some(temp_dir.path().to_path_buf());
11544
11545        session.append_message(make_test_message("msg A"));
11546        run_async(async { session.save().await }).unwrap();
11547
11548        let path = session.path.clone().unwrap();
11549        std::fs::remove_file(&path).unwrap();
11550        assert!(session.should_full_rewrite());
11551
11552        session.append_message(make_test_message("msg B"));
11553        run_async(async { session.save().await }).unwrap();
11554
11555        let reloaded =
11556            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11557        assert_eq!(reloaded.entries.len(), 2);
11558        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11559        assert_eq!(session.appends_since_checkpoint, 0);
11560    }
11561
11562    #[test]
11563    fn crash_queue_backpressure_at_limit() {
11564        let mut queue = AutosaveQueue::with_limit(3);
11565
11566        queue.enqueue_mutation(AutosaveMutationKind::Message);
11567        queue.enqueue_mutation(AutosaveMutationKind::Message);
11568        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11569        assert_eq!(queue.pending_mutations, 3);
11570
11571        queue.enqueue_mutation(AutosaveMutationKind::Label);
11572        assert_eq!(queue.pending_mutations, 3, "capped");
11573        assert_eq!(queue.backpressure_events, 1);
11574    }
11575
11576    #[test]
11577    fn crash_flush_failure_with_intervening_mutations() {
11578        let mut queue = AutosaveQueue::with_limit(8);
11579
11580        for _ in 0..4 {
11581            queue.enqueue_mutation(AutosaveMutationKind::Message);
11582        }
11583        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11584
11585        queue.enqueue_mutation(AutosaveMutationKind::Message);
11586        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11587        assert_eq!(queue.pending_mutations, 2);
11588
11589        // restore_budget = 8 - 2 = 6, restored = min(4, 6) = 4
11590        queue.finish_flush(ticket, false);
11591        assert_eq!(queue.pending_mutations, 6);
11592        assert_eq!(queue.flush_failed, 1);
11593    }
11594
11595    #[test]
11596    fn crash_queue_metrics_snapshot() {
11597        let mut queue = AutosaveQueue::with_limit(5);
11598        queue.enqueue_mutation(AutosaveMutationKind::Message);
11599        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11600        queue.enqueue_mutation(AutosaveMutationKind::Label);
11601
11602        let metrics = queue.metrics();
11603        assert_eq!(metrics.pending_mutations, 3);
11604        assert_eq!(metrics.max_pending_mutations, 5);
11605        assert_eq!(metrics.coalesced_mutations, 2);
11606        assert_eq!(metrics.flush_started, 0);
11607        assert!(metrics.last_flush_duration_ms.is_none());
11608    }
11609
11610    #[test]
11611    fn crash_double_flush_is_noop() {
11612        let mut queue = AutosaveQueue::with_limit(10);
11613        queue.enqueue_mutation(AutosaveMutationKind::Message);
11614        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11615        queue.finish_flush(ticket, true);
11616
11617        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
11618    }
11619
11620    #[test]
11621    fn crash_finish_worker_result_propagates_panic_before_cancellation() {
11622        let handle = thread::spawn(|| -> () {
11623            test_fail!("jsonl worker panic");
11624        });
11625
11626        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11627            let _: Result<()> =
11628                finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled");
11629        }));
11630
11631        assert!(
11632            panic.is_err(),
11633            "worker panic should not be masked as cancellation"
11634        );
11635    }
11636
11637    #[test]
11638    fn crash_finish_worker_result_maps_nonpanic_cancellation_to_session_error() {
11639        let handle = thread::spawn(|| {});
11640
11641        let err = finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled")
11642            .expect_err("error");
11643
11644        assert!(
11645            err.to_string().contains("Save task cancelled"),
11646            "unexpected error: {err}"
11647        );
11648    }
11649
11650    #[test]
11651    fn crash_finish_worker_result_returns_success_payload() {
11652        let handle = thread::spawn(|| {});
11653
11654        let value =
11655            finish_worker_result::<usize, ()>(handle, Ok(Ok(7usize)), "task cancelled").unwrap();
11656
11657        assert_eq!(value, 7);
11658    }
11659
11660    #[test]
11661    fn crash_entries_survive_failed_full_rewrite() {
11662        // Entries are cloned during full rewrite to avoid losing them if the async future drops.
11663        // On error, the session must still contain the entries in memory.
11664        let temp_dir = tempfile::tempdir().unwrap();
11665        let mut session = Session::create();
11666        session.session_dir = Some(temp_dir.path().to_path_buf());
11667
11668        session.append_message(make_test_message("msg A"));
11669        run_async(async { session.save().await }).unwrap();
11670        let path = session.path.clone().unwrap();
11671
11672        session.set_model_header(Some("new-provider".to_string()), None, None);
11673        session.append_message(make_test_message("msg B"));
11674
11675        #[cfg(unix)]
11676        {
11677            use std::os::unix::fs::PermissionsExt;
11678            let parent = path.parent().unwrap();
11679            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
11680            if tempfile::NamedTempFile::new_in(parent).is_ok() {
11681                // Some environments (for example root-run test runners) bypass chmod restrictions.
11682                std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11683                return;
11684            }
11685        }
11686        #[cfg(not(unix))]
11687        {
11688            return;
11689        }
11690
11691        let result = run_async(async { session.save().await });
11692        assert!(result.is_err());
11693
11694        assert_eq!(session.entries.len(), 2, "entries restored");
11695        assert_eq!(session.entry_index.len(), 2);
11696        assert!(session.header_dirty);
11697
11698        #[cfg(unix)]
11699        {
11700            use std::os::unix::fs::PermissionsExt;
11701            let parent = path.parent().unwrap();
11702            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11703        }
11704
11705        run_async(async { session.save().await }).unwrap();
11706        assert!(!session.header_dirty);
11707    }
11708
11709    #[test]
11710    fn crash_metrics_accumulate_across_failure_recovery() {
11711        let temp_dir = tempfile::tempdir().unwrap();
11712        let mut session = Session::create();
11713        session.session_dir = Some(temp_dir.path().to_path_buf());
11714
11715        session.append_message(make_test_message("msg A"));
11716        run_async(async { session.save().await }).unwrap();
11717        let path = session.path.clone().unwrap();
11718
11719        let m = session.autosave_metrics();
11720        assert_eq!(m.flush_succeeded, 1);
11721        assert_eq!(m.flush_failed, 0);
11722
11723        #[cfg(unix)]
11724        {
11725            use std::os::unix::fs::PermissionsExt;
11726            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11727            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11728                // Some environments (for example root-run test runners) bypass chmod restrictions.
11729                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11730                return;
11731            }
11732        }
11733        #[cfg(not(unix))]
11734        {
11735            return;
11736        }
11737
11738        session.append_message(make_test_message("msg B"));
11739        let _ = run_async(async { session.save().await });
11740
11741        let m = session.autosave_metrics();
11742        assert_eq!(m.flush_failed, 1);
11743        assert!(m.pending_mutations > 0);
11744
11745        #[cfg(unix)]
11746        {
11747            use std::os::unix::fs::PermissionsExt;
11748            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11749        }
11750        run_async(async { session.save().await }).unwrap();
11751
11752        let m = session.autosave_metrics();
11753        assert_eq!(m.flush_succeeded, 2);
11754        assert_eq!(m.flush_failed, 1);
11755        assert_eq!(m.pending_mutations, 0);
11756        assert_eq!(m.flush_started, 3);
11757    }
11758
11759    #[test]
11760    fn crash_many_sequential_appends_accumulate() {
11761        let temp_dir = tempfile::tempdir().unwrap();
11762        let mut session = Session::create();
11763        session.session_dir = Some(temp_dir.path().to_path_buf());
11764
11765        session.append_message(make_test_message("initial"));
11766        run_async(async { session.save().await }).unwrap();
11767
11768        for i in 0..10 {
11769            session.append_message(make_test_message(&format!("append-{i}")));
11770            run_async(async { session.save().await }).unwrap();
11771        }
11772
11773        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
11774        assert_eq!(session.appends_since_checkpoint, 10);
11775
11776        let path = session.path.clone().unwrap();
11777        let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
11778        assert_eq!(line_count, 12, "1 header + 11 entries");
11779
11780        let loaded =
11781            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11782        assert_eq!(loaded.entries.len(), 11);
11783    }
11784
11785    #[test]
11786    fn crash_load_unsaved_entry_absent() {
11787        let temp_dir = tempfile::tempdir().unwrap();
11788        let mut session = Session::create();
11789        session.session_dir = Some(temp_dir.path().to_path_buf());
11790
11791        session.append_message(make_test_message("saved A"));
11792        session.append_message(make_test_message("saved B"));
11793        run_async(async { session.save().await }).unwrap();
11794        let path = session.path.clone().unwrap();
11795
11796        session.append_message(make_test_message("unsaved C"));
11797        assert_eq!(session.entries.len(), 3);
11798
11799        let loaded =
11800            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11801        assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
11802    }
11803
11804    #[test]
11805    fn test_clone_has_independent_persisted_entry_count() {
11806        let session = Session::create();
11807        // Set initial count
11808        session.persisted_entry_count.store(10, Ordering::SeqCst);
11809
11810        // Clone the session
11811        let clone = session.clone();
11812
11813        // Verify clone sees initial value
11814        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11815
11816        // Update original
11817        session.persisted_entry_count.store(20, Ordering::SeqCst);
11818
11819        // Verify clone is UNCHANGED (independent atomic)
11820        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11821
11822        // Update clone
11823        clone.persisted_entry_count.store(30, Ordering::SeqCst);
11824
11825        // Verify original is UNCHANGED
11826        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
11827    }
11828
11829    #[test]
11830    fn crash_append_retry_after_transient_failure() {
11831        let temp_dir = tempfile::tempdir().unwrap();
11832        let mut session = Session::create();
11833        session.session_dir = Some(temp_dir.path().to_path_buf());
11834
11835        session.append_message(make_test_message("msg A"));
11836        run_async(async { session.save().await }).unwrap();
11837        let path = session.path.clone().unwrap();
11838
11839        session.append_message(make_test_message("msg B"));
11840
11841        #[cfg(unix)]
11842        {
11843            use std::os::unix::fs::PermissionsExt;
11844            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11845            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11846                // Some environments (for example root-run test runners) bypass chmod restrictions.
11847                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11848                return;
11849            }
11850        }
11851        #[cfg(not(unix))]
11852        {
11853            return;
11854        }
11855
11856        let result = run_async(async { session.save().await });
11857        assert!(result.is_err());
11858        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11859
11860        #[cfg(unix)]
11861        {
11862            use std::os::unix::fs::PermissionsExt;
11863            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11864        }
11865
11866        run_async(async { session.save().await }).unwrap();
11867        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11868
11869        let loaded =
11870            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11871        assert_eq!(loaded.entries.len(), 2);
11872    }
11873
11874    #[test]
11875    fn crash_durability_mode_parsing() {
11876        assert_eq!(
11877            AutosaveDurabilityMode::parse("strict"),
11878            Some(AutosaveDurabilityMode::Strict)
11879        );
11880        assert_eq!(
11881            AutosaveDurabilityMode::parse("BALANCED"),
11882            Some(AutosaveDurabilityMode::Balanced)
11883        );
11884        assert_eq!(
11885            AutosaveDurabilityMode::parse("  Throughput  "),
11886            Some(AutosaveDurabilityMode::Throughput)
11887        );
11888        assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
11889        assert_eq!(AutosaveDurabilityMode::parse(""), None);
11890    }
11891
11892    #[test]
11893    fn crash_durability_resolve_precedence() {
11894        assert_eq!(
11895            resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
11896            AutosaveDurabilityMode::Strict
11897        );
11898        assert_eq!(
11899            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
11900            AutosaveDurabilityMode::Throughput
11901        );
11902        assert_eq!(
11903            resolve_autosave_durability_mode(None, None, Some("strict")),
11904            AutosaveDurabilityMode::Strict
11905        );
11906        assert_eq!(
11907            resolve_autosave_durability_mode(None, None, None),
11908            AutosaveDurabilityMode::Balanced
11909        );
11910    }
11911
11912    // =========================================================================
11913    // bd-3ar8v.2.9: Comprehensive autosave queue and durability state machine
11914    // =========================================================================
11915
11916    // --- Queue boundary: minimum capacity (limit=1) ---
11917
11918    #[test]
11919    fn autosave_queue_limit_one_accepts_single_mutation() {
11920        let mut queue = AutosaveQueue::with_limit(1);
11921        queue.enqueue_mutation(AutosaveMutationKind::Message);
11922        assert_eq!(queue.pending_mutations, 1);
11923        assert_eq!(queue.coalesced_mutations, 0);
11924        assert_eq!(queue.backpressure_events, 0);
11925    }
11926
11927    #[test]
11928    fn autosave_queue_limit_one_backpressures_second_mutation() {
11929        let mut queue = AutosaveQueue::with_limit(1);
11930        queue.enqueue_mutation(AutosaveMutationKind::Message);
11931        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11932        assert_eq!(queue.pending_mutations, 1, "capped at 1");
11933        assert_eq!(queue.backpressure_events, 1);
11934        assert_eq!(queue.coalesced_mutations, 1);
11935    }
11936
11937    #[test]
11938    fn autosave_queue_limit_one_flush_and_refill() {
11939        let mut queue = AutosaveQueue::with_limit(1);
11940        queue.enqueue_mutation(AutosaveMutationKind::Message);
11941
11942        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
11943        assert_eq!(queue.pending_mutations, 0);
11944        assert_eq!(ticket.batch_size, 1);
11945        queue.finish_flush(ticket, true);
11946
11947        // Refill works after flush.
11948        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11949        assert_eq!(queue.pending_mutations, 1);
11950        assert_eq!(queue.flush_succeeded, 1);
11951    }
11952
11953    // --- Queue boundary: with_limit enforces minimum of 1 ---
11954
11955    #[test]
11956    fn autosave_queue_with_limit_zero_clamps_to_one() {
11957        let queue = AutosaveQueue::with_limit(0);
11958        assert_eq!(queue.max_pending_mutations, 1);
11959    }
11960
11961    // --- Empty queue operations ---
11962
11963    #[test]
11964    fn autosave_queue_begin_flush_on_empty_returns_none() {
11965        let mut queue = AutosaveQueue::with_limit(10);
11966        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
11967        assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
11968    }
11969
11970    #[test]
11971    fn autosave_queue_metrics_on_fresh_queue() {
11972        let queue = AutosaveQueue::with_limit(256);
11973        let m = queue.metrics();
11974        assert_eq!(m.pending_mutations, 0);
11975        assert_eq!(m.max_pending_mutations, 256);
11976        assert_eq!(m.coalesced_mutations, 0);
11977        assert_eq!(m.backpressure_events, 0);
11978        assert_eq!(m.flush_started, 0);
11979        assert_eq!(m.flush_succeeded, 0);
11980        assert_eq!(m.flush_failed, 0);
11981        assert_eq!(m.last_flush_batch_size, 0);
11982        assert!(m.last_flush_duration_ms.is_none());
11983        assert!(m.last_flush_trigger.is_none());
11984    }
11985
11986    // --- All three mutation kinds ---
11987
11988    #[test]
11989    fn autosave_queue_all_mutation_kinds() {
11990        let mut queue = AutosaveQueue::with_limit(10);
11991        queue.enqueue_mutation(AutosaveMutationKind::Message);
11992        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11993        queue.enqueue_mutation(AutosaveMutationKind::Label);
11994        assert_eq!(queue.pending_mutations, 3);
11995        // First mutation has no coalescing; subsequent two do.
11996        assert_eq!(queue.coalesced_mutations, 2);
11997    }
11998
11999    // --- Multiple consecutive flushes with mixed outcomes ---
12000
12001    #[test]
12002    fn autosave_queue_consecutive_success_flushes() {
12003        let mut queue = AutosaveQueue::with_limit(5);
12004
12005        for round in 1..=3_u64 {
12006            queue.enqueue_mutation(AutosaveMutationKind::Message);
12007            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12008            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12009            queue.finish_flush(ticket, true);
12010            assert_eq!(queue.pending_mutations, 0);
12011            assert_eq!(queue.flush_succeeded, round);
12012            assert_eq!(queue.flush_started, round);
12013            assert_eq!(queue.last_flush_batch_size, 2);
12014        }
12015        assert_eq!(queue.flush_failed, 0);
12016    }
12017
12018    #[test]
12019    fn autosave_queue_alternating_success_failure() {
12020        let mut queue = AutosaveQueue::with_limit(10);
12021
12022        // Round 1: success
12023        queue.enqueue_mutation(AutosaveMutationKind::Message);
12024        let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12025        queue.finish_flush(t1, true);
12026        assert_eq!(queue.flush_succeeded, 1);
12027        assert_eq!(queue.flush_failed, 0);
12028        assert_eq!(queue.pending_mutations, 0);
12029
12030        // Round 2: failure (mutations restored)
12031        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12032        queue.enqueue_mutation(AutosaveMutationKind::Label);
12033        let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12034        queue.finish_flush(t2, false);
12035        assert_eq!(queue.flush_succeeded, 1);
12036        assert_eq!(queue.flush_failed, 1);
12037        assert_eq!(queue.pending_mutations, 2, "restored from failure");
12038
12039        // Round 3: success (clears the restored mutations)
12040        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12041        assert_eq!(t3.batch_size, 2);
12042        queue.finish_flush(t3, true);
12043        assert_eq!(queue.flush_succeeded, 2);
12044        assert_eq!(queue.flush_failed, 1);
12045        assert_eq!(queue.pending_mutations, 0);
12046        assert_eq!(queue.flush_started, 3);
12047    }
12048
12049    // --- Failure when queue is completely full (zero capacity to restore) ---
12050
12051    #[test]
12052    fn autosave_queue_failure_drops_all_when_full() {
12053        let mut queue = AutosaveQueue::with_limit(3);
12054
12055        // Fill to capacity and flush.
12056        for _ in 0..3 {
12057            queue.enqueue_mutation(AutosaveMutationKind::Message);
12058        }
12059        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12060        assert_eq!(ticket.batch_size, 3);
12061        assert_eq!(queue.pending_mutations, 0);
12062
12063        // Fill queue completely while flush is in flight.
12064        for _ in 0..3 {
12065            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12066        }
12067        assert_eq!(queue.pending_mutations, 3);
12068
12069        // Flush fails — no capacity to restore, all 3 batch mutations are dropped.
12070        let bp_before = queue.backpressure_events;
12071        queue.finish_flush(ticket, false);
12072        assert_eq!(queue.pending_mutations, 3, "capped at max");
12073        assert_eq!(queue.flush_failed, 1);
12074        assert_eq!(
12075            queue.backpressure_events,
12076            bp_before + 3,
12077            "dropped mutations counted as backpressure"
12078        );
12079    }
12080
12081    // --- Flush trigger tracking ---
12082
12083    #[test]
12084    fn autosave_queue_tracks_trigger_across_flushes() {
12085        let mut queue = AutosaveQueue::with_limit(10);
12086
12087        // Manual trigger.
12088        queue.enqueue_mutation(AutosaveMutationKind::Message);
12089        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12090        assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
12091        queue.finish_flush(t1, true);
12092        assert_eq!(
12093            queue.metrics().last_flush_trigger,
12094            Some(AutosaveFlushTrigger::Manual)
12095        );
12096
12097        // Periodic trigger.
12098        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12099        let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12100        queue.finish_flush(t2, true);
12101        assert_eq!(
12102            queue.metrics().last_flush_trigger,
12103            Some(AutosaveFlushTrigger::Periodic)
12104        );
12105
12106        // Shutdown trigger.
12107        queue.enqueue_mutation(AutosaveMutationKind::Label);
12108        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12109        queue.finish_flush(t3, true);
12110        assert_eq!(
12111            queue.metrics().last_flush_trigger,
12112            Some(AutosaveFlushTrigger::Shutdown)
12113        );
12114    }
12115
12116    // --- Flush records duration ---
12117
12118    #[test]
12119    fn autosave_queue_flush_records_duration() {
12120        let mut queue = AutosaveQueue::with_limit(10);
12121        queue.enqueue_mutation(AutosaveMutationKind::Message);
12122        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12123        queue.finish_flush(ticket, true);
12124        // Duration should be recorded (>= 0ms).
12125        assert!(queue.metrics().last_flush_duration_ms.is_some());
12126    }
12127
12128    // --- Rapid enqueue-flush cycles ---
12129
12130    #[test]
12131    fn autosave_queue_rapid_single_mutation_flushes() {
12132        let mut queue = AutosaveQueue::with_limit(10);
12133        let rounds = 20;
12134
12135        for _ in 0..rounds {
12136            queue.enqueue_mutation(AutosaveMutationKind::Message);
12137            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12138            queue.finish_flush(ticket, true);
12139        }
12140
12141        let m = queue.metrics();
12142        assert_eq!(m.flush_started, rounds);
12143        assert_eq!(m.flush_succeeded, rounds);
12144        assert_eq!(m.flush_failed, 0);
12145        assert_eq!(m.pending_mutations, 0);
12146        assert_eq!(m.last_flush_batch_size, 1);
12147    }
12148
12149    // --- Saturating counter behavior under heavy load ---
12150
12151    #[test]
12152    fn autosave_queue_many_backpressure_events_accumulate() {
12153        let mut queue = AutosaveQueue::with_limit(1);
12154        let excess: u64 = 100;
12155
12156        // First enqueue goes into the queue; rest are backpressure.
12157        for _ in 0..=excess {
12158            queue.enqueue_mutation(AutosaveMutationKind::Message);
12159        }
12160        assert_eq!(queue.pending_mutations, 1);
12161        assert_eq!(queue.backpressure_events, excess);
12162    }
12163
12164    // --- Durability mode: as_str roundtrip ---
12165
12166    #[test]
12167    fn autosave_durability_mode_as_str_roundtrip() {
12168        for mode in [
12169            AutosaveDurabilityMode::Strict,
12170            AutosaveDurabilityMode::Balanced,
12171            AutosaveDurabilityMode::Throughput,
12172        ] {
12173            let s = mode.as_str();
12174            let parsed = AutosaveDurabilityMode::parse(s);
12175            assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
12176        }
12177    }
12178
12179    // --- Durability mode: should_flush/best_effort truth table ---
12180
12181    #[test]
12182    fn autosave_durability_mode_shutdown_behavior_truth_table() {
12183        assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
12184        assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
12185
12186        assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
12187        assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
12188
12189        assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
12190        assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
12191    }
12192
12193    // --- Durability mode: case-insensitive parsing ---
12194
12195    #[test]
12196    fn autosave_durability_mode_parse_case_insensitive() {
12197        assert_eq!(
12198            AutosaveDurabilityMode::parse("STRICT"),
12199            Some(AutosaveDurabilityMode::Strict)
12200        );
12201        assert_eq!(
12202            AutosaveDurabilityMode::parse("Balanced"),
12203            Some(AutosaveDurabilityMode::Balanced)
12204        );
12205        assert_eq!(
12206            AutosaveDurabilityMode::parse("tHrOuGhPuT"),
12207            Some(AutosaveDurabilityMode::Throughput)
12208        );
12209    }
12210
12211    // --- Durability mode: whitespace trimming ---
12212
12213    #[test]
12214    fn autosave_durability_mode_parse_trims_whitespace() {
12215        assert_eq!(
12216            AutosaveDurabilityMode::parse("  strict  "),
12217            Some(AutosaveDurabilityMode::Strict)
12218        );
12219        assert_eq!(
12220            AutosaveDurabilityMode::parse("\tbalanced\n"),
12221            Some(AutosaveDurabilityMode::Balanced)
12222        );
12223    }
12224
12225    // --- Session-level: save on empty queue is no-op ---
12226
12227    #[test]
12228    fn autosave_session_save_on_empty_queue_is_noop() {
12229        let temp_dir = tempfile::tempdir().unwrap();
12230        let mut session = Session::create();
12231        session.session_dir = Some(temp_dir.path().to_path_buf());
12232
12233        // Save without any mutations — should succeed and not change metrics.
12234        let m_before = session.autosave_metrics();
12235        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
12236        let m_after = session.autosave_metrics();
12237
12238        assert_eq!(m_before.flush_started, m_after.flush_started);
12239        assert_eq!(m_after.pending_mutations, 0);
12240    }
12241
12242    // --- Session-level: mode change mid-session ---
12243
12244    #[test]
12245    fn autosave_session_mode_change_mid_session() {
12246        let mut session = Session::create();
12247        assert_eq!(
12248            session.autosave_durability_mode(),
12249            AutosaveDurabilityMode::Balanced,
12250            "default is balanced"
12251        );
12252
12253        session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
12254        assert_eq!(
12255            session.autosave_durability_mode(),
12256            AutosaveDurabilityMode::Strict
12257        );
12258
12259        session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
12260        assert_eq!(
12261            session.autosave_durability_mode(),
12262            AutosaveDurabilityMode::Throughput
12263        );
12264    }
12265
12266    // --- Session-level: all mutation types enqueue correctly ---
12267
12268    #[test]
12269    fn autosave_session_all_mutation_types_enqueue() {
12270        let mut session = Session::create();
12271
12272        let first_entry_id = session.append_message(make_test_message("msg"));
12273        assert_eq!(session.autosave_metrics().pending_mutations, 1);
12274
12275        session.append_model_change("prov".to_string(), "model".to_string());
12276        assert_eq!(session.autosave_metrics().pending_mutations, 2);
12277
12278        session.append_thinking_level_change("high".to_string());
12279        assert_eq!(session.autosave_metrics().pending_mutations, 3);
12280
12281        session.append_session_info(Some("test-session".to_string()));
12282        assert_eq!(session.autosave_metrics().pending_mutations, 4);
12283
12284        session.append_custom_entry("custom".to_string(), None);
12285        assert_eq!(session.autosave_metrics().pending_mutations, 5);
12286
12287        // Label mutation (needs existing entry to target).
12288        session.add_label(&first_entry_id, Some("test-label".to_string()));
12289        assert_eq!(session.autosave_metrics().pending_mutations, 6);
12290    }
12291
12292    // --- Session-level: flush then verify metrics ---
12293
12294    #[test]
12295    fn autosave_session_manual_save_resets_pending() {
12296        let temp_dir = tempfile::tempdir().unwrap();
12297        let mut session = Session::create();
12298        session.session_dir = Some(temp_dir.path().to_path_buf());
12299
12300        session.append_message(make_test_message("a"));
12301        session.append_message(make_test_message("b"));
12302        session.append_message(make_test_message("c"));
12303        assert_eq!(session.autosave_metrics().pending_mutations, 3);
12304
12305        run_async(async { session.save().await }).unwrap();
12306
12307        let m = session.autosave_metrics();
12308        assert_eq!(m.pending_mutations, 0);
12309        assert_eq!(m.flush_succeeded, 1);
12310        assert_eq!(m.last_flush_batch_size, 3);
12311        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
12312    }
12313
12314    // --- Session-level: periodic flush trigger tracking ---
12315
12316    #[test]
12317    fn autosave_session_periodic_flush_tracks_trigger() {
12318        let temp_dir = tempfile::tempdir().unwrap();
12319        let mut session = Session::create();
12320        session.session_dir = Some(temp_dir.path().to_path_buf());
12321
12322        session.append_message(make_test_message("periodic msg"));
12323        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
12324
12325        let m = session.autosave_metrics();
12326        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
12327        assert_eq!(m.flush_succeeded, 1);
12328    }
12329
12330    // --- Session-level: shutdown flush with balanced mode success ---
12331
12332    #[test]
12333    fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
12334        let temp_dir = tempfile::tempdir().unwrap();
12335        let mut session = Session::create();
12336        session.session_dir = Some(temp_dir.path().to_path_buf());
12337        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
12338
12339        session.append_message(make_test_message("balanced ok"));
12340        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12341
12342        let m = session.autosave_metrics();
12343        assert_eq!(m.flush_succeeded, 1);
12344        assert_eq!(m.pending_mutations, 0);
12345        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
12346    }
12347
12348    // --- Queue: partial restoration on failure with various fill levels ---
12349
12350    #[test]
12351    fn autosave_queue_failure_partial_restoration() {
12352        let mut queue = AutosaveQueue::with_limit(5);
12353
12354        // Fill to 4 and flush (batch=4).
12355        for _ in 0..4 {
12356            queue.enqueue_mutation(AutosaveMutationKind::Message);
12357        }
12358        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12359        assert_eq!(ticket.batch_size, 4);
12360
12361        // Add 2 while flush is in flight.
12362        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12363        queue.enqueue_mutation(AutosaveMutationKind::Label);
12364        assert_eq!(queue.pending_mutations, 2);
12365
12366        // Fail: available_capacity = 5 - 2 = 3, restored = min(4,3) = 3, dropped = 1.
12367        let bp_before = queue.backpressure_events;
12368        let coal_before = queue.coalesced_mutations;
12369        queue.finish_flush(ticket, false);
12370        assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
12371        assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
12372        assert_eq!(
12373            queue.coalesced_mutations,
12374            coal_before + 1,
12375            "1 dropped coalesced"
12376        );
12377    }
12378
12379    // --- Queue: success flush does not restore ---
12380
12381    #[test]
12382    fn autosave_queue_success_does_not_restore_pending() {
12383        let mut queue = AutosaveQueue::with_limit(10);
12384
12385        queue.enqueue_mutation(AutosaveMutationKind::Message);
12386        queue.enqueue_mutation(AutosaveMutationKind::Message);
12387        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12388
12389        // Add 1 mutation while flush is in flight.
12390        queue.enqueue_mutation(AutosaveMutationKind::Label);
12391        assert_eq!(queue.pending_mutations, 1);
12392
12393        // Success: only the in-flight mutation remains.
12394        queue.finish_flush(ticket, true);
12395        assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
12396        assert_eq!(queue.flush_succeeded, 1);
12397    }
12398
12399    // --- Queue: large batch size tracking ---
12400
12401    #[test]
12402    fn autosave_queue_large_batch_tracking() {
12403        let mut queue = AutosaveQueue::with_limit(500);
12404
12405        for _ in 0..200 {
12406            queue.enqueue_mutation(AutosaveMutationKind::Message);
12407        }
12408
12409        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12410        assert_eq!(ticket.batch_size, 200);
12411        queue.finish_flush(ticket, true);
12412
12413        let m = queue.metrics();
12414        assert_eq!(m.last_flush_batch_size, 200);
12415        assert_eq!(m.flush_succeeded, 1);
12416        assert_eq!(m.pending_mutations, 0);
12417    }
12418
12419    // --- Durability resolve: all invalid falls through to default ---
12420
12421    #[test]
12422    fn autosave_resolve_all_invalid_returns_balanced() {
12423        assert_eq!(
12424            resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
12425            AutosaveDurabilityMode::Balanced
12426        );
12427    }
12428
12429    // --- Session-level: metrics accumulate across many save/flush cycles ---
12430
12431    #[test]
12432    fn autosave_session_metrics_accumulate_over_many_cycles() {
12433        let temp_dir = tempfile::tempdir().unwrap();
12434        let mut session = Session::create();
12435        session.session_dir = Some(temp_dir.path().to_path_buf());
12436
12437        let cycles: u64 = 10;
12438        for i in 0..cycles {
12439            session.append_message(make_test_message(&format!("cycle-{i}")));
12440            run_async(async { session.save().await }).unwrap();
12441        }
12442
12443        let m = session.autosave_metrics();
12444        assert_eq!(m.flush_started, cycles);
12445        assert_eq!(m.flush_succeeded, cycles);
12446        assert_eq!(m.flush_failed, 0);
12447        assert_eq!(m.pending_mutations, 0);
12448        assert_eq!(m.last_flush_batch_size, 1);
12449    }
12450
12451    // --- Queue: coalesced count is cumulative (not per-flush) ---
12452
12453    #[test]
12454    fn autosave_queue_coalesced_is_cumulative() {
12455        let mut queue = AutosaveQueue::with_limit(10);
12456
12457        // Batch 1: 3 mutations => 2 coalesced.
12458        queue.enqueue_mutation(AutosaveMutationKind::Message);
12459        queue.enqueue_mutation(AutosaveMutationKind::Message);
12460        queue.enqueue_mutation(AutosaveMutationKind::Message);
12461        assert_eq!(queue.coalesced_mutations, 2);
12462
12463        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12464        queue.finish_flush(t1, true);
12465
12466        // Batch 2: 2 mutations => 1 more coalesced (total 3).
12467        queue.enqueue_mutation(AutosaveMutationKind::Label);
12468        queue.enqueue_mutation(AutosaveMutationKind::Label);
12469        assert_eq!(queue.coalesced_mutations, 3);
12470    }
12471
12472    // --- Session-level: autosave_queue_limit changes batch size behavior ---
12473
12474    #[test]
12475    fn autosave_session_respects_queue_limit() {
12476        let temp_dir = tempfile::tempdir().unwrap();
12477        let mut session = Session::create();
12478        session.session_dir = Some(temp_dir.path().to_path_buf());
12479        session.set_autosave_queue_limit_for_test(3);
12480
12481        for i in 0..10 {
12482            session.append_message(make_test_message(&format!("lim-{i}")));
12483        }
12484
12485        let m = session.autosave_metrics();
12486        assert_eq!(m.pending_mutations, 3);
12487        assert_eq!(m.max_pending_mutations, 3);
12488        assert_eq!(m.backpressure_events, 7);
12489
12490        // Flush should only capture 3 (the capped count).
12491        run_async(async { session.save().await }).unwrap();
12492        let m = session.autosave_metrics();
12493        assert_eq!(m.last_flush_batch_size, 3);
12494        assert_eq!(m.pending_mutations, 0);
12495    }
12496
12497    // --- Session-level: throughput mode shutdown with successful prior manual save ---
12498
12499    #[test]
12500    fn autosave_session_throughput_shutdown_skips_after_manual_save() {
12501        let temp_dir = tempfile::tempdir().unwrap();
12502        let mut session = Session::create();
12503        session.session_dir = Some(temp_dir.path().to_path_buf());
12504        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
12505
12506        session.append_message(make_test_message("saved"));
12507        run_async(async { session.save().await }).unwrap();
12508        assert_eq!(session.autosave_metrics().flush_succeeded, 1);
12509
12510        // Add more mutations but don't save.
12511        session.append_message(make_test_message("unsaved"));
12512        assert_eq!(session.autosave_metrics().pending_mutations, 1);
12513
12514        // Shutdown skips flush in throughput mode.
12515        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12516        assert_eq!(
12517            session.autosave_metrics().pending_mutations,
12518            1,
12519            "unsaved mutation remains"
12520        );
12521        assert_eq!(
12522            session.autosave_metrics().flush_succeeded,
12523            1,
12524            "no new flush"
12525        );
12526    }
12527
12528    // --- Queue: begin_flush atomically clears pending ---
12529
12530    #[test]
12531    fn autosave_queue_begin_flush_is_atomic_clear() {
12532        let mut queue = AutosaveQueue::with_limit(10);
12533
12534        queue.enqueue_mutation(AutosaveMutationKind::Message);
12535        queue.enqueue_mutation(AutosaveMutationKind::Message);
12536        queue.enqueue_mutation(AutosaveMutationKind::Message);
12537        assert_eq!(queue.pending_mutations, 3);
12538
12539        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12540
12541        // Pending is immediately 0, even before finish_flush.
12542        assert_eq!(queue.pending_mutations, 0);
12543        assert_eq!(ticket.batch_size, 3);
12544
12545        // New mutations start fresh.
12546        queue.enqueue_mutation(AutosaveMutationKind::Label);
12547        assert_eq!(queue.pending_mutations, 1);
12548
12549        queue.finish_flush(ticket, true);
12550        assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
12551    }
12552
12553    // --- Queue: multiple failures accumulate flush_failed ---
12554
12555    #[test]
12556    fn autosave_queue_multiple_failures_accumulate() {
12557        let mut queue = AutosaveQueue::with_limit(10);
12558
12559        // Each round: enqueue 1 new + restored from prior failure.
12560        // Round 1: enqueue → pending=1, flush fails → restore 1 → pending=1
12561        // Round 2: enqueue → pending=2, flush fails → restore 2 → pending=2
12562        // Round N: pending grows by 1 each round because failures restore.
12563        for round in 1..=5_u64 {
12564            queue.enqueue_mutation(AutosaveMutationKind::Message);
12565            #[allow(clippy::cast_possible_truncation)]
12566            let expected_batch = round as usize;
12567            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12568            assert_eq!(ticket.batch_size, expected_batch);
12569            queue.finish_flush(ticket, false);
12570            assert_eq!(queue.flush_failed, round);
12571            assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
12572        }
12573        assert_eq!(queue.flush_succeeded, 0);
12574        assert_eq!(queue.flush_started, 5);
12575    }
12576
12577    // --- ExportSnapshot and non-blocking export ---
12578
12579    #[test]
12580    fn export_snapshot_captures_header_and_entries() {
12581        let mut session = Session::create();
12582        session.append_message(make_test_message("hello world"));
12583        session.append_message(make_test_message("second message"));
12584
12585        let snapshot = session.export_snapshot();
12586        assert_eq!(snapshot.header.id, session.header.id);
12587        assert_eq!(snapshot.header.timestamp, session.header.timestamp);
12588        assert_eq!(snapshot.header.cwd, session.header.cwd);
12589        assert_eq!(snapshot.entries.len(), session.entries.len());
12590        assert_eq!(snapshot.path, session.path);
12591    }
12592
12593    #[test]
12594    fn export_snapshot_does_not_include_internal_caches() {
12595        let mut session = Session::create();
12596        for i in 0..10 {
12597            session.append_message(make_test_message(&format!("msg {i}")));
12598        }
12599        // The snapshot should be lighter than a full Session clone because
12600        // it skips autosave_queue, entry_index, entry_ids, and other caches.
12601        let snapshot = session.export_snapshot();
12602        assert_eq!(snapshot.entries.len(), 10);
12603        // Verify the snapshot is a distinct copy (not sharing references).
12604        assert_eq!(snapshot.header.id, session.header.id);
12605    }
12606
12607    #[test]
12608    fn export_snapshot_html_matches_session_html() {
12609        let mut session = Session::create();
12610        session.append_message(make_test_message("hello"));
12611        session.append_message(make_test_message("world"));
12612
12613        let session_html = session.to_html();
12614        let snapshot_html = session.export_snapshot().to_html();
12615        assert_eq!(session_html, snapshot_html);
12616    }
12617
12618    #[test]
12619    fn export_snapshot_empty_session() {
12620        let session = Session::create();
12621        let snapshot = session.export_snapshot();
12622        assert!(snapshot.entries.is_empty());
12623        let html = snapshot.to_html();
12624        assert!(html.contains("Pi Session"));
12625        assert!(html.contains("</html>"));
12626    }
12627
12628    #[test]
12629    fn render_session_html_contains_header_info() {
12630        let mut session = Session::create();
12631        session.header.id = "test-session-id-xyz".to_string();
12632        session.header.cwd = "/test/cwd/path".to_string();
12633
12634        let html = render_session_html(&session.header, &session.entries);
12635        assert!(html.contains("test-session-id-xyz"));
12636        assert!(html.contains("/test/cwd/path"));
12637    }
12638
12639    #[test]
12640    fn render_session_html_renders_all_entry_types() {
12641        let mut session = Session::create();
12642
12643        // Message entry.
12644        session.append_message(make_test_message("user text here"));
12645
12646        // Model change entry.
12647        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
12648
12649        // Thinking level change entry.
12650        session.entries.push(SessionEntry::ThinkingLevelChange(
12651            ThinkingLevelChangeEntry {
12652                base: EntryBase::new(None, "tlc1".to_string()),
12653                thinking_level: "high".to_string(),
12654            },
12655        ));
12656
12657        let html = render_session_html(&session.header, &session.entries);
12658        assert!(html.contains("user text here"));
12659        assert!(html.contains("anthropic"));
12660        assert!(html.contains("claude-sonnet-4-5"));
12661        assert!(html.contains("high"));
12662    }
12663
12664    #[test]
12665    fn export_snapshot_with_path() {
12666        let mut session = Session::create();
12667        session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
12668        session.append_message(make_test_message("msg"));
12669
12670        let snapshot = session.export_snapshot();
12671        assert_eq!(
12672            snapshot.path.as_deref(),
12673            Some(Path::new("/tmp/my-session.jsonl"))
12674        );
12675    }
12676
12677    #[test]
12678    fn fork_plan_snapshot_consistency() {
12679        let mut session = Session::create();
12680        let msg1 = make_test_message("first message");
12681        session.append_message(msg1);
12682        let msg1_id = session.entries[0].base_id().unwrap().clone();
12683
12684        let msg2 = make_test_message("second message");
12685        session.append_message(msg2);
12686        let msg2_id = session.entries[1].base_id().unwrap().clone();
12687
12688        // Plan fork from the second message.
12689        let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
12690
12691        // Fork plan entries should include the path up to the parent.
12692        assert_eq!(plan.leaf_id, Some(msg1_id));
12693        // The plan captures a snapshot of entries — modifying session shouldn't affect plan.
12694        let plan_entry_count = plan.entries.len();
12695        session.append_message(make_test_message("third message"));
12696        assert_eq!(plan.entries.len(), plan_entry_count);
12697    }
12698}