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, StopReason, TextContent, ToolResultMessage,
13    UserContent, 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    pub(crate) 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    /// Revert only the *incomplete* trailing assistant output of a failed
3285    /// request, leaving the user prompt and every completed tool cycle intact.
3286    ///
3287    /// Unlike [`Self::revert_last_user_message`] (which abandons the whole turn
3288    /// back to and including the user message), this walks back from the leaf
3289    /// and reverts *only* trailing `Assistant` entries whose `stop_reason` is
3290    /// `Error` or `Aborted` — i.e. the partial/error message left behind when a
3291    /// transient connection drop kills a mid-turn request. It stops at the first
3292    /// entry that is anything else (a `User` prompt, a completed `ToolResult`,
3293    /// or a successful `Assistant`). This is the precondition for *resuming* a
3294    /// turn (`run_continue`) after a transient failure instead of *replaying* it
3295    /// from the user message, which would re-execute already-completed tool
3296    /// calls and re-bill prior work (pi_agent_rust#125).
3297    ///
3298    /// Returns whether any entry was reverted.
3299    pub fn revert_incomplete_response(&mut self) -> bool {
3300        let mut current_id = self.leaf_id.clone();
3301        let mut reverted_any = false;
3302
3303        while let Some(id) = current_id {
3304            let Some(entry) = self.get_entry(&id) else {
3305                break;
3306            };
3307            let is_incomplete_assistant = matches!(
3308                entry,
3309                SessionEntry::Message(msg_entry)
3310                    if matches!(
3311                        &msg_entry.message,
3312                        SessionMessage::Assistant { message }
3313                            if matches!(
3314                                message.stop_reason,
3315                                StopReason::Error | StopReason::Aborted
3316                            )
3317                    )
3318            );
3319            if !is_incomplete_assistant {
3320                // Reached the last completed state (user / tool result / success).
3321                break;
3322            }
3323
3324            let parent_id = entry.base().parent_id.clone();
3325            self.leaf_id.clone_from(&parent_id);
3326            self.is_linear = false;
3327            reverted_any = true;
3328            current_id = parent_id;
3329        }
3330
3331        if reverted_any {
3332            self.sync_navigation_state_to_header();
3333        }
3334        reverted_any
3335    }
3336
3337    /// Reset the leaf pointer to root (before any entries).
3338    ///
3339    /// After calling this, the next appended entry will become a new root entry
3340    /// (`parent_id = None`). This is used by interactive `/tree` navigation when
3341    /// re-editing the first user message.
3342    pub fn reset_leaf(&mut self) {
3343        self.leaf_id = None;
3344        self.is_linear = false;
3345        self.sync_navigation_state_to_header();
3346    }
3347
3348    /// Create a new branch starting from a specific entry.
3349    /// Sets the leaf_id to the specified entry so new entries branch from there.
3350    /// Returns true if the entry exists.
3351    pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
3352        self.navigate_to(entry_id)
3353    }
3354
3355    /// Get the entry at a specific ID (Gap B: O(1) via `entry_index`).
3356    pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
3357        self.entry_index
3358            .get(entry_id)
3359            .and_then(|&idx| self.entries.get(idx))
3360    }
3361
3362    /// Get the entry at a specific ID, mutable (Gap B: O(1) via `entry_index`).
3363    pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
3364        self.entry_index
3365            .get(entry_id)
3366            .copied()
3367            .and_then(|idx| self.entries.get_mut(idx))
3368    }
3369
3370    /// Entries along the current leaf path, in chronological order.
3371    ///
3372    /// Gap A: when `is_linear` is true (the 99% case — no branching has
3373    /// occurred), this returns all entries directly without building a
3374    /// parent map or tracing the path.
3375    pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
3376        let Some(leaf_id) = &self.leaf_id else {
3377            return Vec::new();
3378        };
3379
3380        // Fast path: linear session — all entries are on the current path.
3381        if self.is_linear {
3382            return self.entries.iter().collect();
3383        }
3384
3385        let mut path_indices = Vec::with_capacity(16);
3386        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3387        let mut current = Some(leaf_id.clone());
3388
3389        while let Some(id) = current.as_ref() {
3390            if !visited.insert(id.clone()) {
3391                tracing::warn!(
3392                    "Cycle detected in session tree while collecting current path entries at: {id}"
3393                );
3394                break;
3395            }
3396            let Some(&idx) = self.entry_index.get(id.as_str()) else {
3397                break;
3398            };
3399            let Some(entry) = self.entries.get(idx) else {
3400                break;
3401            };
3402            path_indices.push(idx);
3403            current.clone_from(&entry.base().parent_id);
3404        }
3405
3406        path_indices.reverse();
3407        path_indices
3408            .into_iter()
3409            .filter_map(|idx| self.entries.get(idx))
3410            .collect()
3411    }
3412
3413    /// Convert session entries along the current path to model messages.
3414    /// This follows parent_id links from leaf_id back to root.
3415    pub fn to_messages_for_current_path(&self) -> Vec<Message> {
3416        if self.leaf_id.is_none() {
3417            return Vec::new();
3418        }
3419
3420        if self.is_linear {
3421            return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
3422        }
3423
3424        let path_entries = self.entries_for_current_path();
3425        Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
3426    }
3427
3428    fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
3429        match entry {
3430            SessionEntry::Message(msg_entry) => {
3431                if let Some(message) = session_message_to_model(&msg_entry.message) {
3432                    messages.push(message);
3433                }
3434            }
3435            SessionEntry::BranchSummary(summary) => {
3436                let summary_message = SessionMessage::BranchSummary {
3437                    summary: summary.summary.clone(),
3438                    from_id: summary.from_id.clone(),
3439                };
3440                if let Some(message) = session_message_to_model(&summary_message) {
3441                    messages.push(message);
3442                }
3443            }
3444            _ => {}
3445        }
3446    }
3447
3448    fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
3449    where
3450        F: Fn(usize) -> &'a SessionEntry,
3451    {
3452        let mut last_compaction = None;
3453        for idx in (0..path_len).rev() {
3454            if let SessionEntry::Compaction(compaction) = entry_at(idx) {
3455                last_compaction = Some((idx, compaction));
3456                break;
3457            }
3458        }
3459
3460        if let Some((compaction_idx, compaction)) = last_compaction {
3461            let mut messages = Vec::with_capacity(path_len);
3462            let summary_message = SessionMessage::CompactionSummary {
3463                summary: compaction.summary.clone(),
3464                tokens_before: compaction.tokens_before,
3465            };
3466            if let Some(message) = session_message_to_model(&summary_message) {
3467                messages.push(message);
3468            }
3469
3470            let has_kept_entry = (0..path_len).any(|idx| {
3471                entry_at(idx)
3472                    .base_id()
3473                    .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3474            });
3475
3476            let mut keep = false;
3477            let mut past_compaction = false;
3478            for idx in 0..path_len {
3479                let entry = entry_at(idx);
3480                if idx.eq(&compaction_idx) {
3481                    past_compaction = true;
3482                }
3483                if !keep {
3484                    if has_kept_entry {
3485                        if entry
3486                            .base_id()
3487                            .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3488                        {
3489                            keep = true;
3490                        } else {
3491                            continue;
3492                        }
3493                    } else if past_compaction {
3494                        tracing::warn!(
3495                            first_kept_entry_id = %compaction.first_kept_entry_id,
3496                            "Compaction references missing entry; including all post-compaction entries"
3497                        );
3498                        keep = true;
3499                    } else {
3500                        continue;
3501                    }
3502                }
3503                Self::append_model_message_for_entry(&mut messages, entry);
3504            }
3505
3506            return messages;
3507        }
3508
3509        let mut messages = Vec::with_capacity(path_len);
3510        for idx in 0..path_len {
3511            Self::append_model_message_for_entry(&mut messages, entry_at(idx));
3512        }
3513        messages
3514    }
3515
3516    /// Find the nearest ancestor that is a fork point (has multiple children)
3517    /// and return its children (sibling branch roots). Each sibling is represented
3518    /// by its branch-root entry ID plus the leaf ID reachable from that root.
3519    ///
3520    /// Returns `(fork_point_id, sibling_leaves)` where each sibling leaf is
3521    /// a leaf entry ID reachable through the fork point's children. The current
3522    /// leaf is included in the list.
3523    pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
3524        let children_map = self.build_children_map();
3525        let leaf_id = self.leaf_id.as_ref()?;
3526        let path = self.get_path_to_entry(leaf_id);
3527        if path.is_empty() {
3528            return None;
3529        }
3530
3531        // Walk backwards from current leaf's path to find the nearest fork point.
3532        // A fork point is any entry whose parent has >1 children, OR None (root)
3533        // with >1 root entries.
3534        // We check each entry's parent to see if the parent has multiple children.
3535        for (idx, entry_id) in path.iter().enumerate().rev() {
3536            let parent_of_entry = self
3537                .get_entry(entry_id)
3538                .and_then(|e| e.base().parent_id.clone());
3539
3540            let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
3541                continue;
3542            };
3543
3544            if siblings_at_parent.len() > 1 {
3545                // This is a fork point. Collect all leaves reachable from each sibling.
3546                let mut branches = Vec::new();
3547                let current_branch_ids: HashSet<&str> =
3548                    path[idx..].iter().map(String::as_str).collect();
3549                for sibling_root in siblings_at_parent {
3550                    let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
3551                    let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
3552                    let is_current = current_branch_ids.contains(sibling_root.as_str());
3553                    branches.push(SiblingBranch {
3554                        root_id: sibling_root.clone(),
3555                        leaf_id: leaf,
3556                        preview,
3557                        message_count: msg_count,
3558                        is_current,
3559                    });
3560                }
3561                return Some((parent_of_entry, branches));
3562            }
3563        }
3564
3565        None
3566    }
3567
3568    /// Follow the first child chain to reach the deepest leaf from a starting entry.
3569    fn deepest_leaf_from(
3570        children_map: &HashMap<Option<String>, Vec<String>>,
3571        start_id: &str,
3572    ) -> String {
3573        let mut current = start_id.to_string();
3574        let mut visited = HashSet::new();
3575        loop {
3576            if !visited.insert(current.clone()) {
3577                tracing::warn!("Cycle detected in session tree at entry: {current}");
3578                return current;
3579            }
3580            let children = children_map.get(&Some(current.clone()));
3581            match children.and_then(|c| c.first()) {
3582                Some(child) => current.clone_from(child),
3583                None => return current,
3584            }
3585        }
3586    }
3587
3588    /// Compute a short preview (first user message on the path) and the number
3589    /// of message entries for a leaf in a single parent-chain walk.
3590    fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
3591        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3592        let mut current = Some(leaf_id.to_string());
3593        let mut preview = None;
3594        let mut count = 0usize;
3595
3596        while let Some(id) = current.as_ref() {
3597            if !visited.insert(id.clone()) {
3598                tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
3599                break;
3600            }
3601            let Some(entry) = self.get_entry(id.as_str()) else {
3602                break;
3603            };
3604            if matches!(entry, SessionEntry::Message(_)) {
3605                count = count.saturating_add(1);
3606            }
3607            if let SessionEntry::Message(msg) = entry {
3608                if let SessionMessage::User { content, .. } = &msg.message {
3609                    let text = user_content_to_text(content);
3610                    let trimmed = text.trim();
3611                    if !trimmed.is_empty() {
3612                        preview = Some(if trimmed.chars().count() > 60 {
3613                            let truncated: String = trimmed.chars().take(57).collect();
3614                            format!("{truncated}...")
3615                        } else {
3616                            trimmed.to_string()
3617                        });
3618                    }
3619                }
3620            }
3621            current.clone_from(&entry.base().parent_id);
3622        }
3623
3624        (preview.unwrap_or_else(|| String::from("(empty)")), count)
3625    }
3626
3627    /// Get a summary of branches in this session.
3628    pub fn branch_summary(&self) -> BranchInfo {
3629        let leaves = self.list_leaves();
3630        let children_map = self.build_children_map();
3631
3632        // Find branch points (entries with multiple children)
3633        let branch_points: Vec<String> = self
3634            .entries
3635            .iter()
3636            .filter_map(|e| {
3637                let id = e.base_id()?;
3638                let children = children_map.get(&Some(id.clone()))?;
3639                if children.len() > 1 {
3640                    Some(id.clone())
3641                } else {
3642                    None
3643                }
3644            })
3645            .collect();
3646
3647        BranchInfo {
3648            total_entries: self.entries.len(),
3649            leaf_count: leaves.len(),
3650            branch_point_count: branch_points.len(),
3651            current_leaf: self.leaf_id.clone(),
3652            leaves,
3653            branch_points,
3654        }
3655    }
3656
3657    /// Add a label to an entry.
3658    pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
3659        // Verify target exists
3660        self.get_entry(target_id)?;
3661
3662        let id = self.next_entry_id();
3663        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
3664        let entry = SessionEntry::Label(LabelEntry {
3665            base,
3666            target_id: target_id.to_string(),
3667            label,
3668        });
3669        self.leaf_id = Some(id.clone());
3670        self.entries.push(entry);
3671        self.entry_index.insert(id.clone(), self.entries.len() - 1);
3672        self.entry_ids.insert(id.clone());
3673        self.clear_persisted_leaf_override_after_append();
3674        self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
3675        Some(id)
3676    }
3677}
3678
3679/// Summary of branches in a session.
3680#[derive(Debug, Clone)]
3681pub struct BranchInfo {
3682    pub total_entries: usize,
3683    pub leaf_count: usize,
3684    pub branch_point_count: usize,
3685    pub current_leaf: Option<String>,
3686    pub leaves: Vec<String>,
3687    pub branch_points: Vec<String>,
3688}
3689
3690/// A sibling branch at a fork point.
3691#[derive(Debug, Clone)]
3692pub struct SiblingBranch {
3693    /// Entry ID of the branch root (child of the fork point).
3694    pub root_id: String,
3695    /// Leaf entry ID reachable from this branch root.
3696    pub leaf_id: String,
3697    /// Short preview of the first user message on this branch.
3698    pub preview: String,
3699    /// Number of message entries along the path.
3700    pub message_count: usize,
3701    /// Whether the current session leaf is on this branch.
3702    pub is_current: bool,
3703}
3704
3705#[derive(Debug, Clone)]
3706struct SessionPickEntry {
3707    path: PathBuf,
3708    id: String,
3709    cwd: String,
3710    timestamp: String,
3711    message_count: u64,
3712    name: Option<String>,
3713    last_modified_ms: i64,
3714    size_bytes: u64,
3715}
3716
3717impl SessionPickEntry {
3718    fn from_meta(meta: crate::session_index::SessionMeta) -> Self {
3719        Self {
3720            path: PathBuf::from(meta.path),
3721            id: meta.id,
3722            cwd: meta.cwd,
3723            timestamp: meta.timestamp,
3724            message_count: meta.message_count,
3725            name: meta.name,
3726            last_modified_ms: meta.last_modified_ms,
3727            size_bytes: meta.size_bytes,
3728        }
3729    }
3730
3731    fn to_meta(&self) -> crate::session_index::SessionMeta {
3732        crate::session_index::SessionMeta {
3733            path: self.path.display().to_string(),
3734            id: self.id.clone(),
3735            cwd: self.cwd.clone(),
3736            timestamp: self.timestamp.clone(),
3737            message_count: self.message_count,
3738            last_modified_ms: self.last_modified_ms,
3739            size_bytes: self.size_bytes,
3740            name: self.name.clone(),
3741        }
3742    }
3743}
3744
3745fn indexed_session_path_is_missing(path: &Path) -> bool {
3746    match path.try_exists() {
3747        Ok(exists) => !exists,
3748        Err(err) => {
3749            tracing::warn!(
3750                path = %path.display(),
3751                error = %err,
3752                "Failed to determine whether indexed session path exists; deferring prune"
3753            );
3754            false
3755        }
3756    }
3757}
3758
3759fn split_indexed_session_entries(
3760    metas: Vec<crate::session_index::SessionMeta>,
3761) -> (Vec<SessionPickEntry>, Vec<PathBuf>) {
3762    let mut entries = Vec::new();
3763    let mut missing_paths = Vec::new();
3764
3765    for meta in metas {
3766        let path = PathBuf::from(&meta.path);
3767        if indexed_session_path_is_missing(&path) {
3768            missing_paths.push(path);
3769            continue;
3770        }
3771
3772        entries.push(SessionPickEntry::from_meta(meta));
3773    }
3774
3775    (entries, missing_paths)
3776}
3777
3778fn prune_session_index_path(index: &SessionIndex, path: &Path, reason: &'static str) {
3779    if let Err(err) = index.delete_session_path(path) {
3780        tracing::warn!(
3781            path = %path.display(),
3782            error = %err,
3783            reason,
3784            "Failed to prune session from index"
3785        );
3786    }
3787}
3788
3789fn can_reuse_known_entry(known_entry: &SessionPickEntry, disk_ms: i64, disk_size: u64) -> bool {
3790    (known_entry.last_modified_ms, known_entry.size_bytes)
3791        .cmp(&(disk_ms, disk_size))
3792        .is_eq()
3793}
3794
3795struct ScanSessionsResult {
3796    entries: Vec<SessionPickEntry>,
3797    refreshed_entries: Vec<SessionPickEntry>,
3798    failed_paths: Vec<PathBuf>,
3799}
3800
3801fn refresh_session_index_entries(
3802    index: &SessionIndex,
3803    entries: &[SessionPickEntry],
3804    reason: &'static str,
3805) {
3806    for entry in entries {
3807        if let Err(err) = index.upsert_session_meta(entry.to_meta()) {
3808            tracing::warn!(
3809                path = %entry.path.display(),
3810                error = %err,
3811                reason,
3812                "Failed to refresh session metadata in index"
3813            );
3814        }
3815    }
3816}
3817
3818fn merge_scanned_session_entries(
3819    by_path: &mut HashMap<PathBuf, SessionPickEntry>,
3820    entries: Vec<SessionPickEntry>,
3821) {
3822    for entry in entries {
3823        // Disk is the source of truth for session metadata. The scan either
3824        // reparsed the file or confirmed the cached snapshot still matches, so
3825        // it should always win over the earlier index view for that path.
3826        by_path.insert(entry.path.clone(), entry);
3827    }
3828}
3829
3830async fn scan_sessions_on_disk(
3831    project_session_dir: &Path,
3832    known: Vec<SessionPickEntry>,
3833) -> Result<ScanSessionsResult> {
3834    let path_buf = project_session_dir.to_path_buf();
3835    let (tx, mut rx) = oneshot::channel();
3836
3837    let handle = thread::Builder::new()
3838        .name("session-scan".to_string())
3839        .spawn(move || {
3840            let res = (|| -> Result<ScanSessionsResult> {
3841                let mut entries = Vec::new();
3842                let mut refreshed_entries = Vec::new();
3843                let mut failed_paths = Vec::new();
3844                let dir_entries = std::fs::read_dir(&path_buf)
3845                    .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
3846
3847                let known_map: HashMap<PathBuf, SessionPickEntry> =
3848                    known.into_iter().map(|e| (e.path.clone(), e)).collect();
3849
3850                for entry in dir_entries {
3851                    let entry =
3852                        entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
3853                    let path = entry.path();
3854                    if is_session_file_path(&path) {
3855                        // Optimization: if we already have this file indexed and both mtime and
3856                        // size match, reuse indexed metadata to avoid a full parse.
3857                        if let Ok((disk_ms, disk_size)) = session_file_stats(&path) {
3858                            if let Some(known_entry) = known_map.get(&path) {
3859                                if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
3860                                    entries.push(known_entry.clone());
3861                                    continue;
3862                                }
3863                            }
3864                        }
3865
3866                        match load_session_meta(&path) {
3867                            Ok(meta) => {
3868                                refreshed_entries.push(meta.clone());
3869                                entries.push(meta);
3870                            }
3871                            Err(_) => failed_paths.push(path),
3872                        }
3873                    }
3874                }
3875                Ok(ScanSessionsResult {
3876                    entries,
3877                    refreshed_entries,
3878                    failed_paths,
3879                })
3880            })();
3881            let cx = AgentCx::for_request();
3882            let _ = tx.send(cx.cx(), res);
3883        })
3884        .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
3885
3886    let cx = AgentCx::for_request();
3887    let recv_result = rx.recv(cx.cx()).await;
3888    finish_worker_result(handle, recv_result, "Scan task cancelled")
3889}
3890
3891fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
3892    match path.extension().and_then(|ext| ext.to_str()) {
3893        Some("jsonl") => load_session_meta_jsonl(path),
3894        #[cfg(feature = "sqlite-sessions")]
3895        Some("sqlite") => load_session_meta_sqlite(path),
3896        _ => Err(Error::session(format!(
3897            "Unsupported session file extension: {}",
3898            path.display()
3899        ))),
3900    }
3901}
3902
3903#[derive(Deserialize)]
3904struct PartialEntry {
3905    #[serde(default)]
3906    r#type: String,
3907    #[serde(default)]
3908    name: Option<String>,
3909}
3910
3911fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
3912    let file = std::fs::File::open(path)
3913        .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
3914    let mut reader = BufReader::new(file);
3915
3916    let Some(header_line) = read_capped_utf8_line(&mut reader)
3917        .map_err(|e| Error::session(format!("Failed to read header: {e}")))?
3918    else {
3919        return Err(Error::session("Empty session file"));
3920    };
3921
3922    let header: SessionHeader =
3923        serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
3924    header
3925        .validate()
3926        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3927
3928    let mut message_count = 0u64;
3929    let mut name = None;
3930    loop {
3931        let Some(line_content) = read_capped_utf8_line(&mut reader)
3932            .map_err(|e| Error::session(format!("Failed to read session entry: {e}")))?
3933        else {
3934            break;
3935        };
3936        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
3937            match entry.r#type.as_str() {
3938                "message" => message_count += 1,
3939                "session_info" if entry.name.is_some() => {
3940                    name = entry.name;
3941                }
3942                _ => {}
3943            }
3944        }
3945    }
3946
3947    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3948
3949    Ok(SessionPickEntry {
3950        path: path.to_path_buf(),
3951        id: header.id,
3952        cwd: header.cwd,
3953        timestamp: header.timestamp,
3954        message_count,
3955        name,
3956        last_modified_ms,
3957        size_bytes,
3958    })
3959}
3960
3961#[cfg(feature = "sqlite-sessions")]
3962fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
3963    let meta = futures::executor::block_on(async {
3964        crate::session_sqlite::load_session_meta(path).await
3965    })?;
3966    let header = meta.header;
3967    header
3968        .validate()
3969        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3970
3971    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3972
3973    Ok(SessionPickEntry {
3974        path: path.to_path_buf(),
3975        id: header.id,
3976        cwd: header.cwd,
3977        timestamp: header.timestamp,
3978        message_count: meta.message_count,
3979        name: meta.name,
3980        last_modified_ms,
3981        size_bytes,
3982    })
3983}
3984
3985// ============================================================================
3986// Session Header
3987// ============================================================================
3988
3989/// Session file header.
3990#[derive(Debug, Clone, Serialize, Deserialize)]
3991#[serde(rename_all = "camelCase")]
3992pub struct SessionHeader {
3993    pub r#type: String,
3994    #[serde(skip_serializing_if = "Option::is_none")]
3995    pub version: Option<u8>,
3996    pub id: String,
3997    pub timestamp: String,
3998    pub cwd: String,
3999    #[serde(skip_serializing_if = "Option::is_none")]
4000    pub provider: Option<String>,
4001    #[serde(skip_serializing_if = "Option::is_none")]
4002    pub model_id: Option<String>,
4003    #[serde(skip_serializing_if = "Option::is_none")]
4004    pub thinking_level: Option<String>,
4005    #[serde(skip_serializing_if = "Option::is_none")]
4006    pub fallback_provider: Option<String>,
4007    #[serde(skip_serializing_if = "Option::is_none")]
4008    pub fallback_model_id: Option<String>,
4009    #[serde(skip_serializing_if = "Option::is_none")]
4010    pub fallback_thinking_level: Option<String>,
4011    #[serde(skip_serializing_if = "Option::is_none", rename = "leafId")]
4012    pub current_leaf: Option<String>,
4013    #[serde(
4014        skip_serializing_if = "Option::is_none",
4015        rename = "branchedFrom",
4016        alias = "parentSession"
4017    )]
4018    pub parent_session: Option<String>,
4019}
4020
4021impl SessionHeader {
4022    pub fn new() -> Self {
4023        let now = chrono::Utc::now();
4024        Self {
4025            r#type: "session".to_string(),
4026            version: Some(SESSION_VERSION),
4027            id: uuid::Uuid::new_v4().to_string(),
4028            timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4029            cwd: std::env::current_dir()
4030                .map(|p| p.display().to_string())
4031                .unwrap_or_default(),
4032            provider: None,
4033            model_id: None,
4034            thinking_level: None,
4035            fallback_provider: None,
4036            fallback_model_id: None,
4037            fallback_thinking_level: None,
4038            current_leaf: None,
4039            parent_session: None,
4040        }
4041    }
4042
4043    fn branch_fallback_model_fields(&self) -> (Option<String>, Option<String>) {
4044        (
4045            self.fallback_provider
4046                .clone()
4047                .or_else(|| self.provider.clone()),
4048            self.fallback_model_id
4049                .clone()
4050                .or_else(|| self.model_id.clone()),
4051        )
4052    }
4053
4054    fn materialize_branch_fallbacks(&mut self) -> bool {
4055        // Track mutations as booleans, then materialize them.
4056        // This pattern avoids clippy::useless_let_if_seq while
4057        // remaining readable for multiple independent conditions.
4058        let set_provider = self.fallback_provider.is_none() && self.provider.is_some();
4059        let set_model_id = self.fallback_model_id.is_none() && self.model_id.is_some();
4060        let set_thinking = self.fallback_thinking_level.is_none() && self.thinking_level.is_some();
4061
4062        if set_provider {
4063            self.fallback_provider = self.provider.clone();
4064        }
4065        if set_model_id {
4066            self.fallback_model_id = self.model_id.clone();
4067        }
4068        if set_thinking {
4069            self.fallback_thinking_level = self.thinking_level.clone();
4070        }
4071
4072        set_provider || set_model_id || set_thinking
4073    }
4074
4075    pub fn validate(&self) -> std::result::Result<(), String> {
4076        if !self.r#type.eq("session") {
4077            return Err(format!("type must be `session`, got `{}`", self.r#type));
4078        }
4079        if !self.version.eq(&Some(SESSION_VERSION)) {
4080            return Err(format!(
4081                "version must be {SESSION_VERSION}, got {}",
4082                self.version
4083                    .map_or_else(|| "none".to_string(), |value| value.to_string())
4084            ));
4085        }
4086        if self.id.trim().is_empty() {
4087            return Err("id must be non-empty".to_string());
4088        }
4089        if self.timestamp.trim().is_empty() {
4090            return Err("timestamp must be non-empty".to_string());
4091        }
4092        if self.cwd.trim().is_empty() {
4093            return Err("cwd must be non-empty".to_string());
4094        }
4095        Ok(())
4096    }
4097
4098    pub fn is_valid(&self) -> bool {
4099        self.validate().is_ok()
4100    }
4101}
4102
4103impl Default for SessionHeader {
4104    fn default() -> Self {
4105        Self::new()
4106    }
4107}
4108
4109// ============================================================================
4110// Session Entries
4111// ============================================================================
4112
4113/// A session entry.
4114#[derive(Debug, Clone, Serialize, Deserialize)]
4115#[serde(tag = "type", rename_all = "snake_case")]
4116pub enum SessionEntry {
4117    Message(MessageEntry),
4118    ModelChange(ModelChangeEntry),
4119    ThinkingLevelChange(ThinkingLevelChangeEntry),
4120    Compaction(CompactionEntry),
4121    BranchSummary(BranchSummaryEntry),
4122    Label(LabelEntry),
4123    SessionInfo(SessionInfoEntry),
4124    Custom(CustomEntry),
4125}
4126
4127impl SessionEntry {
4128    pub const fn base(&self) -> &EntryBase {
4129        match self {
4130            Self::Message(e) => &e.base,
4131            Self::ModelChange(e) => &e.base,
4132            Self::ThinkingLevelChange(e) => &e.base,
4133            Self::Compaction(e) => &e.base,
4134            Self::BranchSummary(e) => &e.base,
4135            Self::Label(e) => &e.base,
4136            Self::SessionInfo(e) => &e.base,
4137            Self::Custom(e) => &e.base,
4138        }
4139    }
4140
4141    pub const fn base_mut(&mut self) -> &mut EntryBase {
4142        match self {
4143            Self::Message(e) => &mut e.base,
4144            Self::ModelChange(e) => &mut e.base,
4145            Self::ThinkingLevelChange(e) => &mut e.base,
4146            Self::Compaction(e) => &mut e.base,
4147            Self::BranchSummary(e) => &mut e.base,
4148            Self::Label(e) => &mut e.base,
4149            Self::SessionInfo(e) => &mut e.base,
4150            Self::Custom(e) => &mut e.base,
4151        }
4152    }
4153
4154    pub const fn base_id(&self) -> Option<&String> {
4155        self.base().id.as_ref()
4156    }
4157}
4158
4159/// Base entry fields.
4160#[derive(Debug, Clone, Serialize, Deserialize)]
4161#[serde(rename_all = "camelCase")]
4162pub struct EntryBase {
4163    #[serde(skip_serializing_if = "Option::is_none")]
4164    pub id: Option<String>,
4165    #[serde(skip_serializing_if = "Option::is_none")]
4166    pub parent_id: Option<String>,
4167    pub timestamp: String,
4168}
4169
4170impl EntryBase {
4171    pub fn new(parent_id: Option<String>, id: String) -> Self {
4172        Self {
4173            id: Some(id),
4174            parent_id,
4175            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4176        }
4177    }
4178}
4179
4180/// Message entry.
4181#[derive(Debug, Clone, Serialize, Deserialize)]
4182#[serde(rename_all = "camelCase")]
4183pub struct MessageEntry {
4184    #[serde(flatten)]
4185    pub base: EntryBase,
4186    pub message: SessionMessage,
4187}
4188
4189/// Session message payload.
4190#[derive(Debug, Clone, Serialize, Deserialize)]
4191#[serde(
4192    tag = "role",
4193    rename_all = "camelCase",
4194    rename_all_fields = "camelCase"
4195)]
4196pub enum SessionMessage {
4197    User {
4198        content: UserContent,
4199        #[serde(skip_serializing_if = "Option::is_none")]
4200        timestamp: Option<i64>,
4201    },
4202    Assistant {
4203        #[serde(flatten)]
4204        message: AssistantMessage,
4205    },
4206    ToolResult {
4207        tool_call_id: String,
4208        tool_name: String,
4209        content: Vec<ContentBlock>,
4210        #[serde(skip_serializing_if = "Option::is_none")]
4211        details: Option<Value>,
4212        #[serde(default)]
4213        is_error: bool,
4214        #[serde(skip_serializing_if = "Option::is_none")]
4215        timestamp: Option<i64>,
4216    },
4217    Custom {
4218        custom_type: String,
4219        content: String,
4220        #[serde(default)]
4221        display: bool,
4222        #[serde(skip_serializing_if = "Option::is_none")]
4223        details: Option<Value>,
4224        #[serde(skip_serializing_if = "Option::is_none")]
4225        timestamp: Option<i64>,
4226    },
4227    BashExecution {
4228        command: String,
4229        output: String,
4230        exit_code: i32,
4231        #[serde(skip_serializing_if = "Option::is_none")]
4232        cancelled: Option<bool>,
4233        #[serde(skip_serializing_if = "Option::is_none")]
4234        truncated: Option<bool>,
4235        #[serde(skip_serializing_if = "Option::is_none")]
4236        full_output_path: Option<String>,
4237        #[serde(skip_serializing_if = "Option::is_none")]
4238        timestamp: Option<i64>,
4239        #[serde(flatten)]
4240        extra: HashMap<String, Value>,
4241    },
4242    BranchSummary {
4243        summary: String,
4244        from_id: String,
4245    },
4246    CompactionSummary {
4247        summary: String,
4248        tokens_before: u64,
4249    },
4250}
4251
4252impl From<Message> for SessionMessage {
4253    fn from(message: Message) -> Self {
4254        match message {
4255            Message::User(user) => Self::User {
4256                content: user.content,
4257                timestamp: Some(user.timestamp),
4258            },
4259            Message::Assistant(assistant) => Self::Assistant {
4260                message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
4261            },
4262            Message::ToolResult(result) => {
4263                let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
4264                Self::ToolResult {
4265                    tool_call_id: result.tool_call_id,
4266                    tool_name: result.tool_name,
4267                    content: result.content,
4268                    details: result.details,
4269                    is_error: result.is_error,
4270                    timestamp: Some(result.timestamp),
4271                }
4272            }
4273            Message::Custom(custom) => Self::Custom {
4274                custom_type: custom.custom_type,
4275                content: custom.content,
4276                display: custom.display,
4277                details: custom.details,
4278                timestamp: Some(custom.timestamp),
4279            },
4280        }
4281    }
4282}
4283
4284/// Model change entry.
4285#[derive(Debug, Clone, Serialize, Deserialize)]
4286#[serde(rename_all = "camelCase")]
4287pub struct ModelChangeEntry {
4288    #[serde(flatten)]
4289    pub base: EntryBase,
4290    pub provider: String,
4291    pub model_id: String,
4292}
4293
4294/// Thinking level change entry.
4295#[derive(Debug, Clone, Serialize, Deserialize)]
4296#[serde(rename_all = "camelCase")]
4297pub struct ThinkingLevelChangeEntry {
4298    #[serde(flatten)]
4299    pub base: EntryBase,
4300    pub thinking_level: String,
4301}
4302
4303/// Compaction entry.
4304#[derive(Debug, Clone, Serialize, Deserialize)]
4305#[serde(rename_all = "camelCase")]
4306pub struct CompactionEntry {
4307    #[serde(flatten)]
4308    pub base: EntryBase,
4309    pub summary: String,
4310    pub first_kept_entry_id: String,
4311    pub tokens_before: u64,
4312    #[serde(skip_serializing_if = "Option::is_none")]
4313    pub details: Option<serde_json::Value>,
4314    #[serde(skip_serializing_if = "Option::is_none")]
4315    pub from_hook: Option<bool>,
4316}
4317
4318/// Branch summary entry.
4319#[derive(Debug, Clone, Serialize, Deserialize)]
4320#[serde(rename_all = "camelCase")]
4321pub struct BranchSummaryEntry {
4322    #[serde(flatten)]
4323    pub base: EntryBase,
4324    pub from_id: String,
4325    pub summary: String,
4326    #[serde(skip_serializing_if = "Option::is_none")]
4327    pub details: Option<serde_json::Value>,
4328    #[serde(skip_serializing_if = "Option::is_none")]
4329    pub from_hook: Option<bool>,
4330}
4331
4332/// Label entry.
4333#[derive(Debug, Clone, Serialize, Deserialize)]
4334#[serde(rename_all = "camelCase")]
4335pub struct LabelEntry {
4336    #[serde(flatten)]
4337    pub base: EntryBase,
4338    pub target_id: String,
4339    #[serde(skip_serializing_if = "Option::is_none")]
4340    pub label: Option<String>,
4341}
4342
4343/// Session info entry.
4344#[derive(Debug, Clone, Serialize, Deserialize)]
4345#[serde(rename_all = "camelCase")]
4346pub struct SessionInfoEntry {
4347    #[serde(flatten)]
4348    pub base: EntryBase,
4349    #[serde(skip_serializing_if = "Option::is_none")]
4350    pub name: Option<String>,
4351}
4352
4353/// Custom entry.
4354#[derive(Debug, Clone, Serialize, Deserialize)]
4355#[serde(rename_all = "camelCase")]
4356pub struct CustomEntry {
4357    #[serde(flatten)]
4358    pub base: EntryBase,
4359    pub custom_type: String,
4360    #[serde(skip_serializing_if = "Option::is_none")]
4361    pub data: Option<serde_json::Value>,
4362}
4363
4364// ============================================================================
4365// Utilities
4366// ============================================================================
4367
4368/// Encode a working directory path for use in session directory names.
4369pub fn encode_cwd(path: &std::path::Path) -> String {
4370    let s = path.display().to_string();
4371    let s = s.trim_start_matches(['/', '\\']);
4372    let s = s.replace(['/', '\\', ':'], "-");
4373    format!("--{s}--")
4374}
4375
4376fn infer_session_root_from_path(path: &Path) -> Option<PathBuf> {
4377    let parent = path.parent()?.to_path_buf();
4378    if parent
4379        .file_name()
4380        .and_then(|name| name.to_str())
4381        .is_some_and(|name| name.starts_with("--") && name.ends_with("--") && name.len() > 4)
4382    {
4383        return parent.parent().map(PathBuf::from).or(Some(parent));
4384    }
4385    Some(parent)
4386}
4387
4388pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
4389    match message {
4390        SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
4391            content: content.clone(),
4392            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4393        })),
4394        SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
4395        SessionMessage::ToolResult {
4396            tool_call_id,
4397            tool_name,
4398            content,
4399            details,
4400            is_error,
4401            timestamp,
4402        } => Some(Message::tool_result(ToolResultMessage {
4403            tool_call_id: tool_call_id.clone(),
4404            tool_name: tool_name.clone(),
4405            content: content.clone(),
4406            details: details.clone(),
4407            is_error: *is_error,
4408            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4409        })),
4410        SessionMessage::Custom {
4411            custom_type,
4412            content,
4413            display,
4414            details,
4415            timestamp,
4416        } => Some(Message::Custom(crate::model::CustomMessage {
4417            content: content.clone(),
4418            custom_type: custom_type.clone(),
4419            display: *display,
4420            details: details.clone(),
4421            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4422        })),
4423        SessionMessage::BashExecution {
4424            command,
4425            output,
4426            exit_code,
4427            cancelled,
4428            truncated,
4429            full_output_path,
4430            timestamp,
4431            extra,
4432        } => {
4433            if extra
4434                .get("excludeFromContext")
4435                .and_then(Value::as_bool)
4436                .is_some_and(|v| v)
4437            {
4438                return None;
4439            }
4440            let text = bash_execution_to_text(
4441                command,
4442                output,
4443                *exit_code,
4444                cancelled.unwrap_or(false),
4445                truncated.unwrap_or(false),
4446                full_output_path.as_deref(),
4447            );
4448            Some(Message::User(UserMessage {
4449                content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
4450                timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4451            }))
4452        }
4453        SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
4454            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4455                "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
4456            )))]),
4457            timestamp: chrono::Utc::now().timestamp_millis(),
4458        })),
4459        SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
4460            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4461                "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
4462            )))]),
4463            timestamp: chrono::Utc::now().timestamp_millis(),
4464        })),
4465    }
4466}
4467
4468const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
4469const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
4470
4471const BRANCH_SUMMARY_PREFIX: &str =
4472    "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
4473const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
4474
4475pub(crate) fn bash_execution_to_text(
4476    command: &str,
4477    output: &str,
4478    exit_code: i32,
4479    cancelled: bool,
4480    truncated: bool,
4481    full_output_path: Option<&str>,
4482) -> String {
4483    let mut text = format!("Ran `{command}`\n");
4484    if output.is_empty() {
4485        text.push_str("(no output)");
4486    } else {
4487        text.push_str("```\n");
4488        text.push_str(output);
4489        if !output.ends_with('\n') {
4490            text.push('\n');
4491        }
4492        text.push_str("```");
4493    }
4494
4495    if cancelled {
4496        text.push_str("\n\n(command cancelled)");
4497    } else if exit_code != 0 {
4498        let _ = write!(text, "\n\nCommand exited with code {exit_code}");
4499    }
4500
4501    if truncated {
4502        if let Some(path) = full_output_path {
4503            let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
4504        } else {
4505            text.push_str("\n\n[Output truncated]");
4506        }
4507    }
4508
4509    text
4510}
4511
4512/// Render session header and entries as a standalone HTML document.
4513///
4514/// Shared implementation used by both `Session::to_html()` and
4515/// `ExportSnapshot::to_html()`.
4516#[allow(clippy::too_many_lines)]
4517fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
4518    let mut html = String::new();
4519    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
4520    html.push_str("<title>Pi Session</title>");
4521    html.push_str("<style>");
4522    html.push_str(
4523        "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
4524            h1{margin:0 0 8px 0;}
4525            .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
4526            .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
4527            .msg.user{border-left:4px solid #4fc3f7;}
4528            .msg.assistant{border-left:4px solid #81c784;}
4529            .msg.tool{border-left:4px solid #ffb74d;}
4530            .msg.system{border-left:4px solid #ef9a9a;}
4531            .role{font-weight:600;margin-bottom:8px;}
4532            pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
4533            .thinking summary{cursor:pointer;}
4534            img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
4535            .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
4536            ",
4537    );
4538    html.push_str("</style></head><body>");
4539
4540    let _ = write!(
4541        html,
4542        "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
4543        escape_html(&header.id),
4544        escape_html(&header.timestamp),
4545        escape_html(&header.cwd)
4546    );
4547
4548    for entry in entries {
4549        match entry {
4550            SessionEntry::Message(message) => {
4551                html.push_str(&render_session_message(&message.message));
4552            }
4553            SessionEntry::ModelChange(change) => {
4554                let _ = write!(
4555                    html,
4556                    "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
4557                    escape_html(&change.provider),
4558                    escape_html(&change.model_id)
4559                );
4560            }
4561            SessionEntry::ThinkingLevelChange(change) => {
4562                let _ = write!(
4563                    html,
4564                    "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
4565                    escape_html(&change.thinking_level)
4566                );
4567            }
4568            SessionEntry::Compaction(compaction) => {
4569                let _ = write!(
4570                    html,
4571                    "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4572                    escape_html(&compaction.summary)
4573                );
4574            }
4575            SessionEntry::BranchSummary(summary) => {
4576                let _ = write!(
4577                    html,
4578                    "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4579                    escape_html(&summary.summary)
4580                );
4581            }
4582            SessionEntry::SessionInfo(info) => {
4583                if let Some(name) = &info.name {
4584                    let _ = write!(
4585                        html,
4586                        "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
4587                        escape_html(name)
4588                    );
4589                }
4590            }
4591            SessionEntry::Custom(custom) => {
4592                let _ = write!(
4593                    html,
4594                    "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
4595                    escape_html(&custom.custom_type)
4596                );
4597            }
4598            SessionEntry::Label(_) => {}
4599        }
4600    }
4601
4602    html.push_str("</body></html>");
4603    html
4604}
4605
4606fn render_session_message(message: &SessionMessage) -> String {
4607    match message {
4608        SessionMessage::User { content, .. } => {
4609            let mut html = String::new();
4610            html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
4611            html.push_str(&render_user_content(content));
4612            html.push_str("</div>");
4613            html
4614        }
4615        SessionMessage::Assistant { message } => {
4616            let mut html = String::new();
4617            html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
4618            html.push_str(&render_blocks(&message.content));
4619            html.push_str("</div>");
4620            html
4621        }
4622        SessionMessage::ToolResult {
4623            tool_name,
4624            content,
4625            is_error,
4626            details,
4627            ..
4628        } => {
4629            let mut html = String::new();
4630            let role = if *is_error { "Tool Error" } else { "Tool" };
4631            let _ = write!(
4632                html,
4633                "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
4634                role,
4635                escape_html(tool_name)
4636            );
4637            html.push_str(&render_blocks(content));
4638            if let Some(details) = details {
4639                let details_str =
4640                    serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
4641                let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
4642            }
4643            html.push_str("</div>");
4644            html
4645        }
4646        SessionMessage::Custom {
4647            custom_type,
4648            content,
4649            ..
4650        } => {
4651            let mut html = String::new();
4652            let _ = write!(
4653                html,
4654                "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
4655                escape_html(custom_type),
4656                escape_html(content)
4657            );
4658            html
4659        }
4660        SessionMessage::BashExecution {
4661            command,
4662            output,
4663            exit_code,
4664            ..
4665        } => {
4666            let mut html = String::new();
4667            let _ = write!(
4668                html,
4669                "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
4670                escape_html(command),
4671                escape_html(output)
4672            );
4673            html
4674        }
4675        SessionMessage::BranchSummary { summary, .. } => {
4676            format!(
4677                "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4678                escape_html(summary)
4679            )
4680        }
4681        SessionMessage::CompactionSummary { summary, .. } => {
4682            format!(
4683                "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4684                escape_html(summary)
4685            )
4686        }
4687    }
4688}
4689
4690fn render_user_content(content: &UserContent) -> String {
4691    match content {
4692        UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
4693        UserContent::Blocks(blocks) => render_blocks(blocks),
4694    }
4695}
4696
4697fn render_blocks(blocks: &[ContentBlock]) -> String {
4698    let mut html = String::new();
4699    for block in blocks {
4700        match block {
4701            ContentBlock::Text(text) => {
4702                let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
4703            }
4704            ContentBlock::Thinking(thinking) => {
4705                let _ = write!(
4706                    html,
4707                    "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
4708                    escape_html(&thinking.thinking)
4709                );
4710            }
4711            ContentBlock::Image(image) => {
4712                let _ = write!(
4713                    html,
4714                    "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
4715                    escape_html(&image.mime_type),
4716                    escape_html(&image.data)
4717                );
4718            }
4719            ContentBlock::ToolCall(tool_call) => {
4720                let args = serde_json::to_string_pretty(&tool_call.arguments)
4721                    .unwrap_or_else(|_| tool_call.arguments.to_string());
4722                let _ = write!(
4723                    html,
4724                    "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
4725                    escape_html(&tool_call.name),
4726                    escape_html(&args)
4727                );
4728            }
4729            ContentBlock::RedactedThinking(_) => {
4730                // Render a compact placeholder so transcript readers can tell
4731                // a redacted-thinking block was present without exposing the
4732                // opaque payload (which is provider-internal safety state).
4733                html.push_str(
4734                    "<details class=\"thinking\"><summary>Thinking (redacted)</summary></details>",
4735                );
4736            }
4737        }
4738    }
4739    html
4740}
4741
4742fn escape_html(input: &str) -> String {
4743    let mut escaped = String::with_capacity(input.len());
4744    for ch in input.chars() {
4745        match ch {
4746            '&' => escaped.push_str("&amp;"),
4747            '<' => escaped.push_str("&lt;"),
4748            '>' => escaped.push_str("&gt;"),
4749            '"' => escaped.push_str("&quot;"),
4750            '\'' => escaped.push_str("&#39;"),
4751            _ => escaped.push(ch),
4752        }
4753    }
4754    escaped
4755}
4756
4757fn user_content_to_text(content: &UserContent) -> String {
4758    match content {
4759        UserContent::Text(text) => text.clone(),
4760        UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
4761    }
4762}
4763
4764fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
4765    let mut output = String::new();
4766    for block in blocks {
4767        match block {
4768            ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
4769            ContentBlock::Image(image) => {
4770                push_line(&mut output, &format!("[image: {}]", image.mime_type));
4771            }
4772            ContentBlock::Thinking(thinking_block) => {
4773                push_line(&mut output, &thinking_block.thinking);
4774            }
4775            ContentBlock::ToolCall(call) => {
4776                push_line(&mut output, &format!("[tool call: {}]", call.name));
4777            }
4778            ContentBlock::RedactedThinking(_) => {
4779                push_line(&mut output, "[thinking: redacted]");
4780            }
4781        }
4782    }
4783    output
4784}
4785
4786fn push_line(out: &mut String, line: &str) {
4787    if !out.is_empty() {
4788        out.push('\n');
4789    }
4790    out.push_str(line);
4791}
4792
4793fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
4794    entries
4795        .iter()
4796        .filter_map(|e| e.base_id().cloned())
4797        .collect()
4798}
4799
4800/// Minimum entry count to activate parallel deserialization (Gap E).
4801const PARALLEL_THRESHOLD: usize = 512;
4802/// Number of JSONL lines deserialized per batch in the blocking open path.
4803const JSONL_PARSE_BATCH_SIZE: usize = 8192;
4804
4805/// Parse a JSONL session file on the current (blocking) thread.
4806///
4807/// Combines Gap E (parallel deserialization) and Gap F (single-pass
4808/// finalization) for the fastest possible open path.
4809#[allow(clippy::too_many_lines)]
4810fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4811    let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
4812    let mut reader = std::io::BufReader::new(file);
4813
4814    let Some(header_line) =
4815        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4816    else {
4817        return Err(crate::Error::session("Empty session file"));
4818    };
4819    if header_line.trim().is_empty() {
4820        return Err(crate::Error::session("Empty session file"));
4821    }
4822
4823    // Parse header (first line)
4824    let header: SessionHeader = serde_json::from_str(&header_line)
4825        .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
4826    header
4827        .validate()
4828        .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
4829    let (header, normalized_header_dirty) = normalize_loaded_header(header);
4830
4831    let mut entries = Vec::new();
4832    let mut diagnostics = SessionOpenDiagnostics::default();
4833
4834    // Gap E: parallel deserialization for large sessions.
4835    // Batch processing to bound memory usage while allowing parallelism.
4836    let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
4837
4838    let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
4839    let mut current_line_num = 2; // Header is line 1
4840
4841    loop {
4842        line_batch.clear();
4843        let mut batch_eof = false;
4844
4845        for _ in 0..JSONL_PARSE_BATCH_SIZE {
4846            match read_capped_utf8_line(&mut reader) {
4847                Ok(None) => {
4848                    batch_eof = true;
4849                    break;
4850                }
4851                Ok(Some(line)) => {
4852                    if !line.trim().is_empty() {
4853                        line_batch.push((current_line_num, line));
4854                    }
4855                }
4856                Err(e) => {
4857                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4858                        line_number: current_line_num,
4859                        error: format!("IO error reading line: {e}"),
4860                    });
4861                }
4862            }
4863            current_line_num += 1;
4864        }
4865
4866        if line_batch.is_empty() {
4867            if batch_eof {
4868                break;
4869            }
4870            continue;
4871        }
4872
4873        if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
4874            let chunk_size = (line_batch.len() / num_threads).max(64);
4875
4876            let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
4877                std::thread::scope(|s| {
4878                    line_batch
4879                        .chunks(chunk_size)
4880                        .map(|chunk| {
4881                            s.spawn(move || {
4882                                let mut ok = Vec::with_capacity(chunk.len());
4883                                let mut skip = Vec::new();
4884                                for (line_num, line) in chunk {
4885                                    match serde_json::from_str::<SessionEntry>(line) {
4886                                        Ok(entry) => ok.push(entry),
4887                                        Err(e) => {
4888                                            skip.push(SessionOpenSkippedEntry {
4889                                                line_number: *line_num,
4890                                                error: e.to_string(),
4891                                            });
4892                                        }
4893                                    }
4894                                }
4895                                (ok, skip)
4896                            })
4897                        })
4898                        .collect::<Vec<_>>()
4899                        .into_iter()
4900                        .map(|h| {
4901                            h.join().map_err(|panic_payload| {
4902                                let panic_message =
4903                                    panic_payload.downcast_ref::<String>().map_or_else(
4904                                        || {
4905                                            panic_payload.downcast_ref::<&str>().map_or_else(
4906                                                || "unknown panic payload".to_string(),
4907                                                |message| (*message).to_string(),
4908                                            )
4909                                        },
4910                                        std::clone::Clone::clone,
4911                                    );
4912                                Error::session(format!(
4913                                    "parallel session parse worker panicked: {panic_message}"
4914                                ))
4915                            })
4916                        })
4917                        .collect()
4918                });
4919            let chunk_results = chunk_results?;
4920
4921            for (chunk_entries, chunk_skipped) in chunk_results {
4922                entries.extend(chunk_entries);
4923                diagnostics.skipped_entries.extend(chunk_skipped);
4924            }
4925        } else {
4926            // Sequential path
4927            for (line_num, line) in &line_batch {
4928                match serde_json::from_str::<SessionEntry>(line) {
4929                    Ok(entry) => entries.push(entry),
4930                    Err(e) => {
4931                        diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4932                            line_number: *line_num,
4933                            error: e.to_string(),
4934                        });
4935                    }
4936                }
4937            }
4938        }
4939
4940        if batch_eof {
4941            break;
4942        }
4943    }
4944
4945    // --- Single-pass load finalization (Gap F) ---
4946    let finalized = finalize_loaded_entries(&mut entries);
4947    for orphan in &finalized.orphans {
4948        diagnostics
4949            .orphaned_parent_links
4950            .push(SessionOpenOrphanedParentLink {
4951                entry_id: orphan.0.clone(),
4952                missing_parent_id: orphan.1.clone(),
4953            });
4954    }
4955
4956    let entry_count = entries.len();
4957    let natural_leaf_id = finalized.leaf_id.clone();
4958    let leaf_id = resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
4959
4960    Ok((
4961        Session {
4962            header,
4963            entries,
4964            path: Some(path_buf),
4965            leaf_id: leaf_id.clone(),
4966            session_dir: None,
4967            store_kind: SessionStoreKind::Jsonl,
4968            entry_ids: finalized.entry_ids,
4969            is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
4970            entry_index: finalized.entry_index,
4971            cached_message_count: finalized.message_count,
4972            cached_name: finalized.name,
4973            autosave_queue: AutosaveQueue::new(),
4974            autosave_durability: AutosaveDurabilityMode::from_env(),
4975            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
4976            header_dirty: normalized_header_dirty,
4977            appends_since_checkpoint: 0,
4978            v2_sidecar_root: None,
4979            v2_partial_hydration: false,
4980            v2_resume_mode: None,
4981            v2_sidecar_stale: false,
4982            v2_message_count_offset: 0,
4983        },
4984        diagnostics,
4985    ))
4986}
4987
4988/// Open a session from its V2 sidecar store.
4989///
4990/// Reads the JSONL header (first line) for `SessionHeader`, then loads
4991/// entries from the V2 segment store via its offset index — O(index + tail)
4992/// instead of the O(n) full-file parse that `open_jsonl_blocking` performs.
4993#[allow(clippy::too_many_lines)]
4994fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4995    // 1. Read JSONL header (first line only).
4996    let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4997    let mut reader = BufReader::new(file);
4998    let Some(header_line) =
4999        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5000    else {
5001        return Err(crate::Error::session("Empty JSONL session file"));
5002    };
5003    let header: SessionHeader = serde_json::from_str(header_line.trim())
5004        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5005    header.validate().map_err(|reason| {
5006        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5007    })?;
5008
5009    // 2. Open V2 sidecar store.
5010    let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
5011    let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
5012
5013    // 3. Choose an explicit hydration strategy for resume:
5014    // - env override (PI_SESSION_V2_OPEN_MODE)
5015    // - auto lazy mode for large sessions
5016    let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
5017    let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
5018    if let Some(raw) = mode_override_raw.as_deref() {
5019        if parse_v2_open_mode(raw).is_none() {
5020            tracing::warn!(
5021                value = %raw,
5022                "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
5023            );
5024        }
5025    }
5026    if let Some(raw) = threshold_override_raw.as_deref() {
5027        if raw.trim().parse::<u64>().is_err() {
5028            tracing::warn!(
5029                value = %raw,
5030                "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
5031            );
5032        }
5033    }
5034
5035    let entry_count = store.entry_count();
5036    let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
5037        entry_count,
5038        mode_override_raw.as_deref(),
5039        threshold_override_raw.as_deref(),
5040    );
5041    let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
5042        && entry_count > 0
5043        && store.head().is_none()
5044    {
5045        tracing::warn!(
5046            entry_count,
5047            "active-path hydration selected but store has no head; falling back to full hydration"
5048        );
5049        V2OpenMode::Full
5050    } else {
5051        selected_mode
5052    };
5053    tracing::debug!(
5054        entry_count,
5055        lazy_threshold,
5056        selection_reason,
5057        ?mode,
5058        "selected V2 resume hydration mode"
5059    );
5060
5061    // 4. Load entries using the selected mode.
5062    let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
5063    session.path = Some(jsonl_path);
5064    session.v2_sidecar_root = Some(v2_root);
5065    session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
5066    session.v2_resume_mode = Some(mode);
5067    Ok((session, diagnostics))
5068}
5069
5070/// Create a V2 sidecar store from an existing JSONL session file.
5071///
5072/// This is the migration path: parse the full JSONL once and write each entry
5073/// into the V2 segmented store with offset index. Subsequent opens can then
5074/// use `open_from_v2_store_blocking` for O(index+tail) resume.
5075pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
5076    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5077    if !v2_root.exists() {
5078        return build_v2_sidecar_from_jsonl_into(jsonl_path, &v2_root);
5079    }
5080
5081    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5082    let _staged_store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5083        Ok(store) => store,
5084        Err(err) => {
5085            let _ = cleanup_sidecar_root(&staging_root);
5086            return Err(err);
5087        }
5088    };
5089
5090    let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5091    if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5092        let _ = cleanup_sidecar_root(&staging_root);
5093        return Err(crate::Error::Io(Box::new(err)));
5094    }
5095
5096    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5097        let _ = std::fs::rename(&backup_root, &v2_root);
5098        let _ = cleanup_sidecar_root(&staging_root);
5099        return Err(crate::Error::Io(Box::new(err)));
5100    }
5101
5102    if let Err(err) = cleanup_sidecar_root(&backup_root) {
5103        tracing::warn!(
5104            path = %backup_root.display(),
5105            error = %err,
5106            "create_v2_sidecar_from_jsonl left backup sidecar after successful swap"
5107        );
5108    }
5109
5110    SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)
5111}
5112
5113fn build_v2_sidecar_from_jsonl_into(jsonl_path: &Path, v2_root: &Path) -> Result<SessionStoreV2> {
5114    let build_result = (|| -> Result<SessionStoreV2> {
5115        let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5116        let mut reader = std::io::BufReader::new(file);
5117
5118        let header_line = read_capped_utf8_line(&mut reader)
5119            .map_err(|e| crate::Error::Io(Box::new(e)))?
5120            .filter(|l| !l.trim().is_empty())
5121            .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
5122
5123        let header: SessionHeader = serde_json::from_str(header_line.trim())
5124            .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5125        header.validate().map_err(|reason| {
5126            crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5127        })?;
5128
5129        if v2_root.exists() {
5130            std::fs::remove_dir_all(v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5131        }
5132        let mut store = SessionStoreV2::create(v2_root, 64 * 1024 * 1024)?;
5133
5134        loop {
5135            let Some(line) =
5136                read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5137            else {
5138                break;
5139            };
5140            if line.trim().is_empty() {
5141                continue;
5142            }
5143            let entry: SessionEntry = serde_json::from_str(&line)
5144                .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5145            let (entry_id, parent_entry_id, entry_type, payload) =
5146                session_store_v2::session_entry_to_frame_args(&entry)?;
5147            store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5148        }
5149
5150        store.write_manifest(header.id, "jsonl")?;
5151
5152        Ok(store)
5153    })();
5154
5155    if build_result.is_err() && v2_root.exists() {
5156        let _ = std::fs::remove_dir_all(v2_root);
5157    }
5158
5159    build_result
5160}
5161
5162fn unique_sidecar_aux_path(v2_root: &Path, suffix: &str) -> PathBuf {
5163    let file_name = v2_root
5164        .file_name()
5165        .and_then(|name| name.to_str())
5166        .unwrap_or("session.v2");
5167    v2_root.with_file_name(format!(
5168        "{file_name}.{suffix}.{}",
5169        uuid::Uuid::new_v4().simple()
5170    ))
5171}
5172
5173fn cleanup_sidecar_root(path: &Path) -> Result<()> {
5174    if path.exists() {
5175        std::fs::remove_dir_all(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5176    }
5177    Ok(())
5178}
5179
5180/// Migrate a JSONL session to V2 with full verification and event logging.
5181///
5182/// Returns the `MigrationEvent` that was recorded in the V2 store's migration
5183/// ledger. The migration is atomic: if verification fails, the sidecar is
5184/// removed and an error is returned.
5185pub fn migrate_jsonl_to_v2(
5186    jsonl_path: &Path,
5187    correlation_id: &str,
5188) -> Result<session_store_v2::MigrationEvent> {
5189    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5190    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5191    let store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5192        Ok(store) => store,
5193        Err(err) => {
5194            let _ = cleanup_sidecar_root(&staging_root);
5195            return Err(err);
5196        }
5197    };
5198
5199    // Verify fidelity.
5200    let verification = match verify_v2_against_jsonl(jsonl_path, &store) {
5201        Ok(verification) => verification,
5202        Err(err) => {
5203            let _ = cleanup_sidecar_root(&staging_root);
5204            return Err(err);
5205        }
5206    };
5207
5208    if !(verification.entry_count_match
5209        && verification.hash_chain_match
5210        && verification.index_consistent)
5211    {
5212        // Verification failed — remove the sidecar.
5213        cleanup_sidecar_root(&staging_root)?;
5214        return Err(crate::Error::session(format!(
5215            "V2 migration verification failed: count={} hash={} index={}",
5216            verification.entry_count_match,
5217            verification.hash_chain_match,
5218            verification.index_consistent,
5219        )));
5220    }
5221
5222    let event = session_store_v2::MigrationEvent {
5223        schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5224        migration_id: uuid::Uuid::new_v4().to_string(),
5225        phase: "forward".to_string(),
5226        at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5227        source_path: jsonl_path.display().to_string(),
5228        target_path: session_store_v2::v2_sidecar_path(jsonl_path)
5229            .display()
5230            .to_string(),
5231        source_format: "jsonl_v3".to_string(),
5232        target_format: "native_v2".to_string(),
5233        verification,
5234        outcome: "ok".to_string(),
5235        error_class: None,
5236        correlation_id: correlation_id.to_string(),
5237    };
5238    if let Err(err) = store.append_migration_event(event.clone()) {
5239        let _ = cleanup_sidecar_root(&staging_root);
5240        return Err(err);
5241    }
5242
5243    let backup_root = if v2_root.exists() {
5244        let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5245        if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5246            let _ = cleanup_sidecar_root(&staging_root);
5247            return Err(crate::Error::Io(Box::new(err)));
5248        }
5249        Some(backup_root)
5250    } else {
5251        None
5252    };
5253
5254    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5255        if let Some(backup_root) = backup_root.as_ref() {
5256            let _ = std::fs::rename(backup_root, &v2_root);
5257        }
5258        let _ = cleanup_sidecar_root(&staging_root);
5259        return Err(crate::Error::Io(Box::new(err)));
5260    }
5261
5262    if let Some(backup_root) = backup_root {
5263        if let Err(err) = cleanup_sidecar_root(&backup_root) {
5264            tracing::warn!(
5265                path = %backup_root.display(),
5266                error = %err,
5267                "V2 migration left backup sidecar after successful swap"
5268            );
5269        }
5270    }
5271
5272    Ok(event)
5273}
5274
5275/// Verify a V2 sidecar against its source JSONL for fidelity.
5276///
5277/// Compares entry count, entry IDs in order, and validates the V2 store's
5278/// internal integrity (checksums + hash chain).
5279pub fn verify_v2_against_jsonl(
5280    jsonl_path: &Path,
5281    store: &SessionStoreV2,
5282) -> Result<session_store_v2::MigrationVerification> {
5283    // Parse all JSONL entries (skip header).
5284    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5285    let mut reader = std::io::BufReader::new(file);
5286
5287    let Some(header_line) =
5288        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5289    else {
5290        return Err(crate::Error::session("Empty JSONL session file"));
5291    };
5292    if header_line.trim().is_empty() {
5293        return Err(crate::Error::session("Empty JSONL session file"));
5294    }
5295
5296    let header: SessionHeader = serde_json::from_str(header_line.trim())
5297        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5298    header.validate().map_err(|reason| {
5299        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5300    })?;
5301
5302    let mut jsonl_ids: Vec<String> = Vec::new();
5303    let mut jsonl_chain_hash = V2_CHAIN_HASH_GENESIS.to_string();
5304
5305    loop {
5306        let Some(line) =
5307            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5308        else {
5309            break;
5310        };
5311        if line.trim().is_empty() {
5312            continue;
5313        }
5314        let entry: SessionEntry = serde_json::from_str(&line)
5315            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5316        let id = entry
5317            .base_id()
5318            .cloned()
5319            .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
5320        jsonl_ids.push(id);
5321        jsonl_chain_hash = session_entry_chain_hash_step(&jsonl_chain_hash, &entry)?;
5322    }
5323
5324    // Read V2 store entries.
5325    let frames = store.read_all_entries()?;
5326    let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
5327
5328    let entry_count_match = jsonl_ids.len().eq(&v2_ids.len()) && jsonl_ids.eq(&v2_ids);
5329
5330    // Check hash chain via validate_integrity (which also verifies checksums).
5331    let index_consistent = store.validate_integrity().is_ok();
5332
5333    let hash_chain_match = jsonl_chain_hash.eq(store.chain_hash());
5334
5335    Ok(session_store_v2::MigrationVerification {
5336        entry_count_match,
5337        hash_chain_match,
5338        index_consistent,
5339    })
5340}
5341
5342fn is_v2_sidecar_stale(jsonl_path: &Path, v2_root: &Path) -> bool {
5343    let Some(jsonl_meta) = std::fs::metadata(jsonl_path).ok() else {
5344        return true;
5345    };
5346
5347    let v2_index = v2_root.join("index").join("offsets.jsonl");
5348    let v2_manifest = v2_root.join("manifest.json");
5349    let Some(v2_meta) = std::fs::metadata(&v2_index)
5350        .or_else(|_| std::fs::metadata(&v2_manifest))
5351        .ok()
5352    else {
5353        return true;
5354    };
5355
5356    let Some(jsonl_mtime) = jsonl_meta.modified().ok() else {
5357        return true;
5358    };
5359    let Some(v2_mtime) = v2_meta.modified().ok() else {
5360        return true;
5361    };
5362
5363    jsonl_mtime > v2_mtime
5364}
5365
5366fn session_entry_chain_hash_step(prev_chain: &str, entry: &SessionEntry) -> Result<String> {
5367    let (_, _, _, payload) = session_store_v2::session_entry_to_frame_args(entry)?;
5368    let payload_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&payload)?));
5369    let mut hasher = Sha256::new();
5370    hasher.update(prev_chain.as_bytes());
5371    hasher.update(payload_sha256.as_bytes());
5372    Ok(format!("{:x}", hasher.finalize()))
5373}
5374
5375/// Remove a V2 sidecar, reverting to JSONL-only storage.
5376///
5377/// Logs a rollback event in the migration ledger before removing the sidecar.
5378/// Returns `Ok(())` if the sidecar was removed (or didn't exist).
5379pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
5380    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5381    if !v2_root.exists() {
5382        return Ok(());
5383    }
5384
5385    // Try to log the rollback event before deleting.
5386    if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5387        let event = session_store_v2::MigrationEvent {
5388            schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5389            migration_id: uuid::Uuid::new_v4().to_string(),
5390            phase: "rollback_to_jsonl".to_string(),
5391            at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5392            source_path: v2_root.display().to_string(),
5393            target_path: jsonl_path.display().to_string(),
5394            source_format: "native_v2".to_string(),
5395            target_format: "jsonl_v3".to_string(),
5396            verification: session_store_v2::MigrationVerification {
5397                entry_count_match: true,
5398                hash_chain_match: true,
5399                index_consistent: true,
5400            },
5401            outcome: "ok".to_string(),
5402            error_class: None,
5403            correlation_id: correlation_id.to_string(),
5404        };
5405        let _ = store.append_migration_event(event);
5406    }
5407
5408    std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5409    Ok(())
5410}
5411
5412/// Current migration state of a JSONL session.
5413#[derive(Debug, Clone, PartialEq, Eq)]
5414pub enum MigrationState {
5415    /// No V2 sidecar exists — pure JSONL.
5416    Unmigrated,
5417    /// V2 sidecar exists and passes integrity validation.
5418    Migrated,
5419    /// V2 sidecar exists but fails integrity validation.
5420    Corrupt { error: String },
5421    /// V2 sidecar directory exists but is missing critical files (partial write).
5422    Partial,
5423}
5424
5425/// Query the migration state of a JSONL session file.
5426pub fn migration_status(jsonl_path: &Path) -> MigrationState {
5427    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5428    if !v2_root.exists() {
5429        return MigrationState::Unmigrated;
5430    }
5431
5432    let segments_dir = v2_root.join("segments");
5433    if !segments_dir.exists() {
5434        return MigrationState::Partial;
5435    }
5436
5437    let index_path = v2_root.join("index").join("offsets.jsonl");
5438    if !index_path.exists() {
5439        match jsonl_has_entry_lines(jsonl_path) {
5440            Ok(true) => return MigrationState::Partial,
5441            Ok(false) => {}
5442            Err(e) => {
5443                return MigrationState::Corrupt {
5444                    error: e.to_string(),
5445                };
5446            }
5447        }
5448    }
5449
5450    let inspector = match SessionStoreV2::open_for_inspection(&v2_root, 64 * 1024 * 1024) {
5451        Ok(store) => store,
5452        Err(e) => {
5453            return MigrationState::Corrupt {
5454                error: e.to_string(),
5455            };
5456        }
5457    };
5458
5459    match inspector.read_index() {
5460        Ok(_) => match inspector.validate_integrity() {
5461            Ok(()) => MigrationState::Migrated,
5462            Err(e) => MigrationState::Corrupt {
5463                error: e.to_string(),
5464            },
5465        },
5466        Err(e) if migration_status_can_rebuild_index(&e) => {
5467            match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5468                Ok(store) => match verify_v2_against_jsonl(jsonl_path, &store) {
5469                    Ok(verification)
5470                        if verification.entry_count_match
5471                            && verification.hash_chain_match
5472                            && verification.index_consistent =>
5473                    {
5474                        MigrationState::Migrated
5475                    }
5476                    Ok(verification) => MigrationState::Corrupt {
5477                        error: format!(
5478                            "migration verification failed after index rebuild: count={} hash={} index={}",
5479                            verification.entry_count_match,
5480                            verification.hash_chain_match,
5481                            verification.index_consistent,
5482                        ),
5483                    },
5484                    Err(err) => MigrationState::Corrupt {
5485                        error: err.to_string(),
5486                    },
5487                },
5488                Err(err) => MigrationState::Corrupt {
5489                    error: err.to_string(),
5490                },
5491            }
5492        }
5493        Err(e) => MigrationState::Corrupt {
5494            error: e.to_string(),
5495        },
5496    }
5497}
5498
5499fn migration_status_can_rebuild_index(error: &Error) -> bool {
5500    match error {
5501        Error::Json(_) => true,
5502        Error::Io(err) => matches!(
5503            err.kind(),
5504            std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::InvalidData
5505        ),
5506        _ => false,
5507    }
5508}
5509
5510/// Dry-run a JSONL → V2 migration without persisting the sidecar.
5511///
5512/// Creates the V2 store in a temporary directory, runs verification, then
5513/// cleans up. Returns the verification result so callers can inspect
5514/// entry counts and integrity before committing.
5515pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
5516    let tmp_dir =
5517        tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
5518    let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
5519
5520    // Parse JSONL and populate a temporary V2 store.
5521    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5522    let mut reader = std::io::BufReader::new(file);
5523
5524    let Some(header_line) =
5525        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5526    else {
5527        return Err(crate::Error::session("Empty JSONL session file"));
5528    };
5529
5530    let header: SessionHeader = serde_json::from_str(header_line.trim_end())
5531        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5532    header.validate().map_err(|reason| {
5533        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5534    })?;
5535
5536    let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
5537
5538    loop {
5539        let Some(line) =
5540            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5541        else {
5542            break;
5543        };
5544        if line.trim().is_empty() {
5545            continue;
5546        }
5547        let entry: SessionEntry = serde_json::from_str(line.trim_end())
5548            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5549        let (entry_id, parent_entry_id, entry_type, payload) =
5550            session_store_v2::session_entry_to_frame_args(&entry)?;
5551        store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5552    }
5553
5554    // Verify against source JSONL (but using the temp store).
5555    verify_v2_against_jsonl(jsonl_path, &store)
5556    // tmp_dir drops here → auto-cleanup
5557}
5558
5559/// Recover from a partial or corrupted V2 migration.
5560///
5561/// If the sidecar is in a partial/corrupt state, removes it and optionally
5562/// re-runs the migration. Returns the final migration state.
5563pub fn recover_partial_migration(
5564    jsonl_path: &Path,
5565    correlation_id: &str,
5566    re_migrate: bool,
5567) -> Result<MigrationState> {
5568    let status = migration_status(jsonl_path);
5569    match &status {
5570        MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
5571        MigrationState::Partial | MigrationState::Corrupt { .. } => {
5572            // Remove the broken sidecar.
5573            let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5574            if v2_root.exists() {
5575                std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5576            }
5577
5578            if re_migrate {
5579                migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
5580                Ok(MigrationState::Migrated)
5581            } else {
5582                Ok(MigrationState::Unmigrated)
5583            }
5584        }
5585    }
5586}
5587
5588fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
5589    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5590    let mut reader = std::io::BufReader::new(file);
5591
5592    let Some(_line) =
5593        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5594    else {
5595        return Err(crate::Error::session("Empty JSONL session file"));
5596    };
5597
5598    loop {
5599        let Some(line) =
5600            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5601        else {
5602            return Ok(false);
5603        };
5604        if !line.trim().is_empty() {
5605            return Ok(true);
5606        }
5607    }
5608}
5609
5610/// Result of single-pass load finalization (Gap F).
5611///
5612/// Replaces the previous multi-pass approach (`ensure_entry_ids` +
5613/// `entry_id_set` + orphan detection + stats) with a single O(n) scan
5614/// that produces all required caches at once.
5615struct LoadFinalization {
5616    leaf_id: Option<String>,
5617    entry_ids: HashSet<String>,
5618    entry_index: HashMap<String, usize>,
5619    message_count: u64,
5620    name: Option<String>,
5621    is_linear: bool,
5622    orphans: Vec<(String, String)>,
5623}
5624
5625/// Single-pass finalization of loaded entries.
5626///
5627/// 1. Assigns IDs to entries missing them (`ensure_entry_ids` work).
5628/// 2. Builds `entry_ids` set and `entry_index` map.
5629/// 3. Detects orphaned parent links.
5630/// 4. Computes `session_entry_stats` (message count + name).
5631/// 5. Determines `is_linear` (no branching, leaf == last entry).
5632fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
5633    // First pass: assign missing IDs (same logic as `ensure_entry_ids`).
5634    let mut entry_ids: HashSet<String> = entries
5635        .iter()
5636        .filter_map(|e| e.base_id().cloned())
5637        .collect();
5638    for entry in entries.iter_mut() {
5639        if entry.base().id.is_none() {
5640            let id = generate_entry_id(&entry_ids);
5641            entry.base_mut().id = Some(id.clone());
5642            entry_ids.insert(id);
5643        }
5644    }
5645
5646    // Second (main) pass: build all caches in one scan.
5647    let mut entry_index = HashMap::with_capacity(entries.len());
5648    let mut message_count = 0u64;
5649    let mut name: Option<String> = None;
5650    let mut leaf_id: Option<String> = None;
5651    let mut orphans = Vec::new();
5652    // Track parent_ids seen as children's parent to detect branching.
5653    let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
5654    let mut has_branching = false;
5655    let mut root_count = 0u32;
5656
5657    for (idx, entry) in entries.iter().enumerate() {
5658        let Some(id) = entry.base_id() else {
5659            continue;
5660        };
5661        entry_index.insert(id.clone(), idx);
5662        leaf_id = Some(id.clone());
5663
5664        // Orphan detection.
5665        if let Some(parent_id) = entry.base().parent_id.as_ref() {
5666            if !entry_ids.contains(parent_id) {
5667                orphans.push((id.clone(), parent_id.clone()));
5668            }
5669        } else {
5670            root_count += 1;
5671        }
5672
5673        // Branch detection: if any parent_id has >1 child, it's branched.
5674        if !has_branching {
5675            let parent_key = entry.base().parent_id.as_deref();
5676            let count = parent_id_child_count.entry(parent_key).or_insert(0);
5677            *count += 1;
5678            if *count > 1 {
5679                has_branching = true;
5680            }
5681        }
5682
5683        // Stats.
5684        match entry {
5685            SessionEntry::Message(_) => message_count += 1,
5686            SessionEntry::SessionInfo(info) if info.name.is_some() => {
5687                name.clone_from(&info.name);
5688            }
5689            _ => {}
5690        }
5691    }
5692
5693    // is_linear: no branching detected in the entry set, exactly one root, and no orphans.
5694    // Note: callers (e.g. rebuild_all_caches) add the additional check that
5695    // self.leaf_id == finalized.leaf_id to confirm we're at the tip.
5696    let is_linear = !has_branching && root_count <= 1 && orphans.is_empty();
5697
5698    LoadFinalization {
5699        leaf_id,
5700        entry_ids,
5701        entry_index,
5702        message_count,
5703        name,
5704        is_linear,
5705        orphans,
5706    }
5707}
5708
5709fn parse_env_bool(value: &str) -> bool {
5710    matches!(
5711        value.trim().to_ascii_lowercase().as_str(),
5712        "1" | "true" | "yes" | "on"
5713    )
5714}
5715
5716fn session_entry_id_cache_enabled() -> bool {
5717    static ENABLED: OnceLock<bool> = OnceLock::new();
5718    *ENABLED.get_or_init(|| {
5719        std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
5720    })
5721}
5722
5723#[cfg(test)]
5724fn ensure_entry_ids(entries: &mut [SessionEntry]) {
5725    let mut existing = entry_id_set(entries);
5726    for entry in entries.iter_mut() {
5727        if entry.base().id.is_none() {
5728            let id = generate_entry_id(&existing);
5729            entry.base_mut().id = Some(id.clone());
5730            existing.insert(id);
5731        }
5732    }
5733}
5734
5735/// Generate a unique entry ID (8 hex characters), falling back to UUID on collision.
5736fn generate_entry_id(existing: &HashSet<String>) -> String {
5737    for _ in 0..100 {
5738        let uuid = uuid::Uuid::new_v4();
5739        let id = uuid.simple().to_string()[..8].to_string();
5740        if !existing.contains(&id) {
5741            return id;
5742        }
5743    }
5744    uuid::Uuid::new_v4().to_string()
5745}
5746
5747#[cfg(test)]
5748type SetNameDeadlineProbe = Option<(String, std::sync::mpsc::Sender<Option<asupersync::Time>>)>;
5749
5750#[cfg(test)]
5751fn set_name_deadline_probe() -> &'static std::sync::Mutex<SetNameDeadlineProbe> {
5752    static PROBE: std::sync::OnceLock<std::sync::Mutex<SetNameDeadlineProbe>> =
5753        std::sync::OnceLock::new();
5754    PROBE.get_or_init(|| std::sync::Mutex::new(None))
5755}
5756
5757#[cfg(test)]
5758fn emit_set_name_deadline_probe(session_id: &str, deadline: Option<asupersync::Time>) {
5759    let probe = set_name_deadline_probe();
5760    let guard = probe.lock().expect("lock set_name deadline probe");
5761    if let Some((target_session_id, tx)) = guard.as_ref() {
5762        if target_session_id.eq(session_id) {
5763            let _ = tx.send(deadline);
5764        }
5765    }
5766}
5767
5768#[cfg(test)]
5769mod tests {
5770    use super::*;
5771    use crate::model::{Cost, StopReason, Usage};
5772    use asupersync::runtime::RuntimeBuilder;
5773    use asupersync::sync::Mutex as AsyncMutex;
5774    use clap::Parser;
5775    use std::env;
5776    use std::future::Future;
5777    use std::path::{Path, PathBuf};
5778    use std::sync::{Mutex as StdMutex, OnceLock};
5779    use std::time::Duration;
5780
5781    macro_rules! test_fail {
5782        ($message:literal $(,)?) => {
5783            std::panic::panic_any($message)
5784        };
5785        ($fmt:literal, $($arg:tt)+) => {
5786            std::panic::panic_any(format!($fmt, $($arg)+))
5787        };
5788    }
5789
5790    fn make_test_message(text: &str) -> SessionMessage {
5791        SessionMessage::User {
5792            content: UserContent::Text(text.to_string()),
5793            timestamp: Some(0),
5794        }
5795    }
5796
5797    fn make_test_assistant_message(text: &str, total_tokens: u64) -> SessionMessage {
5798        SessionMessage::Assistant {
5799            message: AssistantMessage {
5800                content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5801                api: "test-api".to_string(),
5802                provider: "test-provider".to_string(),
5803                model: "test-model".to_string(),
5804                usage: Usage {
5805                    input: total_tokens / 2,
5806                    output: total_tokens.saturating_sub(total_tokens / 2),
5807                    total_tokens,
5808                    ..Usage::default()
5809                },
5810                stop_reason: StopReason::Stop,
5811                error_message: None,
5812                timestamp: 0,
5813            },
5814        }
5815    }
5816
5817    fn make_test_tool_call_message(tool_call_id: &str) -> SessionMessage {
5818        SessionMessage::Assistant {
5819            message: AssistantMessage {
5820                content: vec![ContentBlock::ToolCall(crate::model::ToolCall {
5821                    id: tool_call_id.to_string(),
5822                    name: "read".to_string(),
5823                    arguments: serde_json::json!({ "path": "src/session.rs" }),
5824                    thought_signature: None,
5825                })],
5826                api: "test-api".to_string(),
5827                provider: "test-provider".to_string(),
5828                model: "test-model".to_string(),
5829                usage: Usage {
5830                    input: 24,
5831                    output: 16,
5832                    total_tokens: 40,
5833                    ..Usage::default()
5834                },
5835                stop_reason: StopReason::ToolUse,
5836                error_message: None,
5837                timestamp: 0,
5838            },
5839        }
5840    }
5841
5842    fn make_test_tool_result_message(tool_call_id: &str) -> SessionMessage {
5843        SessionMessage::ToolResult {
5844            tool_call_id: tool_call_id.to_string(),
5845            tool_name: "read".to_string(),
5846            content: vec![ContentBlock::Text(TextContent::new(
5847                "read output for replay harness".to_string(),
5848            ))],
5849            details: Some(serde_json::json!({
5850                "bytes": 31,
5851                "truncated": false,
5852            })),
5853            is_error: false,
5854            timestamp: Some(0),
5855        }
5856    }
5857
5858    fn make_test_aborted_assistant_message(text: &str) -> SessionMessage {
5859        SessionMessage::Assistant {
5860            message: AssistantMessage {
5861                content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5862                api: "test-api".to_string(),
5863                provider: "test-provider".to_string(),
5864                model: "test-model".to_string(),
5865                usage: Usage {
5866                    input: 10,
5867                    output: 6,
5868                    total_tokens: 16,
5869                    ..Usage::default()
5870                },
5871                stop_reason: StopReason::Aborted,
5872                error_message: Some("interrupted by local abort".to_string()),
5873                timestamp: 0,
5874            },
5875        }
5876    }
5877
5878    fn run_async<T>(future: impl Future<Output = T>) -> T {
5879        let runtime = RuntimeBuilder::current_thread()
5880            .build()
5881            .expect("build runtime");
5882        runtime.block_on(future)
5883    }
5884
5885    fn tempdir_under_tmpdir(prefix: &str) -> tempfile::TempDir {
5886        let tmp_root = env::var_os("TMPDIR").map_or_else(env::temp_dir, PathBuf::from);
5887        std::fs::create_dir_all(&tmp_root).expect("create TMPDIR root");
5888        tempfile::Builder::new()
5889            .prefix(prefix)
5890            .tempdir_in(&tmp_root)
5891            .expect("create tempdir under TMPDIR")
5892    }
5893
5894    fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
5895        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
5896        LOCK.get_or_init(|| StdMutex::new(())).lock().expect("lock")
5897    }
5898
5899    struct CurrentDirGuard {
5900        previous: PathBuf,
5901    }
5902
5903    impl CurrentDirGuard {
5904        fn new(path: &Path) -> Self {
5905            let previous = env::current_dir().expect("current dir");
5906            env::set_current_dir(path).expect("set current dir");
5907            Self { previous }
5908        }
5909    }
5910
5911    impl Drop for CurrentDirGuard {
5912        fn drop(&mut self) {
5913            let _ = env::set_current_dir(&self.previous);
5914        }
5915    }
5916
5917    #[test]
5918    fn v2_open_mode_parser_supports_expected_values() {
5919        assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
5920        assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
5921        assert_eq!(
5922            parse_v2_open_mode("active_path"),
5923            Some(V2OpenMode::ActivePath)
5924        );
5925        assert_eq!(
5926            parse_v2_open_mode("active-path"),
5927            Some(V2OpenMode::ActivePath)
5928        );
5929        assert_eq!(
5930            parse_v2_open_mode("tail"),
5931            Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
5932        );
5933        assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
5934        assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
5935        assert_eq!(parse_v2_open_mode("bad-mode"), None);
5936        assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
5937    }
5938
5939    #[test]
5940    fn v2_open_mode_selection_prefers_env_override_then_threshold() {
5941        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
5942        assert_eq!(mode, V2OpenMode::Full);
5943        assert_eq!(reason, "env_override");
5944        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5945
5946        let (mode, reason, threshold) =
5947            select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
5948        assert_eq!(
5949            mode,
5950            V2OpenMode::ActivePath,
5951            "invalid threshold falls back to default threshold"
5952        );
5953        assert_eq!(reason, "entry_count_above_lazy_threshold");
5954        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5955
5956        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
5957        assert_eq!(mode, V2OpenMode::ActivePath);
5958        assert_eq!(reason, "entry_count_above_lazy_threshold");
5959        assert_eq!(threshold, 500);
5960
5961        let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
5962        assert_eq!(mode, V2OpenMode::Full);
5963        assert_eq!(reason, "default_full");
5964        assert_eq!(threshold, 500);
5965    }
5966
5967    #[test]
5968    fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
5969        let temp_dir = tempfile::tempdir().unwrap();
5970        let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
5971
5972        // Build a branching session:
5973        // root -> a -> b
5974        //           \-> c (active leaf)
5975        let mut seed = Session::create();
5976        seed.path = Some(path.clone());
5977        let _id_root = seed.append_message(make_test_message("root"));
5978        let id_a = seed.append_message(make_test_message("a"));
5979        let id_b = seed.append_message(make_test_message("main-branch"));
5980        assert!(seed.create_branch_from(&id_a));
5981        let id_c = seed.append_message(make_test_message("side-branch"));
5982        run_async(async { seed.save().await }).unwrap();
5983
5984        // Build sidecar and reopen in ActivePath mode.
5985        create_v2_sidecar_from_jsonl(&path).unwrap();
5986        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5987        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5988        let (mut loaded, _) =
5989            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5990        loaded.path = Some(path.clone());
5991        loaded.v2_sidecar_root = Some(v2_root);
5992        loaded.v2_partial_hydration = true;
5993        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5994
5995        let active_ids: Vec<String> = loaded
5996            .entries
5997            .iter()
5998            .filter_map(|entry| entry.base().id.clone())
5999            .collect();
6000        assert!(
6001            !active_ids.contains(&id_b),
6002            "active path intentionally excludes non-leaf sibling branch"
6003        );
6004        assert!(active_ids.contains(&id_c));
6005        assert_eq!(
6006            loaded.cached_message_count, seed.cached_message_count,
6007            "active-path resume should retain total message count metadata"
6008        );
6009        assert!(
6010            loaded.v2_message_count_offset > 0,
6011            "active-path resume should track hidden messages outside the active path"
6012        );
6013
6014        // Force full rewrite path (header dirty). Save must rehydrate first so b survives.
6015        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6016        run_async(async { loaded.save().await }).unwrap();
6017
6018        let (reopened, _) =
6019            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6020        let reopened_ids: Vec<String> = reopened
6021            .entries
6022            .iter()
6023            .filter_map(|entry| entry.base().id.clone())
6024            .collect();
6025        assert!(
6026            reopened_ids.contains(&id_b),
6027            "non-active branch entry must survive full rewrite after lazy hydration"
6028        );
6029        assert!(reopened_ids.contains(&id_c));
6030        assert_eq!(reopened_ids.len(), 4);
6031    }
6032
6033    #[test]
6034    fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
6035        let temp_dir = tempfile::tempdir().unwrap();
6036        let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
6037
6038        let mut seed = Session::create();
6039        seed.path = Some(path.clone());
6040        let _id_root = seed.append_message(make_test_message("root"));
6041        let id_a = seed.append_message(make_test_message("a"));
6042        let id_b = seed.append_message(make_test_message("main-branch"));
6043        assert!(seed.create_branch_from(&id_a));
6044        let _id_c = seed.append_message(make_test_message("side-branch"));
6045        run_async(async { seed.save().await }).unwrap();
6046
6047        create_v2_sidecar_from_jsonl(&path).unwrap();
6048        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
6049        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
6050        let (mut loaded, _) =
6051            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
6052        loaded.path = Some(path.clone());
6053        loaded.v2_sidecar_root = Some(v2_root);
6054        loaded.v2_partial_hydration = true;
6055        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6056
6057        let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
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 rehydration+save"
6071        );
6072        assert!(
6073            reopened_ids.contains(&new_id),
6074            "pending entry appended on partial session must be preserved"
6075        );
6076        assert_eq!(reopened_ids.len(), 5);
6077    }
6078
6079    #[test]
6080    fn v2_partial_hydration_full_rewrite_uses_newer_jsonl_when_sidecar_is_stale() {
6081        let temp_dir = tempfile::tempdir().unwrap();
6082        let path = temp_dir.path().join("lazy_hydration_stale_sidecar.jsonl");
6083
6084        let mut seed = Session::create();
6085        seed.path = Some(path.clone());
6086        let _id_root = seed.append_message(make_test_message("root"));
6087        let id_a = seed.append_message(make_test_message("a"));
6088        let id_b = seed.append_message(make_test_message("main-branch"));
6089        assert!(seed.create_branch_from(&id_a));
6090        let _id_c = seed.append_message(make_test_message("side-branch"));
6091        run_async(async { seed.save().await }).unwrap();
6092
6093        create_v2_sidecar_from_jsonl(&path).unwrap();
6094        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
6095        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
6096        let (mut loaded, _) =
6097            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
6098        loaded.path = Some(path.clone());
6099        loaded.v2_sidecar_root = Some(v2_root.clone());
6100        loaded.v2_partial_hydration = true;
6101        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6102
6103        std::thread::sleep(std::time::Duration::from_secs(1));
6104        let new_id = loaded.append_message(make_test_message("saved-before-full-rewrite"));
6105        run_async(async { loaded.save().await }).unwrap();
6106        assert!(
6107            is_v2_sidecar_stale(&path, &v2_root),
6108            "incremental JSONL save should make sidecar stale"
6109        );
6110
6111        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6112        run_async(async { loaded.save().await }).unwrap();
6113
6114        let (reopened, _) =
6115            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6116        let reopened_ids: Vec<String> = reopened
6117            .entries
6118            .iter()
6119            .filter_map(|entry| entry.base().id.clone())
6120            .collect();
6121        assert!(
6122            reopened_ids.contains(&id_b),
6123            "non-active branch entry must survive full rewrite after stale sidecar"
6124        );
6125        assert!(
6126            reopened_ids.contains(&new_id),
6127            "entry already saved to JSONL must not be dropped during rehydrate"
6128        );
6129        assert_eq!(reopened_ids.len(), 5);
6130    }
6131
6132    #[test]
6133    fn verify_v2_against_jsonl_detects_payload_mismatch_with_matching_ids() {
6134        let temp_dir = tempfile::tempdir().unwrap();
6135        let path = temp_dir.path().join("verify_v2_payload_mismatch.jsonl");
6136
6137        let mut session = Session::create();
6138        session.path = Some(path.clone());
6139        session.append_message(make_test_message("alpha"));
6140        session.append_message(make_test_message("beta"));
6141        run_async(async { session.save().await }).unwrap();
6142
6143        let contents = std::fs::read_to_string(&path).unwrap();
6144        let mut lines = contents.lines();
6145        let _header_line = lines.next().expect("header");
6146        let mut tampered_entries: Vec<SessionEntry> = lines
6147            .filter(|line| !line.trim().is_empty())
6148            .map(|line| serde_json::from_str(line).expect("parse session entry"))
6149            .collect();
6150
6151        let SessionEntry::Message(message_entry) = tampered_entries
6152            .first_mut()
6153            .expect("first tampered entry should exist")
6154        else {
6155            test_fail!("expected message entry");
6156        };
6157        let SessionMessage::User {
6158            content: UserContent::Text(text),
6159            ..
6160        } = &mut message_entry.message
6161        else {
6162            test_fail!("expected user text message");
6163        };
6164        *text = "alpha-tampered".to_string();
6165
6166        let tampered_root = temp_dir.path().join("verify_v2_payload_mismatch.v2");
6167        let mut tampered_store = SessionStoreV2::create(&tampered_root, 64 * 1024 * 1024).unwrap();
6168        for entry in &tampered_entries {
6169            let (entry_id, parent_entry_id, entry_type, payload) =
6170                session_store_v2::session_entry_to_frame_args(entry).unwrap();
6171            tampered_store
6172                .append_entry(entry_id, parent_entry_id, entry_type, payload)
6173                .unwrap();
6174        }
6175
6176        let verification = verify_v2_against_jsonl(&path, &tampered_store).unwrap();
6177        assert!(verification.entry_count_match);
6178        assert!(verification.index_consistent);
6179        assert!(
6180            !verification.hash_chain_match,
6181            "payload divergence must fail migration verification even when entry ids match"
6182        );
6183    }
6184
6185    #[test]
6186    fn test_session_handle_mutations_defer_persistence_side_effects() {
6187        let temp_dir = tempfile::tempdir().expect("temp dir");
6188        let mut session = Session::create();
6189        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6190        // Point at a directory path so an eager save would fail with an IO error.
6191        session.path = Some(temp_dir.path().to_path_buf());
6192        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6193
6194        run_async(async { handle.set_name("deferred-save".to_string()).await })
6195            .expect("set_name should not trigger immediate save");
6196        run_async(async { handle.append_message(make_test_message("hello")).await })
6197            .expect("append_message should not trigger immediate save");
6198        run_async(async {
6199            handle
6200                .append_custom_entry(
6201                    "marker".to_string(),
6202                    Some(serde_json::json!({ "value": 42 })),
6203                )
6204                .await
6205        })
6206        .expect("append_custom_entry should not trigger immediate save");
6207        run_async(async {
6208            handle
6209                .set_model("prov".to_string(), "model".to_string())
6210                .await
6211        })
6212        .expect("set_model should not trigger immediate save");
6213        run_async(async { handle.set_thinking_level("high".to_string()).await })
6214            .expect("set_thinking_level should not trigger immediate save");
6215
6216        let branch = run_async(async { handle.get_branch().await });
6217        let message_id = branch
6218            .iter()
6219            .find_map(|entry| {
6220                if entry
6221                    .get("type")
6222                    .and_then(Value::as_str)
6223                    .is_some_and(|entry_type| entry_type.eq("message"))
6224                {
6225                    entry
6226                        .get("id")
6227                        .and_then(Value::as_str)
6228                        .map(ToString::to_string)
6229                } else {
6230                    None
6231                }
6232            })
6233            .expect("message entry id in branch");
6234        run_async(async {
6235            handle
6236                .set_label(message_id, Some("hot-path".to_string()))
6237                .await
6238        })
6239        .expect("set_label should not trigger immediate save");
6240
6241        let state = run_async(async { handle.get_state().await });
6242        assert_eq!(
6243            state.get("sessionName").and_then(Value::as_str),
6244            Some("deferred-save")
6245        );
6246        assert_eq!(
6247            state.get("thinkingLevel").and_then(Value::as_str),
6248            Some("high")
6249        );
6250        assert_eq!(
6251            state.get("durabilityMode").and_then(Value::as_str),
6252            Some("throughput")
6253        );
6254        assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
6255        assert_eq!(
6256            state
6257                .get("model")
6258                .and_then(|model| model.get("provider"))
6259                .and_then(Value::as_str),
6260            Some("prov")
6261        );
6262        assert_eq!(
6263            state
6264                .get("model")
6265                .and_then(|model| model.get("id"))
6266                .and_then(Value::as_str),
6267            Some("model")
6268        );
6269
6270        let (provider, model_id) = run_async(async { handle.get_model().await });
6271        assert_eq!(provider.as_deref(), Some("prov"));
6272        assert_eq!(model_id.as_deref(), Some("model"));
6273    }
6274
6275    #[test]
6276    fn session_handle_set_name_inherits_cancelled_context_when_lock_is_held() {
6277        let runtime = RuntimeBuilder::current_thread()
6278            .build()
6279            .expect("build runtime");
6280
6281        runtime.block_on(async {
6282            let session = Arc::new(AsyncMutex::new(Session::in_memory()));
6283            let handle = SessionHandle(Arc::clone(&session));
6284
6285            let hold_cx = AgentCx::for_request();
6286            let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
6287
6288            let ambient_cx = asupersync::Cx::for_testing();
6289            ambient_cx.set_cancel_requested(true);
6290            let _current = asupersync::Cx::set_current(Some(ambient_cx));
6291            let inner = asupersync::time::timeout(
6292                asupersync::time::wall_now(),
6293                Duration::from_millis(100),
6294                handle.set_name("cancelled-name".to_string()),
6295            )
6296            .await;
6297            let outcome = inner.expect("cancelled helper should finish before timeout");
6298            let err = outcome.expect_err("lock acquisition should honor inherited cancellation");
6299            assert!(
6300                err.to_string().contains("Failed to lock session"),
6301                "unexpected error: {err}"
6302            );
6303
6304            drop(held_guard);
6305
6306            let state = SessionHandle(Arc::clone(&session)).get_state().await;
6307            assert!(
6308                state.get("sessionName").is_none_or(Value::is_null),
6309                "cancelled mutation should not update the session name: {state:?}"
6310            );
6311        });
6312    }
6313
6314    #[test]
6315    fn session_handle_set_name_inherits_deadline() {
6316        let runtime = RuntimeBuilder::current_thread()
6317            .build()
6318            .expect("build runtime");
6319
6320        runtime.block_on(async {
6321            struct ProbeReset;
6322            impl Drop for ProbeReset {
6323                fn drop(&mut self) {
6324                    let mut probe = set_name_deadline_probe()
6325                        .lock()
6326                        .expect("lock set_name deadline probe");
6327                    *probe = None;
6328                }
6329            }
6330
6331            let session_state = Session::in_memory();
6332            let probe_session_id = session_state.header.id.clone();
6333            let session = Arc::new(AsyncMutex::new(session_state));
6334            let handle = SessionHandle(Arc::clone(&session));
6335
6336            let (probe_tx, probe_rx) = std::sync::mpsc::channel();
6337            {
6338                let mut probe = set_name_deadline_probe()
6339                    .lock()
6340                    .expect("lock set_name deadline probe");
6341                assert!(probe.is_none(), "set_name deadline probe already installed");
6342                *probe = Some((probe_session_id, probe_tx));
6343            }
6344            let _probe_reset = ProbeReset;
6345
6346            let expected_deadline = asupersync::time::wall_now() + Duration::from_secs(30);
6347            let ambient_cx = AgentCx::for_request_with_budget(asupersync::Budget {
6348                deadline: Some(expected_deadline),
6349                ..asupersync::Budget::INFINITE
6350            });
6351            let _current = asupersync::Cx::set_current(Some(ambient_cx.cx().clone()));
6352            handle
6353                .set_name("deadline-name".to_string())
6354                .await
6355                .expect("set_name should succeed with inherited deadline");
6356
6357            let recorded = probe_rx
6358                .recv_timeout(Duration::from_secs(1))
6359                .expect("set_name deadline probe");
6360            assert_eq!(recorded, Some(expected_deadline));
6361
6362            let state = SessionHandle(Arc::clone(&session)).get_state().await;
6363            assert_eq!(
6364                state.get("sessionName").and_then(Value::as_str),
6365                Some("deadline-name")
6366            );
6367        });
6368    }
6369
6370    #[test]
6371    fn test_session_handle_set_model_and_thinking_level_dedupe_history() {
6372        let handle = SessionHandle(Arc::new(AsyncMutex::new(Session::in_memory())));
6373
6374        run_async(async {
6375            handle
6376                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6377                .await
6378        })
6379        .expect("set model");
6380        run_async(async {
6381            handle
6382                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6383                .await
6384        })
6385        .expect("repeat model");
6386        run_async(async { handle.set_thinking_level("high".to_string()).await })
6387            .expect("set thinking");
6388        run_async(async { handle.set_thinking_level("high".to_string()).await })
6389            .expect("repeat thinking");
6390
6391        let branch = run_async(async { handle.get_branch().await });
6392        let model_changes = branch
6393            .iter()
6394            .filter(|entry| {
6395                entry
6396                    .get("type")
6397                    .and_then(Value::as_str)
6398                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6399            })
6400            .count();
6401        let thinking_changes = branch
6402            .iter()
6403            .filter(|entry| {
6404                entry
6405                    .get("type")
6406                    .and_then(Value::as_str)
6407                    .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6408            })
6409            .count();
6410        assert_eq!(model_changes, 1);
6411        assert_eq!(thinking_changes, 1);
6412    }
6413
6414    #[test]
6415    fn test_session_handle_preserves_alias_equivalent_model_state() {
6416        let mut session = Session::in_memory();
6417        session.append_model_change("google".to_string(), "gemini-2.5-pro".to_string());
6418        session.set_model_header(
6419            Some("google".to_string()),
6420            Some("gemini-2.5-pro".to_string()),
6421            None,
6422        );
6423        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6424
6425        run_async(async {
6426            handle
6427                .set_model("gemini".to_string(), "GEMINI-2.5-PRO".to_string())
6428                .await
6429        })
6430        .expect("alias-equivalent model should dedupe");
6431
6432        let branch = run_async(async { handle.get_branch().await });
6433        let model_changes: Vec<_> = branch
6434            .iter()
6435            .filter_map(|entry| {
6436                if entry
6437                    .get("type")
6438                    .and_then(Value::as_str)
6439                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6440                {
6441                    Some((
6442                        entry.get("provider").and_then(Value::as_str),
6443                        entry.get("modelId").and_then(Value::as_str),
6444                    ))
6445                } else {
6446                    None
6447                }
6448            })
6449            .collect();
6450        assert_eq!(
6451            model_changes,
6452            vec![(Some("google"), Some("gemini-2.5-pro"))],
6453            "alias-equivalent set_model should not append duplicate history"
6454        );
6455
6456        let (provider, model_id) = run_async(async { handle.get_model().await });
6457        assert_eq!(provider.as_deref(), Some("google"));
6458        assert_eq!(model_id.as_deref(), Some("gemini-2.5-pro"));
6459
6460        let state = run_async(async { handle.get_state().await });
6461        assert_eq!(state["model"]["provider"], "google");
6462        assert_eq!(state["model"]["id"], "gemini-2.5-pro");
6463    }
6464
6465    #[test]
6466    fn session_handle_reports_branch_local_model_and_thinking_state() {
6467        let mut session = Session::in_memory();
6468        let root_id = session.append_message(make_test_message("root"));
6469
6470        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6471        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6472        session.set_model_header(
6473            Some("openai".to_string()),
6474            Some("gpt-4o".to_string()),
6475            Some("low".to_string()),
6476        );
6477
6478        assert!(session.create_branch_from(&root_id));
6479        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6480        session.append_thinking_level_change("high".to_string());
6481        session.set_model_header(
6482            Some("anthropic".to_string()),
6483            Some("claude-sonnet-4-5".to_string()),
6484            Some("high".to_string()),
6485        );
6486
6487        assert!(session.navigate_to(&branch_a_thinking));
6488
6489        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6490        let state = run_async(async { handle.get_state().await });
6491        let (provider, model_id) = run_async(async { handle.get_model().await });
6492        let thinking_level = run_async(async { handle.get_thinking_level().await });
6493
6494        assert_eq!(provider.as_deref(), Some("openai"));
6495        assert_eq!(model_id.as_deref(), Some("gpt-4o"));
6496        assert_eq!(thinking_level.as_deref(), Some("low"));
6497        assert_eq!(
6498            state
6499                .get("model")
6500                .and_then(|model| model.get("provider"))
6501                .and_then(Value::as_str),
6502            Some("openai")
6503        );
6504        assert_eq!(
6505            state
6506                .get("model")
6507                .and_then(|model| model.get("id"))
6508                .and_then(Value::as_str),
6509            Some("gpt-4o")
6510        );
6511        assert_eq!(
6512            state.get("thinkingLevel").and_then(Value::as_str),
6513            Some("low")
6514        );
6515    }
6516
6517    #[test]
6518    fn session_handle_set_model_and_thinking_level_dedupe_on_switched_branch() {
6519        let mut session = Session::in_memory();
6520        let root_id = session.append_message(make_test_message("root"));
6521
6522        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6523        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6524        session.set_model_header(
6525            Some("openai".to_string()),
6526            Some("gpt-4o".to_string()),
6527            Some("low".to_string()),
6528        );
6529
6530        assert!(session.create_branch_from(&root_id));
6531        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6532        session.append_thinking_level_change("high".to_string());
6533        session.set_model_header(
6534            Some("anthropic".to_string()),
6535            Some("claude-sonnet-4-5".to_string()),
6536            Some("high".to_string()),
6537        );
6538
6539        assert!(session.navigate_to(&branch_a_thinking));
6540
6541        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6542
6543        run_async(async {
6544            handle
6545                .set_model("openai".to_string(), "gpt-4o".to_string())
6546                .await
6547        })
6548        .expect("same-branch model should dedupe");
6549        run_async(async { handle.set_thinking_level("low".to_string()).await })
6550            .expect("same-branch thinking should dedupe");
6551
6552        let branch = run_async(async { handle.get_branch().await });
6553        let model_changes = branch
6554            .iter()
6555            .filter(|entry| {
6556                entry
6557                    .get("type")
6558                    .and_then(Value::as_str)
6559                    .is_some_and(|entry_type| entry_type.eq("model_change"))
6560            })
6561            .count();
6562        let thinking_changes = branch
6563            .iter()
6564            .filter(|entry| {
6565                entry
6566                    .get("type")
6567                    .and_then(Value::as_str)
6568                    .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6569            })
6570            .count();
6571
6572        assert_eq!(model_changes, 1, "expected one branch-local model_change");
6573        assert_eq!(
6574            thinking_changes, 1,
6575            "expected one branch-local thinking_level_change"
6576        );
6577    }
6578
6579    #[test]
6580    fn test_autosave_queue_coalesces_mutations_per_flush() {
6581        let temp_dir = tempfile::tempdir().expect("temp dir");
6582        let mut session = Session::create();
6583        session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
6584
6585        session.append_message(make_test_message("one"));
6586        session.append_custom_entry("marker".to_string(), None);
6587        session.append_message(make_test_message("two"));
6588
6589        let before = session.autosave_metrics();
6590        assert_eq!(before.pending_mutations, 3);
6591        assert!(before.coalesced_mutations >= 2);
6592        assert_eq!(before.flush_succeeded, 0);
6593
6594        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
6595            .expect("periodic flush");
6596
6597        let after = session.autosave_metrics();
6598        assert_eq!(after.pending_mutations, 0);
6599        assert_eq!(after.flush_started, 1);
6600        assert_eq!(after.flush_succeeded, 1);
6601        assert_eq!(after.last_flush_batch_size, 3);
6602        assert_eq!(
6603            after.last_flush_trigger,
6604            Some(AutosaveFlushTrigger::Periodic)
6605        );
6606    }
6607
6608    #[test]
6609    fn test_autosave_queue_backpressure_is_bounded() {
6610        let mut session = Session::create();
6611        session.set_autosave_queue_limit_for_test(2);
6612
6613        for i in 0..5 {
6614            session.append_message(make_test_message(&format!("message-{i}")));
6615        }
6616
6617        let metrics = session.autosave_metrics();
6618        assert_eq!(metrics.max_pending_mutations, 2);
6619        assert_eq!(metrics.pending_mutations, 2);
6620        assert_eq!(metrics.backpressure_events, 3);
6621        assert!(metrics.coalesced_mutations >= 4);
6622    }
6623
6624    #[test]
6625    fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
6626        let temp_dir = tempfile::tempdir().expect("temp dir");
6627
6628        let mut strict = Session::create();
6629        // Point at a directory path so strict shutdown flush attempts fail.
6630        strict.path = Some(temp_dir.path().to_path_buf());
6631        strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
6632        strict.append_message(make_test_message("strict"));
6633
6634        run_async(async { strict.flush_autosave_on_shutdown().await })
6635            .expect_err("strict mode should propagate shutdown flush failure");
6636        let strict_metrics = strict.autosave_metrics();
6637        assert_eq!(strict_metrics.flush_failed, 1);
6638        assert!(strict_metrics.pending_mutations > 0);
6639
6640        let mut throughput = Session::create();
6641        throughput.path = Some(temp_dir.path().to_path_buf());
6642        throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6643        throughput.append_message(make_test_message("throughput"));
6644
6645        run_async(async { throughput.flush_autosave_on_shutdown().await })
6646            .expect("throughput mode skips shutdown flush");
6647        let throughput_metrics = throughput.autosave_metrics();
6648        assert_eq!(throughput_metrics.flush_started, 0);
6649        assert_eq!(throughput_metrics.pending_mutations, 1);
6650    }
6651
6652    #[test]
6653    fn test_session_new_prefers_cli_durability_mode_over_config() {
6654        let cli =
6655            crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
6656        let config: Config =
6657            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6658        let session =
6659            run_async(async { Session::new(&cli, &config).await }).expect("create session");
6660        assert_eq!(
6661            session.autosave_durability_mode(),
6662            AutosaveDurabilityMode::Strict
6663        );
6664    }
6665
6666    #[test]
6667    fn test_session_new_uses_config_durability_mode_when_cli_unset() {
6668        let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
6669        let config: Config =
6670            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6671        let session =
6672            run_async(async { Session::new(&cli, &config).await }).expect("create session");
6673        assert_eq!(
6674            session.autosave_durability_mode(),
6675            AutosaveDurabilityMode::Throughput
6676        );
6677    }
6678
6679    #[test]
6680    fn test_resolve_autosave_durability_mode_precedence() {
6681        assert_eq!(
6682            resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
6683            AutosaveDurabilityMode::Strict
6684        );
6685        assert_eq!(
6686            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
6687            AutosaveDurabilityMode::Throughput
6688        );
6689        assert_eq!(
6690            resolve_autosave_durability_mode(None, None, Some("strict")),
6691            AutosaveDurabilityMode::Strict
6692        );
6693        assert_eq!(
6694            resolve_autosave_durability_mode(None, None, None),
6695            AutosaveDurabilityMode::Balanced
6696        );
6697    }
6698
6699    #[test]
6700    fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
6701        assert_eq!(
6702            resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
6703            AutosaveDurabilityMode::Throughput
6704        );
6705        assert_eq!(
6706            resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
6707            AutosaveDurabilityMode::Strict
6708        );
6709        assert_eq!(
6710            resolve_autosave_durability_mode(None, None, Some("bad")),
6711            AutosaveDurabilityMode::Balanced
6712        );
6713    }
6714
6715    #[test]
6716    fn test_get_share_viewer_url_matches_legacy() {
6717        assert_eq!(
6718            build_share_viewer_url(None, "gist-123"),
6719            "https://buildwithpi.ai/session/#gist-123"
6720        );
6721        assert_eq!(
6722            build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
6723            "https://example.com/session/#gist-123"
6724        );
6725        assert_eq!(
6726            build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
6727            "https://example.com/session#gist-123"
6728        );
6729        // Legacy JS uses `process.env.PI_SHARE_VIEWER_URL || DEFAULT`, so empty-string should
6730        // fall back to default.
6731        assert_eq!(
6732            build_share_viewer_url(Some(""), "gist-123"),
6733            "https://buildwithpi.ai/session/#gist-123"
6734        );
6735    }
6736
6737    #[test]
6738    fn test_session_linear_history() {
6739        let mut session = Session::in_memory();
6740
6741        let id1 = session.append_message(make_test_message("Hello"));
6742        let id2 = session.append_message(make_test_message("World"));
6743        let id3 = session.append_message(make_test_message("Test"));
6744
6745        // Check leaf is the last entry
6746        assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
6747
6748        // Check path from last entry
6749        let path = session.get_path_to_entry(&id3);
6750        assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
6751
6752        // Check only one leaf
6753        let leaves = session.list_leaves();
6754        assert_eq!(leaves.len(), 1);
6755        assert_eq!(leaves[0], id3);
6756    }
6757
6758    #[test]
6759    fn test_session_branching() {
6760        let mut session = Session::in_memory();
6761
6762        // Create linear history: A -> B -> C
6763        let id_a = session.append_message(make_test_message("A"));
6764        let id_b = session.append_message(make_test_message("B"));
6765        let id_c = session.append_message(make_test_message("C"));
6766
6767        // Now branch from B: A -> B -> D
6768        assert!(session.create_branch_from(&id_b));
6769        let id_d = session.append_message(make_test_message("D"));
6770
6771        // Should have 2 leaves: C and D
6772        let leaves = session.list_leaves();
6773        assert_eq!(leaves.len(), 2);
6774        assert!(leaves.contains(&id_c));
6775        assert!(leaves.contains(&id_d));
6776
6777        // Path to D should be A -> B -> D
6778        let path_to_d = session.get_path_to_entry(&id_d);
6779        assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
6780
6781        // Path to C should be A -> B -> C
6782        let path_to_c = session.get_path_to_entry(&id_c);
6783        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6784    }
6785
6786    #[test]
6787    fn test_session_navigation() {
6788        let mut session = Session::in_memory();
6789
6790        let id1 = session.append_message(make_test_message("First"));
6791        let id2 = session.append_message(make_test_message("Second"));
6792
6793        // Navigate to first entry
6794        assert!(session.navigate_to(&id1));
6795        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6796
6797        // Navigate to non-existent entry
6798        assert!(!session.navigate_to("nonexistent"));
6799        // leaf_id unchanged
6800        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6801
6802        // Navigate back to second
6803        assert!(session.navigate_to(&id2));
6804        assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
6805    }
6806
6807    #[test]
6808    fn test_navigation_syncs_header_to_current_branch_metadata() {
6809        let mut session = Session::in_memory();
6810
6811        let root_id = session.append_message(make_test_message("root"));
6812        let openai_id = session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
6813        let high_id = session.append_thinking_level_change("high".to_string());
6814        let _tip_a = session.append_message(make_test_message("branch-a"));
6815
6816        assert!(session.create_branch_from(&root_id));
6817        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6818        let minimal_id = session.append_thinking_level_change("minimal".to_string());
6819        let _tip_b = session.append_message(make_test_message("branch-b"));
6820
6821        assert!(session.navigate_to(&high_id));
6822        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6823        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6824        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6825
6826        assert!(session.navigate_to(&minimal_id));
6827        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6828        assert_eq!(session.header.model_id.as_deref(), Some("claude-sonnet-4"));
6829        assert_eq!(session.header.thinking_level.as_deref(), Some("minimal"));
6830
6831        assert!(session.navigate_to(&openai_id));
6832        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6833        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6834    }
6835
6836    #[test]
6837    fn test_navigation_clears_stale_header_metadata_when_target_branch_has_no_override() {
6838        let mut session = Session::in_memory();
6839
6840        let root_id = session.append_message(make_test_message("root"));
6841        let branch_a_tip = session.append_message(make_test_message("branch-a"));
6842
6843        assert!(session.create_branch_from(&root_id));
6844        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6845        session.append_thinking_level_change("high".to_string());
6846        session.set_model_header(
6847            Some("anthropic".to_string()),
6848            Some("claude-sonnet-4".to_string()),
6849            Some("high".to_string()),
6850        );
6851
6852        assert!(session.navigate_to(&branch_a_tip));
6853        assert!(session.header.provider.is_none());
6854        assert!(session.header.model_id.is_none());
6855        assert!(session.header.thinking_level.is_none());
6856    }
6857
6858    #[test]
6859    fn test_open_materializes_header_fallback_for_historyless_branch_navigation() {
6860        let temp = tempfile::tempdir().expect("temp dir");
6861        let path = temp.path().join("legacy-historyless-branch.jsonl");
6862
6863        let mut legacy = Session::in_memory();
6864        legacy.header.provider = Some("openai".to_string());
6865        legacy.header.model_id = Some("gpt-5.4".to_string());
6866        legacy.header.thinking_level = Some("low".to_string());
6867
6868        let root_id = legacy.append_message(make_test_message("root"));
6869        let branch_b_tip = legacy.append_message(make_test_message("branch-b"));
6870
6871        assert!(legacy.create_branch_from(&root_id));
6872        legacy.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6873        legacy.append_thinking_level_change("high".to_string());
6874        let branch_a_tip = legacy.append_message(make_test_message("branch-a"));
6875
6876        legacy.header.current_leaf = Some(branch_b_tip.clone());
6877
6878        let mut jsonl = serde_json::to_string(&legacy.header).expect("serialize legacy header");
6879        jsonl.push('\n');
6880        for entry in &legacy.entries {
6881            jsonl.push_str(&serde_json::to_string(entry).expect("serialize session entry"));
6882            jsonl.push('\n');
6883        }
6884        std::fs::write(&path, jsonl).expect("write legacy session");
6885
6886        let mut loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6887            .expect("open legacy session");
6888
6889        assert_eq!(loaded.leaf_id.as_deref(), Some(branch_b_tip.as_str()));
6890        assert_eq!(loaded.header.fallback_provider.as_deref(), Some("openai"));
6891        assert_eq!(loaded.header.fallback_model_id.as_deref(), Some("gpt-5.4"));
6892        assert_eq!(
6893            loaded.header.fallback_thinking_level.as_deref(),
6894            Some("low")
6895        );
6896
6897        assert!(loaded.navigate_to(&branch_a_tip));
6898        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
6899        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-sonnet-4"));
6900        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
6901
6902        assert!(loaded.navigate_to(&branch_b_tip));
6903        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
6904        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
6905        assert_eq!(loaded.header.thinking_level.as_deref(), Some("low"));
6906    }
6907
6908    #[test]
6909    fn test_session_get_children() {
6910        let mut session = Session::in_memory();
6911
6912        // A -> B -> C
6913        //   -> D
6914        let id_a = session.append_message(make_test_message("A"));
6915        let id_b = session.append_message(make_test_message("B"));
6916        let _id_c = session.append_message(make_test_message("C"));
6917
6918        // Branch from A
6919        session.create_branch_from(&id_a);
6920        let id_d = session.append_message(make_test_message("D"));
6921
6922        // A should have 2 children: B and D
6923        let children_a = session.get_children(Some(&id_a));
6924        assert_eq!(children_a.len(), 2);
6925        assert!(children_a.contains(&id_b));
6926        assert!(children_a.contains(&id_d));
6927
6928        // Root (None) should have 1 child: A
6929        let root_children = session.get_children(None);
6930        assert_eq!(root_children.len(), 1);
6931        assert_eq!(root_children[0], id_a);
6932    }
6933
6934    #[test]
6935    fn test_branch_summary() {
6936        let mut session = Session::in_memory();
6937
6938        // Linear: A -> B
6939        let id_a = session.append_message(make_test_message("A"));
6940        let id_b = session.append_message(make_test_message("B"));
6941
6942        let info = session.branch_summary();
6943        assert_eq!(info.total_entries, 2);
6944        assert_eq!(info.leaf_count, 1);
6945        assert_eq!(info.branch_point_count, 0);
6946
6947        // Create branch: A -> B, A -> C
6948        session.create_branch_from(&id_a);
6949        let _id_c = session.append_message(make_test_message("C"));
6950
6951        let info = session.branch_summary();
6952        assert_eq!(info.total_entries, 3);
6953        assert_eq!(info.leaf_count, 2);
6954        assert_eq!(info.branch_point_count, 1);
6955        assert!(info.branch_points.contains(&id_a));
6956        assert!(info.leaves.contains(&id_b));
6957    }
6958
6959    fn build_branch_heavy_session(
6960        path: &Path,
6961        fork_count: usize,
6962        side_branch_len: usize,
6963    ) -> (Session, String) {
6964        let mut session = Session::create();
6965        session.path = Some(path.to_path_buf());
6966        let mut selected_tip = session.append_message(make_test_message("root"));
6967
6968        for fork_idx in 0..fork_count {
6969            assert!(
6970                session.navigate_to(&selected_tip),
6971                "navigate to selected tip before side branch {fork_idx}"
6972            );
6973            for side_idx in 0..side_branch_len {
6974                session.append_message(make_test_message(&format!("side-{fork_idx}-{side_idx}")));
6975            }
6976
6977            assert!(
6978                session.navigate_to(&selected_tip),
6979                "return to selected tip before active branch {fork_idx}"
6980            );
6981            selected_tip = session.append_message(make_test_message(&format!("active-{fork_idx}")));
6982        }
6983
6984        (session, selected_tip)
6985    }
6986
6987    const LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA: &str = "pi.session.large_replay_correctness.v1";
6988
6989    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6990    struct LargeReplayCorrectnessEvidence {
6991        schema: String,
6992        fixture: String,
6993        entry_count: usize,
6994        selected_depth: usize,
6995        replayed_entries: usize,
6996        skipped_sibling_entries: usize,
6997        index_initial_miss_files: usize,
6998        index_cached_hit_files: usize,
6999        index_cached_reused_files: usize,
7000        index_failed_files: usize,
7001        elapsed_budget_class: String,
7002        fallback_reason: Option<String>,
7003        baseline_message_count: usize,
7004        accelerated_message_count: usize,
7005        baseline_leaf: String,
7006        accelerated_leaf: String,
7007        verdict: String,
7008    }
7009
7010    fn cold_start_elapsed_budget_class(elapsed_us: u64) -> &'static str {
7011        match elapsed_us {
7012            0..=250_000 => "target",
7013            250_001..=1_000_000 => "bounded",
7014            _ => "observed_slow",
7015        }
7016    }
7017
7018    fn current_path_message_json(session: &Session) -> serde_json::Value {
7019        let mut value = serde_json::to_value(session.to_messages_for_current_path())
7020            .expect("serialize current-path messages");
7021        redact_json_timestamps(&mut value);
7022        value
7023    }
7024
7025    fn redact_json_timestamps(value: &mut serde_json::Value) {
7026        match value {
7027            serde_json::Value::Object(object) => {
7028                if object.contains_key("timestamp") {
7029                    object.insert("timestamp".to_string(), serde_json::json!(0));
7030                }
7031                for child in object.values_mut() {
7032                    redact_json_timestamps(child);
7033                }
7034            }
7035            serde_json::Value::Array(items) => {
7036                for item in items {
7037                    redact_json_timestamps(item);
7038                }
7039            }
7040            serde_json::Value::Null
7041            | serde_json::Value::Bool(_)
7042            | serde_json::Value::Number(_)
7043            | serde_json::Value::String(_) => {}
7044        }
7045    }
7046
7047    fn append_large_replay_mixed_turns(session: &mut Session, selected_tip: &str) -> String {
7048        assert!(
7049            session.navigate_to(selected_tip),
7050            "return to active branch before mixed replay fixture"
7051        );
7052        let first_kept_id = selected_tip.to_string();
7053        session.append_model_change(
7054            "openai-responses".to_string(),
7055            "gpt-5.2-replay-harness".to_string(),
7056        );
7057        let tool_call_id = "call_large_replay";
7058        session.append_message(make_test_tool_call_message(tool_call_id));
7059        session.append_message(make_test_tool_result_message(tool_call_id));
7060        let aborted_id =
7061            session.append_message(make_test_aborted_assistant_message("interrupted assistant"));
7062        session.append_bash_execution(
7063            "cargo test session replay index".to_string(),
7064            "cancelled by operator".to_string(),
7065            130,
7066            true,
7067            false,
7068            None,
7069        );
7070        session.append_compaction(
7071            "large replay harness compaction".to_string(),
7072            first_kept_id,
7073            42_000,
7074            Some(serde_json::json!({
7075                "reason": "large_replay_correctness_harness",
7076            })),
7077            Some(false),
7078        );
7079        session.append_branch_summary(
7080            aborted_id,
7081            "interrupted turn branch summary".to_string(),
7082            Some(serde_json::json!({
7083                "turn_state": "interrupted",
7084            })),
7085            Some(false),
7086        );
7087        session.append_message(make_test_message("active-after-interrupted-turn"))
7088    }
7089
7090    #[allow(clippy::too_many_lines)]
7091    #[test]
7092    fn cold_start_replay_minimization_bounds_branch_heavy_v2_resume() {
7093        const FORKS: usize = 700;
7094        const SIDE_BRANCH_LEN: usize = 15;
7095        const MIXED_ACTIVE_ENTRIES: usize = 8;
7096        const MIXED_ACTIVE_REPLAYED_ENTRIES: usize = 7;
7097        const MIXED_ACTIVE_PROJECTED_MESSAGES: usize = 5;
7098
7099        let temp = tempdir_under_tmpdir("branch-heavy-v2-resume");
7100        let path = temp.path().join("branch-heavy.jsonl");
7101        let (mut session, mut selected_tip) =
7102            build_branch_heavy_session(&path, FORKS, SIDE_BRANCH_LEN);
7103        selected_tip = append_large_replay_mixed_turns(&mut session, &selected_tip);
7104        session.header.current_leaf = Some("stale-missing-leaf".to_string());
7105        run_async(async { session.save().await }).expect("save branch-heavy session");
7106
7107        let baseline_loaded =
7108            run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7109                .expect("open JSONL baseline session");
7110        let baseline_messages = current_path_message_json(&baseline_loaded);
7111
7112        create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7113
7114        let first_trace = run_async(async {
7115            Session::cold_start_trace_bundle(&path, temp.path())
7116                .await
7117                .expect("initial cold-start trace")
7118        });
7119        let trace = run_async(async {
7120            Session::cold_start_trace_bundle(&path, temp.path())
7121                .await
7122                .expect("cached cold-start trace")
7123        });
7124
7125        let accelerated_loaded =
7126            run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7127                .expect("open accelerated session");
7128        let accelerated_messages = current_path_message_json(&accelerated_loaded);
7129
7130        let expected_entries = 1 + (FORKS * (SIDE_BRANCH_LEN + 1)) + MIXED_ACTIVE_ENTRIES;
7131        let expected_depth = 1 + FORKS + MIXED_ACTIVE_ENTRIES;
7132        let expected_replayed_entries = 1 + FORKS + MIXED_ACTIVE_REPLAYED_ENTRIES;
7133        let expected_projected_messages = 1 + FORKS + MIXED_ACTIVE_PROJECTED_MESSAGES;
7134        assert_eq!(trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7135        assert_eq!(
7136            trace.replay_minimization.schema,
7137            SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA
7138        );
7139        assert_eq!(trace.storage.selected_backend, "v2_sidecar");
7140        assert_eq!(trace.storage.opened_backend, "v2_sidecar");
7141        assert_eq!(trace.input.total_entries, expected_depth);
7142        assert_eq!(trace.replay_minimization.entry_count, expected_entries);
7143        assert_eq!(trace.replay_minimization.branch_count, FORKS);
7144        assert_eq!(trace.replay_minimization.selected_depth, expected_depth);
7145        assert_eq!(
7146            trace.replay_minimization.replayed_entries,
7147            expected_replayed_entries
7148        );
7149        assert_eq!(
7150            trace.replay_minimization.skipped_sibling_entries,
7151            expected_entries - expected_depth
7152        );
7153        assert!(trace.replay_minimization.scanned_files >= 1);
7154        assert_eq!(trace.replay_minimization.fallback_behavior, None);
7155        assert_eq!(trace.replay_minimization.verdict, "bounded_selected_branch");
7156        assert_eq!(trace.compaction_scan.scanned_entries, expected_depth);
7157        assert_eq!(trace.compaction_scan.compaction_entries, 1);
7158        assert!(trace.compaction_scan.latest_compaction_present);
7159        assert_eq!(trace.compaction_scan.first_kept_entry_found, Some(true));
7160        assert_eq!(trace.first_render.current_path_entries, expected_depth);
7161        assert_eq!(
7162            trace.first_render.projected_messages,
7163            expected_projected_messages
7164        );
7165        assert_eq!(trace.first_render.tool_messages, 2);
7166        assert_eq!(trace.first_render.assistant_messages, 2);
7167        assert_eq!(trace.first_render.user_messages, 1 + FORKS + 1);
7168        assert_eq!(trace.first_render.system_messages, 0);
7169        assert!(first_trace.index_refresh.refreshed_files >= 1);
7170        assert_eq!(first_trace.index_refresh.failed_files, 0);
7171        assert!(trace.index_refresh.cache_hit_files >= 1);
7172        assert_eq!(
7173            trace.index_refresh.cache_hit_files,
7174            trace.index_refresh.reused_files
7175        );
7176        assert_eq!(trace.index_refresh.failed_files, 0);
7177
7178        assert_eq!(baseline_loaded.leaf_id(), Some(selected_tip.as_str()));
7179        assert_eq!(accelerated_loaded.leaf_id(), Some(selected_tip.as_str()));
7180        assert!(accelerated_loaded.v2_partial_hydration);
7181        assert_eq!(
7182            accelerated_loaded.v2_resume_mode,
7183            Some(V2OpenMode::ActivePath)
7184        );
7185        assert_eq!(
7186            accelerated_loaded.entries_for_current_path().len(),
7187            baseline_loaded.entries_for_current_path().len()
7188        );
7189        assert_eq!(
7190            accelerated_messages, baseline_messages,
7191            "accelerated V2 replay must match full JSONL replay"
7192        );
7193
7194        let evidence = LargeReplayCorrectnessEvidence {
7195            schema: LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA.to_string(),
7196            fixture: "branch-heavy-v2-resume".to_string(),
7197            entry_count: trace.replay_minimization.entry_count,
7198            selected_depth: trace.replay_minimization.selected_depth,
7199            replayed_entries: trace.replay_minimization.replayed_entries,
7200            skipped_sibling_entries: trace.replay_minimization.skipped_sibling_entries,
7201            index_initial_miss_files: first_trace.index_refresh.refreshed_files,
7202            index_cached_hit_files: trace.index_refresh.cache_hit_files,
7203            index_cached_reused_files: trace.index_refresh.reused_files,
7204            index_failed_files: trace.index_refresh.failed_files,
7205            elapsed_budget_class: cold_start_elapsed_budget_class(trace.total_elapsed_us)
7206                .to_string(),
7207            fallback_reason: trace.replay_minimization.fallback_behavior.clone(),
7208            baseline_message_count: baseline_loaded.to_messages_for_current_path().len(),
7209            accelerated_message_count: accelerated_loaded.to_messages_for_current_path().len(),
7210            baseline_leaf: baseline_loaded.leaf_id().unwrap_or_default().to_string(),
7211            accelerated_leaf: accelerated_loaded.leaf_id().unwrap_or_default().to_string(),
7212            verdict: trace.replay_minimization.verdict,
7213        };
7214        assert_eq!(evidence.fallback_reason, None);
7215        assert_eq!(
7216            evidence.baseline_message_count,
7217            evidence.accelerated_message_count
7218        );
7219        assert!(matches!(
7220            evidence.elapsed_budget_class.as_str(),
7221            "target" | "bounded" | "observed_slow"
7222        ));
7223        let serialized = serde_json::to_string(&evidence).expect("serialize evidence");
7224        assert!(!serialized.contains("side-0-0"));
7225        let parsed: LargeReplayCorrectnessEvidence =
7226            serde_json::from_str(&serialized).expect("parse evidence");
7227        assert_eq!(parsed, evidence);
7228    }
7229
7230    #[test]
7231    fn cold_start_replay_minimization_reports_missing_and_stale_sidecar_fallbacks() {
7232        let temp = tempdir_under_tmpdir("branch-heavy-fallbacks");
7233        let path = temp.path().join("branch-fallback.jsonl");
7234        let (mut session, _selected_tip) = build_branch_heavy_session(&path, 12, 3);
7235        run_async(async { session.save().await }).expect("save branch-heavy session");
7236
7237        let missing_sidecar_trace = run_async(async {
7238            Session::cold_start_trace_bundle(&path, temp.path())
7239                .await
7240                .expect("missing sidecar trace")
7241        });
7242        assert_eq!(missing_sidecar_trace.storage.selected_backend, "jsonl");
7243        assert_eq!(missing_sidecar_trace.storage.opened_backend, "jsonl");
7244        assert_eq!(
7245            missing_sidecar_trace
7246                .replay_minimization
7247                .fallback_behavior
7248                .as_deref(),
7249            Some("jsonl_full_scan_without_sidecar")
7250        );
7251        assert_eq!(
7252            missing_sidecar_trace.replay_minimization.verdict,
7253            "fallback_explicit"
7254        );
7255        assert!(
7256            missing_sidecar_trace
7257                .replay_minimization
7258                .skipped_sibling_entries
7259                > 0
7260        );
7261
7262        let corrupt_path = temp.path().join("branch-corrupt-tail.jsonl");
7263        let (mut corrupt_session, _selected_tip) = build_branch_heavy_session(&corrupt_path, 8, 2);
7264        run_async(async { corrupt_session.save().await }).expect("save corrupt-tail fixture");
7265        {
7266            use std::io::Write as _;
7267
7268            let mut file = std::fs::OpenOptions::new()
7269                .append(true)
7270                .open(&corrupt_path)
7271                .expect("open corrupt-tail fixture");
7272            writeln!(file, "{{invalid-tail-frame").expect("append corrupt tail frame");
7273        }
7274
7275        let corrupt_tail_trace = run_async(async {
7276            Session::cold_start_trace_bundle(&corrupt_path, temp.path())
7277                .await
7278                .expect("corrupt tail trace")
7279        });
7280        assert_eq!(
7281            corrupt_tail_trace.open_diagnostics.skipped_entries, 1,
7282            "corrupt tail frames must be surfaced in cold-start diagnostics"
7283        );
7284        assert_eq!(
7285            corrupt_tail_trace
7286                .replay_minimization
7287                .fallback_behavior
7288                .as_deref(),
7289            Some("corrupt_jsonl_entries_skipped")
7290        );
7291        assert_eq!(
7292            corrupt_tail_trace.replay_minimization.verdict,
7293            "fallback_explicit"
7294        );
7295
7296        create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7297        std::thread::sleep(Duration::from_millis(25));
7298        session.append_message(make_test_message("jsonl-tail-after-sidecar"));
7299        run_async(async { session.save().await }).expect("save stale jsonl tail");
7300
7301        let stale_sidecar_trace = run_async(async {
7302            Session::cold_start_trace_bundle(&path, temp.path())
7303                .await
7304                .expect("stale sidecar trace")
7305        });
7306        assert!(stale_sidecar_trace.storage.v2_sidecar_present);
7307        assert!(stale_sidecar_trace.storage.v2_sidecar_stale);
7308        assert_eq!(stale_sidecar_trace.storage.selected_backend, "jsonl");
7309        assert_eq!(stale_sidecar_trace.storage.opened_backend, "jsonl");
7310        assert_eq!(
7311            stale_sidecar_trace
7312                .replay_minimization
7313                .fallback_behavior
7314                .as_deref(),
7315            Some("v2_sidecar_stale")
7316        );
7317        assert_eq!(
7318            stale_sidecar_trace.replay_minimization.verdict,
7319            "fallback_explicit"
7320        );
7321    }
7322
7323    #[test]
7324    fn test_session_jsonl_serialization() {
7325        let temp = tempfile::tempdir().unwrap();
7326        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7327        session.header.provider = Some("anthropic".to_string());
7328        session.header.model_id = Some("claude-test".to_string());
7329        session.header.thinking_level = Some("medium".to_string());
7330
7331        let user_id = session.append_message(make_test_message("Hello"));
7332        let assistant = AssistantMessage {
7333            content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
7334            api: "anthropic".to_string(),
7335            provider: "anthropic".to_string(),
7336            model: "claude-test".to_string(),
7337            usage: Usage::default(),
7338            stop_reason: StopReason::Stop,
7339            error_message: None,
7340            timestamp: 0,
7341        };
7342        session.append_message(SessionMessage::Assistant { message: assistant });
7343        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
7344        session.append_thinking_level_change("high".to_string());
7345        session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
7346        session.append_branch_summary(user_id, "branch".to_string(), None, None);
7347        session.append_session_info(Some("my-session".to_string()));
7348
7349        run_async(async { session.save().await }).unwrap();
7350
7351        let path = session.path.clone().unwrap();
7352        let contents = std::fs::read_to_string(path).unwrap();
7353        let mut lines = contents.lines();
7354
7355        let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
7356        assert_eq!(header["type"], "session");
7357        assert_eq!(header["version"], SESSION_VERSION);
7358
7359        let mut types = Vec::new();
7360        for line in lines {
7361            let value: serde_json::Value = serde_json::from_str(line).unwrap();
7362            let entry_type = value["type"].as_str().unwrap_or_default().to_string();
7363            types.push(entry_type);
7364        }
7365
7366        assert!(types.contains(&"message".to_string()));
7367        assert!(types.contains(&"model_change".to_string()));
7368        assert!(types.contains(&"thinking_level_change".to_string()));
7369        assert!(types.contains(&"compaction".to_string()));
7370        assert!(types.contains(&"branch_summary".to_string()));
7371        assert!(types.contains(&"session_info".to_string()));
7372    }
7373
7374    #[test]
7375    fn cold_start_trace_bundle_is_bounded_redacted_and_cache_aware() {
7376        let temp = tempdir_under_tmpdir("pi-session-cold-start-");
7377        if let Some(tmpdir) = env::var_os("TMPDIR") {
7378            assert!(temp.path().starts_with(PathBuf::from(tmpdir)));
7379        }
7380
7381        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7382        session.header.cwd = "/private/project/secret-cwd".to_string();
7383        session.header.provider = Some("test-provider".to_string());
7384        session.header.model_id = Some("test-model".to_string());
7385
7386        let mut first_kept_entry_id = None;
7387        for idx in 0..640 {
7388            let text = if idx == 7 {
7389                "secret-user-message should not appear in trace".to_string()
7390            } else {
7391                format!("large-history-message-{idx}")
7392            };
7393            let id = session.append_message(make_test_message(&text));
7394            if idx == 512 {
7395                first_kept_entry_id = Some(id);
7396            }
7397            if idx % 128 == 0 {
7398                session.append_message(make_test_assistant_message(
7399                    &format!("secret-assistant-message-{idx}"),
7400                    32,
7401                ));
7402            }
7403        }
7404
7405        session.append_compaction(
7406            "secret compaction summary should not appear in trace".to_string(),
7407            first_kept_entry_id.expect("first kept entry id"),
7408            12_345,
7409            None,
7410            None,
7411        );
7412        for idx in 0..16 {
7413            session.append_message(make_test_message(&format!("tail-message-{idx}")));
7414        }
7415
7416        run_async(async { session.save().await }).expect("save large session");
7417        let path = session.path.clone().expect("session path");
7418
7419        let first_trace = run_async(async {
7420            Session::cold_start_trace_bundle(&path, temp.path())
7421                .await
7422                .expect("first cold-start trace")
7423        });
7424        let second_trace = run_async(async {
7425            Session::cold_start_trace_bundle(&path, temp.path())
7426                .await
7427                .expect("second cold-start trace")
7428        });
7429
7430        assert_eq!(first_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7431        assert_eq!(second_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7432        assert_eq!(second_trace.storage.selected_backend, "jsonl");
7433        assert_eq!(second_trace.storage.opened_backend, "jsonl");
7434        assert!(second_trace.index_refresh.scanned_files >= 1);
7435        assert!(second_trace.index_refresh.cache_hit_files >= 1);
7436        assert_eq!(
7437            second_trace.index_refresh.cache_hit_files,
7438            second_trace.index_refresh.reused_files
7439        );
7440        assert_eq!(
7441            second_trace.open_diagnostics,
7442            SessionColdStartOpenDiagnosticsTrace {
7443                skipped_entries: 0,
7444                orphaned_parent_links: 0,
7445            }
7446        );
7447        assert!(second_trace.compaction_scan.latest_compaction_present);
7448        assert_eq!(
7449            second_trace.compaction_scan.first_kept_entry_found,
7450            Some(true)
7451        );
7452        assert!(second_trace.first_render.ready);
7453        assert!(second_trace.first_render.current_path_entries >= 640);
7454        assert!(second_trace.first_render.projected_messages >= 640);
7455        assert!(second_trace.first_render.total_tokens >= 32);
7456
7457        let phase_names = second_trace
7458            .phases
7459            .iter()
7460            .map(|phase| phase.name.as_str())
7461            .collect::<Vec<_>>();
7462        assert_eq!(
7463            phase_names,
7464            vec![
7465                "session_open",
7466                "session_index_refresh",
7467                "compaction_scan",
7468                "first_render_ready",
7469            ]
7470        );
7471        assert!(second_trace.phases.len() <= second_trace.bounds.max_phase_count);
7472        assert!(!second_trace.bounds.raw_path_included);
7473        assert!(!second_trace.bounds.raw_cwd_included);
7474        assert!(!second_trace.bounds.raw_message_content_included);
7475
7476        let serialized = serde_json::to_string(&second_trace).expect("serialize trace");
7477        assert!(!serialized.contains("secret-user-message"));
7478        assert!(!serialized.contains("secret-assistant-message"));
7479        assert!(!serialized.contains("secret compaction summary"));
7480        assert!(!serialized.contains("secret-cwd"));
7481        assert!(!serialized.contains(&path.display().to_string()));
7482        assert!(!serialized.contains(&temp.path().display().to_string()));
7483        assert_eq!(second_trace.session_path_hash.len(), 16);
7484    }
7485
7486    #[test]
7487    fn test_save_handles_short_or_empty_session_id() {
7488        let temp = tempfile::tempdir().unwrap();
7489        let project_cwd = temp.path().join("project");
7490        std::fs::create_dir(&project_cwd).expect("create project cwd");
7491        let project_cwd = project_cwd.display().to_string();
7492
7493        let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7494        short_id_session.header.cwd.clone_from(&project_cwd);
7495        short_id_session.header.id = "x".to_string();
7496        run_async(async { short_id_session.save().await }).expect("save with short id");
7497        let short_name = short_id_session
7498            .path
7499            .as_ref()
7500            .and_then(|p| p.file_name())
7501            .and_then(|n| n.to_str())
7502            .expect("short id filename");
7503        assert!(short_name.contains("_x."));
7504
7505        let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7506        empty_id_session.header.cwd.clone_from(&project_cwd);
7507        empty_id_session.header.id.clear();
7508        run_async(async { empty_id_session.save().await }).expect("save with empty id");
7509        let empty_name = empty_id_session
7510            .path
7511            .as_ref()
7512            .and_then(|p| p.file_name())
7513            .and_then(|n| n.to_str())
7514            .expect("empty id filename");
7515        assert!(empty_name.contains("_session."));
7516
7517        let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7518        unsafe_id_session.header.cwd.clone_from(&project_cwd);
7519        unsafe_id_session.header.id = "../etc/passwd".to_string();
7520        run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
7521        let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
7522        let unsafe_name = unsafe_path
7523            .file_name()
7524            .and_then(|n| n.to_str())
7525            .expect("unsafe id filename");
7526        assert!(unsafe_name.contains("____etc_p."));
7527        let expected_dir = temp.path().join(encode_cwd(Path::new(&project_cwd)));
7528        assert_eq!(
7529            unsafe_path.parent().expect("unsafe id parent"),
7530            expected_dir.as_path()
7531        );
7532    }
7533
7534    #[test]
7535    fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
7536        let temp = tempfile::tempdir().unwrap();
7537        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7538
7539        let first_id = session.append_message(make_test_message("Hello"));
7540        let second_id = session.append_message(make_test_message("World"));
7541        assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
7542
7543        run_async(async { session.save().await }).unwrap();
7544        let path = session.path.clone().expect("session path set");
7545
7546        let mut lines = std::fs::read_to_string(&path)
7547            .expect("read session")
7548            .lines()
7549            .map(str::to_string)
7550            .collect::<Vec<_>>();
7551        assert!(lines.len() >= 3, "expected header + 2 entries");
7552
7553        let corrupted_line_number = lines.len(); // 1-based
7554        let last_index = lines.len() - 1;
7555        lines[last_index] = "{ this is not json }".to_string();
7556
7557        let corrupted_path = temp.path().join("corrupted.jsonl");
7558        std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
7559            .expect("write corrupted session");
7560
7561        let (loaded, diagnostics) = run_async(async {
7562            Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
7563        })
7564        .expect("open corrupted session");
7565
7566        assert_eq!(diagnostics.skipped_entries.len(), 1);
7567        assert_eq!(
7568            diagnostics.skipped_entries[0].line_number,
7569            corrupted_line_number
7570        );
7571
7572        let warnings = diagnostics.warning_lines();
7573        assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
7574        assert!(
7575            warnings[0].starts_with(&format!(
7576                "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
7577            )),
7578            "unexpected warning: {}",
7579            warnings[0]
7580        );
7581        assert_eq!(
7582            warnings[1],
7583            "Warning: Skipped 1 corrupted entries while loading session"
7584        );
7585
7586        assert_eq!(
7587            loaded.entries.len(),
7588            session.entries.len() - 1,
7589            "expected last entry to be dropped"
7590        );
7591        assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
7592    }
7593
7594    #[test]
7595    fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
7596        let temp = tempfile::tempdir().unwrap();
7597        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7598
7599        let root_id = session.append_message(make_test_message("Hello"));
7600        session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
7601        session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
7602
7603        run_async(async { session.save().await }).unwrap();
7604        let path = session.path.clone().expect("session path set");
7605
7606        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7607            .expect("reopen session");
7608
7609        assert!(loaded.entries.iter().any(|entry| {
7610            matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary.eq("compacted") && compaction.tokens_before.eq(&123))
7611        }));
7612        assert!(loaded.entries.iter().any(|entry| {
7613            matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary.eq("branch summary"))
7614        }));
7615
7616        let html = loaded.to_html();
7617        assert!(html.contains("compacted"));
7618        assert!(html.contains("branch summary"));
7619    }
7620
7621    #[test]
7622    fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
7623        let temp = tempfile::tempdir().unwrap();
7624        let base_dir = temp.path().join("sessions");
7625
7626        let mut session = Session::create_with_dir(Some(base_dir));
7627        session.append_message(make_test_message("Hello"));
7628
7629        run_async(async { session.save().await }).expect("initial save");
7630        let path = session.path.clone().expect("session path set");
7631
7632        let path1 = path.clone();
7633        let path2 = path.clone();
7634
7635        let t1 = std::thread::spawn(move || {
7636            let runtime = RuntimeBuilder::current_thread()
7637                .build()
7638                .expect("build runtime");
7639            runtime.block_on(async move {
7640                let mut s = Session::open(path1.to_string_lossy().as_ref())
7641                    .await
7642                    .expect("open session");
7643                s.append_message(make_test_message("From thread 1"));
7644                s.save().await
7645            })
7646        });
7647
7648        let t2 = std::thread::spawn(move || {
7649            let runtime = RuntimeBuilder::current_thread()
7650                .build()
7651                .expect("build runtime");
7652            runtime.block_on(async move {
7653                let mut s = Session::open(path2.to_string_lossy().as_ref())
7654                    .await
7655                    .expect("open session");
7656                s.append_message(make_test_message("From thread 2"));
7657                s.save().await
7658            })
7659        });
7660
7661        let r1 = t1.join().expect("thread 1 join");
7662        let r2 = t2.join().expect("thread 2 join");
7663        assert!(
7664            r1.is_ok() || r2.is_ok(),
7665            "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
7666        );
7667
7668        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7669            .expect("open after concurrent saves");
7670        assert!(!loaded.entries.is_empty());
7671    }
7672
7673    #[test]
7674    fn test_to_messages_for_current_path() {
7675        let mut session = Session::in_memory();
7676
7677        // Tree structure:
7678        // A -> B -> C
7679        //       \-> D  (D branches from B)
7680        let _id_a = session.append_message(make_test_message("A"));
7681        let id_b = session.append_message(make_test_message("B"));
7682        let _id_c = session.append_message(make_test_message("C"));
7683
7684        // Navigate to B and add D
7685        session.create_branch_from(&id_b);
7686        let id_d = session.append_message(make_test_message("D"));
7687
7688        // Current path should be A -> B -> D
7689        session.navigate_to(&id_d);
7690        let messages = session.to_messages_for_current_path();
7691        assert_eq!(messages.len(), 3);
7692
7693        // Verify content
7694        if let Message::User(user) = &messages[0] {
7695            if let UserContent::Text(text) = &user.content {
7696                assert_eq!(text, "A");
7697            }
7698        }
7699        if let Message::User(user) = &messages[2] {
7700            if let UserContent::Text(text) = &user.content {
7701                assert_eq!(text, "D");
7702            }
7703        }
7704    }
7705
7706    #[test]
7707    fn test_reset_leaf_produces_empty_current_path() {
7708        let mut session = Session::in_memory();
7709
7710        let _id_a = session.append_message(make_test_message("A"));
7711        let _id_b = session.append_message(make_test_message("B"));
7712
7713        session.reset_leaf();
7714        assert!(session.entries_for_current_path().is_empty());
7715        assert!(session.to_messages_for_current_path().is_empty());
7716
7717        // After reset, the next entry becomes a new root.
7718        let id_root = session.append_message(make_test_message("Root"));
7719        let entry = session.get_entry(&id_root).expect("entry");
7720        assert!(entry.base().parent_id.is_none());
7721    }
7722
7723    #[test]
7724    fn test_encode_cwd() {
7725        let path = std::path::Path::new("/home/user/project");
7726        let encoded = encode_cwd(path);
7727        assert!(encoded.starts_with("--"));
7728        assert!(encoded.ends_with("--"));
7729        assert!(encoded.contains("home-user-project"));
7730    }
7731
7732    // ======================================================================
7733    // Session creation and header validation
7734    // ======================================================================
7735
7736    #[test]
7737    fn test_session_header_defaults() {
7738        let header = SessionHeader::new();
7739        assert_eq!(header.r#type, "session");
7740        assert_eq!(header.version, Some(SESSION_VERSION));
7741        assert!(!header.id.is_empty());
7742        assert!(!header.timestamp.is_empty());
7743        assert!(header.provider.is_none());
7744        assert!(header.model_id.is_none());
7745        assert!(header.thinking_level.is_none());
7746        assert!(header.parent_session.is_none());
7747    }
7748
7749    #[test]
7750    fn test_session_create_produces_unique_ids() {
7751        let s1 = Session::create();
7752        let s2 = Session::create();
7753        assert_ne!(s1.header.id, s2.header.id);
7754    }
7755
7756    #[test]
7757    fn test_in_memory_session_has_no_path() {
7758        let session = Session::in_memory();
7759        assert!(session.path.is_none());
7760        assert!(session.leaf_id.is_none());
7761        assert!(session.entries.is_empty());
7762    }
7763
7764    #[test]
7765    fn test_create_with_dir_stores_session_dir() {
7766        let temp = tempfile::tempdir().unwrap();
7767        let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7768        assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
7769    }
7770
7771    // ======================================================================
7772    // Message types: tool result, bash execution, custom
7773    // ======================================================================
7774
7775    #[test]
7776    fn test_append_tool_result_message() {
7777        let mut session = Session::in_memory();
7778        let user_id = session.append_message(make_test_message("Hello"));
7779
7780        let tool_msg = SessionMessage::ToolResult {
7781            tool_call_id: "call_123".to_string(),
7782            tool_name: "read".to_string(),
7783            content: vec![ContentBlock::Text(TextContent::new("file contents"))],
7784            details: None,
7785            is_error: false,
7786            timestamp: Some(1000),
7787        };
7788        let tool_id = session.append_message(tool_msg);
7789
7790        // Verify parent linking
7791        let entry = session.get_entry(&tool_id).unwrap();
7792        assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
7793
7794        // Verify it converts to model message
7795        let messages = session.to_messages();
7796        assert_eq!(messages.len(), 2);
7797        assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id.eq("call_123")));
7798    }
7799
7800    #[test]
7801    fn test_tool_result_artifact_metadata_round_trip_without_full_payload() {
7802        let temp = tempfile::tempdir().unwrap();
7803        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7804        session.append_message(make_test_message("list the huge directory"));
7805
7806        let omitted_payload = "x".repeat(1024);
7807        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.]";
7808        session.append_message(SessionMessage::ToolResult {
7809            tool_call_id: "call_artifact".to_string(),
7810            tool_name: "ls".to_string(),
7811            content: vec![ContentBlock::Text(TextContent::new(preview))],
7812            details: Some(serde_json::json!({
7813                "artifact": {
7814                    "schema": "pi.tool_output_artifact.v1",
7815                    "id": "tool-artifact-abc",
7816                    "toolName": "ls",
7817                    "sourceKind": "directoryEntries",
7818                    "path": "/tmp/pi-tool-artifacts/call/abc.txt",
7819                    "metadataPath": "/tmp/pi-tool-artifacts/call/abc.json",
7820                    "sha256": "abc",
7821                    "byteCount": 5_000_000_u64,
7822                    "lineCount": 50_000,
7823                    "previewBytes": preview.len(),
7824                    "contentType": "text/plain; charset=utf-8"
7825                }
7826            })),
7827            is_error: false,
7828            timestamp: Some(12346),
7829        });
7830
7831        run_async(async { session.save().await }).unwrap();
7832        let path = session.path.clone().unwrap();
7833        let jsonl = std::fs::read_to_string(&path).unwrap();
7834        assert!(jsonl.contains("\"schema\":\"pi.tool_output_artifact.v1\""));
7835        assert!(!jsonl.contains(&omitted_payload));
7836
7837        let loaded =
7838            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7839        let tool_result = loaded
7840            .to_messages()
7841            .into_iter()
7842            .find_map(|message| match message {
7843                Message::ToolResult(result) if result.tool_call_id.eq("call_artifact") => {
7844                    Some(result)
7845                }
7846                _ => None,
7847            })
7848            .expect("artifact tool result");
7849
7850        assert_eq!(
7851            tool_result
7852                .details
7853                .as_ref()
7854                .and_then(|details| details.pointer("/artifact/schema"))
7855                .and_then(Value::as_str),
7856            Some("pi.tool_output_artifact.v1")
7857        );
7858        assert!(tool_result.content.iter().all(|block| match block {
7859            ContentBlock::Text(text) => !text.text.contains(&omitted_payload),
7860            _ => true,
7861        }));
7862    }
7863
7864    #[test]
7865    fn test_append_tool_result_error() {
7866        let mut session = Session::in_memory();
7867        session.append_message(make_test_message("Hello"));
7868
7869        let tool_msg = SessionMessage::ToolResult {
7870            tool_call_id: "call_err".to_string(),
7871            tool_name: "bash".to_string(),
7872            content: vec![ContentBlock::Text(TextContent::new("command not found"))],
7873            details: None,
7874            is_error: true,
7875            timestamp: Some(2000),
7876        };
7877        let tool_id = session.append_message(tool_msg);
7878
7879        let entry = session.get_entry(&tool_id).expect("should find tool entry");
7880        if let SessionEntry::Message(msg) = entry {
7881            if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
7882                assert!(is_error);
7883            } else {
7884                test_fail!("Expected SessionMessage::ToolResult, got {:?}", msg.message);
7885            }
7886        } else {
7887            test_fail!("Expected SessionEntry::Message");
7888        }
7889    }
7890
7891    #[test]
7892    fn test_append_bash_execution() {
7893        let mut session = Session::in_memory();
7894        session.append_message(make_test_message("run something"));
7895
7896        let bash_id = session.append_bash_execution(
7897            "echo hello".to_string(),
7898            "hello\n".to_string(),
7899            0,
7900            false,
7901            false,
7902            None,
7903        );
7904
7905        let entry = session.get_entry(&bash_id).expect("should find bash entry");
7906        if let SessionEntry::Message(msg) = entry {
7907            if let SessionMessage::BashExecution {
7908                command, exit_code, ..
7909            } = &msg.message
7910            {
7911                assert_eq!(command, "echo hello");
7912                assert_eq!(*exit_code, 0);
7913            } else {
7914                test_fail!(
7915                    "Expected SessionMessage::BashExecution, got {:?}",
7916                    msg.message
7917                );
7918            }
7919        } else {
7920            test_fail!("Expected SessionEntry::Message");
7921        }
7922
7923        // BashExecution converts to User message for model context
7924        let messages = session.to_messages();
7925        assert_eq!(messages.len(), 2);
7926        assert!(matches!(&messages[1], Message::User(_)));
7927    }
7928
7929    #[test]
7930    fn test_bash_execution_exclude_from_context() {
7931        let mut session = Session::in_memory();
7932        session.append_message(make_test_message("run something"));
7933
7934        let id = session.next_entry_id();
7935        let base = EntryBase::new(session.leaf_id.clone(), id.clone());
7936        let mut extra = HashMap::new();
7937        extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
7938        let entry = SessionEntry::Message(MessageEntry {
7939            base,
7940            message: SessionMessage::BashExecution {
7941                command: "secret".to_string(),
7942                output: "hidden".to_string(),
7943                exit_code: 0,
7944                cancelled: None,
7945                truncated: None,
7946                full_output_path: None,
7947                timestamp: Some(0),
7948                extra,
7949            },
7950        });
7951        session.leaf_id = Some(id);
7952        session.entries.push(entry);
7953        session.entry_ids = entry_id_set(&session.entries);
7954
7955        // The excluded bash execution should not appear in model messages
7956        let messages = session.to_messages();
7957        assert_eq!(messages.len(), 1); // only the user message
7958    }
7959
7960    #[test]
7961    fn test_append_custom_message() {
7962        let mut session = Session::in_memory();
7963        session.append_message(make_test_message("Hello"));
7964
7965        let custom_msg = SessionMessage::Custom {
7966            custom_type: "extension_state".to_string(),
7967            content: "some state".to_string(),
7968            display: false,
7969            details: Some(serde_json::json!({"key": "value"})),
7970            timestamp: Some(0),
7971        };
7972        let custom_id = session.append_message(custom_msg);
7973
7974        let entry = session
7975            .get_entry(&custom_id)
7976            .expect("should find custom entry");
7977        if let SessionEntry::Message(msg) = entry {
7978            if let SessionMessage::Custom {
7979                custom_type,
7980                display,
7981                ..
7982            } = &msg.message
7983            {
7984                assert_eq!(custom_type, "extension_state");
7985                assert!(!display);
7986            } else {
7987                test_fail!("Expected SessionMessage::Custom, got {:?}", msg.message);
7988            }
7989        } else {
7990            test_fail!("Expected SessionEntry::Message");
7991        }
7992    }
7993
7994    #[test]
7995    fn test_append_custom_entry() {
7996        let mut session = Session::in_memory();
7997        let root_id = session.append_message(make_test_message("Hello"));
7998
7999        let custom_id =
8000            session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
8001
8002        let entry = session
8003            .get_entry(&custom_id)
8004            .expect("should find custom entry");
8005        if let SessionEntry::Custom(custom) = entry {
8006            assert_eq!(custom.custom_type, "my_type");
8007            assert_eq!(custom.data, Some(serde_json::json!(42)));
8008            assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
8009        } else {
8010            test_fail!("Expected SessionEntry::Custom, got {:?}", entry);
8011        }
8012    }
8013
8014    // ======================================================================
8015    // Parent linking / tree structure
8016    // ======================================================================
8017
8018    #[test]
8019    fn test_parent_linking_chain() {
8020        let mut session = Session::in_memory();
8021
8022        let id1 = session.append_message(make_test_message("A"));
8023        let id2 = session.append_message(make_test_message("B"));
8024        let id3 = session.append_message(make_test_message("C"));
8025
8026        // First entry has no parent
8027        let e1 = session.get_entry(&id1).unwrap();
8028        assert!(e1.base().parent_id.is_none());
8029
8030        // Second entry's parent is first
8031        let e2 = session.get_entry(&id2).unwrap();
8032        assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
8033
8034        // Third entry's parent is second
8035        let e3 = session.get_entry(&id3).unwrap();
8036        assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
8037    }
8038
8039    #[test]
8040    fn test_model_change_updates_leaf() {
8041        let mut session = Session::in_memory();
8042
8043        let msg_id = session.append_message(make_test_message("Hello"));
8044        let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
8045
8046        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
8047
8048        let entry = session
8049            .get_entry(&change_id)
8050            .expect("should find change entry");
8051        assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
8052
8053        if let SessionEntry::ModelChange(mc) = entry {
8054            assert_eq!(mc.provider, "openai");
8055            assert_eq!(mc.model_id, "gpt-4");
8056        } else {
8057            test_fail!("Expected SessionEntry::ModelChange, got {:?}", entry);
8058        }
8059    }
8060
8061    #[test]
8062    fn test_thinking_level_change_updates_leaf() {
8063        let mut session = Session::in_memory();
8064        session.append_message(make_test_message("Hello"));
8065
8066        let change_id = session.append_thinking_level_change("high".to_string());
8067        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
8068
8069        let entry = session
8070            .get_entry(&change_id)
8071            .expect("should find change entry");
8072        if let SessionEntry::ThinkingLevelChange(tlc) = entry {
8073            assert_eq!(tlc.thinking_level, "high");
8074        } else {
8075            test_fail!(
8076                "Expected SessionEntry::ThinkingLevelChange, got {:?}",
8077                entry
8078            );
8079        }
8080    }
8081
8082    // ======================================================================
8083    // Session name get/set
8084    // ======================================================================
8085
8086    #[test]
8087    fn test_get_name_returns_latest() {
8088        let mut session = Session::in_memory();
8089
8090        assert!(session.get_name().is_none());
8091
8092        session.set_name("first");
8093        assert_eq!(session.get_name().as_deref(), Some("first"));
8094
8095        session.set_name("second");
8096        assert_eq!(session.get_name().as_deref(), Some("second"));
8097    }
8098
8099    #[test]
8100    fn test_set_name_returns_entry_id() {
8101        let mut session = Session::in_memory();
8102        let id = session.set_name("test-name");
8103        assert!(!id.is_empty());
8104        let entry = session.get_entry(&id).unwrap();
8105        assert!(matches!(entry, SessionEntry::SessionInfo(_)));
8106    }
8107
8108    // ======================================================================
8109    // Label
8110    // ======================================================================
8111
8112    #[test]
8113    fn test_add_label_to_existing_entry() {
8114        let mut session = Session::in_memory();
8115        let msg_id = session.append_message(make_test_message("Hello"));
8116
8117        let label_id = session.add_label(&msg_id, Some("important".to_string()));
8118        assert!(label_id.is_some());
8119
8120        let entry = session
8121            .get_entry(&label_id.unwrap())
8122            .expect("should find label entry");
8123        if let SessionEntry::Label(label) = entry {
8124            assert_eq!(label.target_id, msg_id);
8125            assert_eq!(label.label.as_deref(), Some("important"));
8126        } else {
8127            test_fail!("Expected SessionEntry::Label, got {:?}", entry);
8128        }
8129    }
8130
8131    #[test]
8132    fn test_add_label_to_nonexistent_entry_returns_none() {
8133        let mut session = Session::in_memory();
8134        let result = session.add_label("nonexistent", Some("label".to_string()));
8135        assert!(result.is_none());
8136    }
8137
8138    // ======================================================================
8139    // JSONL round-trip (save + reload)
8140    // ======================================================================
8141
8142    #[test]
8143    fn test_round_trip_preserves_all_message_types() {
8144        let temp = tempfile::tempdir().unwrap();
8145        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8146
8147        // Append diverse message types
8148        session.append_message(make_test_message("user text"));
8149
8150        let assistant = AssistantMessage {
8151            content: vec![ContentBlock::Text(TextContent::new("response"))],
8152            api: "anthropic".to_string(),
8153            provider: "anthropic".to_string(),
8154            model: "claude-test".to_string(),
8155            usage: Usage::default(),
8156            stop_reason: StopReason::Stop,
8157            error_message: None,
8158            timestamp: 0,
8159        };
8160        session.append_message(SessionMessage::Assistant { message: assistant });
8161
8162        session.append_message(SessionMessage::ToolResult {
8163            tool_call_id: "call_1".to_string(),
8164            tool_name: "read".to_string(),
8165            content: vec![ContentBlock::Text(TextContent::new("result"))],
8166            details: None,
8167            is_error: false,
8168            timestamp: Some(100),
8169        });
8170
8171        session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
8172
8173        session.append_custom_entry(
8174            "ext_data".to_string(),
8175            Some(serde_json::json!({"foo": "bar"})),
8176        );
8177
8178        run_async(async { session.save().await }).unwrap();
8179        let path = session.path.clone().unwrap();
8180
8181        let loaded =
8182            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8183
8184        assert_eq!(loaded.entries.len(), session.entries.len());
8185        assert_eq!(loaded.header.id, session.header.id);
8186        assert_eq!(loaded.header.version, Some(SESSION_VERSION));
8187
8188        // Verify specific entry types survived the round-trip
8189        let has_tool_result = loaded.entries.iter().any(|e| {
8190            matches!(
8191                e,
8192                SessionEntry::Message(m) if matches!(
8193                    &m.message,
8194                    SessionMessage::ToolResult { tool_name, .. } if tool_name.eq("read")
8195                )
8196            )
8197        });
8198        assert!(has_tool_result, "tool result should survive round-trip");
8199
8200        let has_bash = loaded.entries.iter().any(|e| {
8201            matches!(
8202                e,
8203                SessionEntry::Message(m) if matches!(
8204                    &m.message,
8205                    SessionMessage::BashExecution { command, .. } if command.eq("ls")
8206                )
8207            )
8208        });
8209        assert!(has_bash, "bash execution should survive round-trip");
8210
8211        let has_custom = loaded.entries.iter().any(|e| {
8212            matches!(
8213                e,
8214                SessionEntry::Custom(c) if c.custom_type.eq("ext_data")
8215            )
8216        });
8217        assert!(has_custom, "custom entry should survive round-trip");
8218    }
8219
8220    #[test]
8221    fn test_round_trip_preserves_leaf_id() {
8222        let temp = tempfile::tempdir().unwrap();
8223        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8224
8225        let _id1 = session.append_message(make_test_message("A"));
8226        let id2 = session.append_message(make_test_message("B"));
8227
8228        run_async(async { session.save().await }).unwrap();
8229        let path = session.path.clone().unwrap();
8230
8231        let loaded =
8232            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8233
8234        assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
8235    }
8236
8237    #[test]
8238    fn test_round_trip_preserves_selected_branch_leaf_and_header_state() {
8239        let temp = tempfile::tempdir().unwrap();
8240        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8241
8242        let root_id = session.append_message(make_test_message("root"));
8243        let _openai_model =
8244            session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
8245        session.set_model_header(
8246            Some("openai".to_string()),
8247            Some("gpt-5.4".to_string()),
8248            None,
8249        );
8250        let high_id = session.append_thinking_level_change("high".to_string());
8251        session.set_model_header(None, None, Some("high".to_string()));
8252
8253        assert!(session.create_branch_from(&root_id));
8254        let _anthropic_model =
8255            session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
8256        session.set_model_header(
8257            Some("anthropic".to_string()),
8258            Some("claude-sonnet-4".to_string()),
8259            None,
8260        );
8261        session.append_thinking_level_change("medium".to_string());
8262        session.set_model_header(None, None, Some("medium".to_string()));
8263
8264        assert!(session.navigate_to(&high_id));
8265
8266        run_async(async { session.save().await }).unwrap();
8267        let path = session.path.clone().unwrap();
8268
8269        let loaded =
8270            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8271
8272        assert_eq!(loaded.leaf_id.as_deref(), Some(high_id.as_str()));
8273        assert_eq!(
8274            loaded.header.current_leaf.as_deref(),
8275            Some(high_id.as_str())
8276        );
8277        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
8278        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
8279        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8280    }
8281
8282    #[test]
8283    fn test_append_after_branch_navigation_clears_persisted_leaf_override() {
8284        let temp = tempfile::tempdir().unwrap();
8285        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8286
8287        let id_a = session.append_message(make_test_message("A"));
8288        let id_b = session.append_message(make_test_message("B"));
8289        session.append_message(make_test_message("C"));
8290
8291        assert!(session.create_branch_from(&id_a));
8292        session.append_message(make_test_message("D"));
8293
8294        assert!(session.navigate_to(&id_b));
8295        let id_e = session.append_message(make_test_message("E"));
8296
8297        run_async(async { session.save().await }).unwrap();
8298        let path = session.path.clone().unwrap();
8299
8300        let loaded =
8301            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8302
8303        assert_eq!(loaded.leaf_id.as_deref(), Some(id_e.as_str()));
8304        assert!(loaded.header.current_leaf.is_none());
8305    }
8306
8307    #[test]
8308    fn test_round_trip_preserves_header_fields() {
8309        let temp = tempfile::tempdir().unwrap();
8310        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8311        session.header.provider = Some("anthropic".to_string());
8312        session.header.model_id = Some("claude-opus".to_string());
8313        session.header.thinking_level = Some("high".to_string());
8314        session.header.parent_session = Some("/old/session.jsonl".to_string());
8315
8316        session.append_message(make_test_message("Hello"));
8317        run_async(async { session.save().await }).unwrap();
8318        let path = session.path.clone().unwrap();
8319
8320        let loaded =
8321            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8322
8323        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
8324        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
8325        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8326        assert_eq!(
8327            loaded.header.parent_session.as_deref(),
8328            Some("/old/session.jsonl")
8329        );
8330    }
8331
8332    #[test]
8333    fn test_empty_session_save_and_reload() {
8334        let temp = tempfile::tempdir().unwrap();
8335        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8336
8337        run_async(async { session.save().await }).unwrap();
8338        let path = session.path.clone().unwrap();
8339
8340        let loaded =
8341            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8342
8343        assert!(loaded.entries.is_empty());
8344        assert!(loaded.leaf_id.is_none());
8345        assert_eq!(loaded.header.id, session.header.id);
8346    }
8347
8348    // ======================================================================
8349    // Corrupted JSONL recovery
8350    // ======================================================================
8351
8352    #[test]
8353    fn test_corrupted_middle_entry_preserves_surrounding_entries() {
8354        let temp = tempfile::tempdir().unwrap();
8355        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8356
8357        let id1 = session.append_message(make_test_message("First"));
8358        let id2 = session.append_message(make_test_message("Second"));
8359        let id3 = session.append_message(make_test_message("Third"));
8360
8361        run_async(async { session.save().await }).unwrap();
8362        let path = session.path.clone().unwrap();
8363
8364        // Corrupt the middle entry (line 3, 1-indexed: header=1, first=2, second=3)
8365        let mut lines: Vec<String> = std::fs::read_to_string(&path)
8366            .unwrap()
8367            .lines()
8368            .map(str::to_string)
8369            .collect();
8370        assert!(lines.len() >= 4);
8371        lines[2] = "GARBAGE JSON".to_string();
8372        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8373
8374        let (loaded, diagnostics) = run_async(async {
8375            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8376        })
8377        .unwrap();
8378
8379        let diag = serde_json::json!({
8380            "fixture_id": "session-corrupted-middle-entry-replay-integrity",
8381            "path": path.display().to_string(),
8382            "seed": "deterministic-static",
8383            "env": {
8384                "os": std::env::consts::OS,
8385                "arch": std::env::consts::ARCH,
8386            },
8387            "expected": {
8388                "skipped_entries": 1,
8389                "orphaned_parent_links": 1,
8390            },
8391            "actual": {
8392                "skipped_entries": diagnostics.skipped_entries.len(),
8393                "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
8394                "leaf_id": loaded.leaf_id,
8395            },
8396        })
8397        .to_string();
8398
8399        assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
8400        assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
8401        assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
8402        assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
8403        assert_eq!(
8404            diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
8405            "{diag}"
8406        );
8407        assert!(
8408            diagnostics.warning_lines().iter().any(|line| {
8409                line.contains("references missing parent")
8410                    && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
8411            }),
8412            "{diag}"
8413        );
8414
8415        // First and third entries should survive
8416        assert_eq!(loaded.entries.len(), 2, "{diag}");
8417        assert!(loaded.get_entry(&id1).is_some(), "{diag}");
8418        assert!(loaded.get_entry(&id3).is_some(), "{diag}");
8419    }
8420
8421    #[test]
8422    fn test_multiple_corrupted_entries_recovery() {
8423        let temp = tempfile::tempdir().unwrap();
8424        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8425
8426        session.append_message(make_test_message("A"));
8427        session.append_message(make_test_message("B"));
8428        session.append_message(make_test_message("C"));
8429        session.append_message(make_test_message("D"));
8430
8431        run_async(async { session.save().await }).unwrap();
8432        let path = session.path.clone().unwrap();
8433
8434        let mut lines: Vec<String> = std::fs::read_to_string(&path)
8435            .unwrap()
8436            .lines()
8437            .map(str::to_string)
8438            .collect();
8439        // Corrupt entries B (line 3) and D (line 5)
8440        lines[2] = "BAD".to_string();
8441        lines[4] = "ALSO BAD".to_string();
8442        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8443
8444        let (loaded, diagnostics) = run_async(async {
8445            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8446        })
8447        .unwrap();
8448
8449        assert_eq!(diagnostics.skipped_entries.len(), 2);
8450        assert_eq!(loaded.entries.len(), 2); // A and C survive
8451    }
8452
8453    #[test]
8454    fn test_corrupted_header_fails_to_open() {
8455        let temp = tempfile::tempdir().unwrap();
8456        let path = temp.path().join("bad_header.jsonl");
8457        std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
8458
8459        let result = run_async(async {
8460            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8461        });
8462        assert!(
8463            result.is_err(),
8464            "corrupted header should cause open failure"
8465        );
8466    }
8467
8468    // ======================================================================
8469    // Branching and navigation
8470    // ======================================================================
8471
8472    #[test]
8473    fn test_create_branch_from_nonexistent_returns_false() {
8474        let mut session = Session::in_memory();
8475        session.append_message(make_test_message("A"));
8476        assert!(!session.create_branch_from("nonexistent"));
8477    }
8478
8479    #[test]
8480    fn test_deep_branching() {
8481        let mut session = Session::in_memory();
8482
8483        // Create A -> B -> C
8484        let id_a = session.append_message(make_test_message("A"));
8485        let id_b = session.append_message(make_test_message("B"));
8486        let _id_c = session.append_message(make_test_message("C"));
8487
8488        // Branch from A: A -> D
8489        session.create_branch_from(&id_a);
8490        let _id_d = session.append_message(make_test_message("D"));
8491
8492        // Branch from B: A -> B -> E
8493        session.create_branch_from(&id_b);
8494        let id_e = session.append_message(make_test_message("E"));
8495
8496        // Should have 3 leaves: C, D, E
8497        let leaves = session.list_leaves();
8498        assert_eq!(leaves.len(), 3);
8499
8500        // Path to E is A -> B -> E
8501        let path = session.get_path_to_entry(&id_e);
8502        assert_eq!(path.len(), 3);
8503        assert_eq!(path[0], id_a);
8504        assert_eq!(path[1], id_b);
8505        assert_eq!(path[2], id_e);
8506    }
8507
8508    #[test]
8509    fn test_sibling_branches_at_fork() {
8510        let mut session = Session::in_memory();
8511
8512        // Create A -> B -> C
8513        let id_a = session.append_message(make_test_message("A"));
8514        let _id_b = session.append_message(make_test_message("B"));
8515        let _id_c = session.append_message(make_test_message("C"));
8516
8517        // Branch from A: A -> D
8518        session.create_branch_from(&id_a);
8519        let id_d = session.append_message(make_test_message("D"));
8520
8521        // Navigate to D to make it current
8522        session.navigate_to(&id_d);
8523
8524        let siblings = session.sibling_branches();
8525        assert!(siblings.is_some());
8526        let (fork_point, branches) = siblings.unwrap();
8527        assert!(
8528            fork_point.is_none()
8529                || fork_point
8530                    .as_deref()
8531                    .is_some_and(|fork_point_id| fork_point_id.eq(id_a.as_str()))
8532        );
8533        assert_eq!(branches.len(), 2);
8534
8535        // One should be current, one not
8536        let current_count = branches.iter().filter(|b| b.is_current).count();
8537        assert_eq!(current_count, 1);
8538    }
8539
8540    #[test]
8541    fn test_sibling_branches_no_fork() {
8542        let mut session = Session::in_memory();
8543        session.append_message(make_test_message("A"));
8544        session.append_message(make_test_message("B"));
8545
8546        // No fork points, so sibling_branches returns None
8547        assert!(session.sibling_branches().is_none());
8548    }
8549
8550    // ======================================================================
8551    // Plan fork
8552    // ======================================================================
8553
8554    #[test]
8555    fn test_plan_fork_from_user_message() {
8556        let mut session = Session::in_memory();
8557
8558        let _id_a = session.append_message(make_test_message("First question"));
8559        let assistant = AssistantMessage {
8560            content: vec![ContentBlock::Text(TextContent::new("Answer"))],
8561            api: "anthropic".to_string(),
8562            provider: "anthropic".to_string(),
8563            model: "test".to_string(),
8564            usage: Usage::default(),
8565            stop_reason: StopReason::Stop,
8566            error_message: None,
8567            timestamp: 0,
8568        };
8569        let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
8570        let id_c = session.append_message(make_test_message("Second question"));
8571
8572        // Fork from the second user message
8573        let plan = session.plan_fork_from_user_message(&id_c).unwrap();
8574        assert_eq!(plan.selected_text, "Second question");
8575        // Entries should be the path up to (but not including) the forked message
8576        assert_eq!(plan.entries.len(), 2); // A and B
8577    }
8578
8579    #[test]
8580    fn test_plan_fork_from_root_message() {
8581        let mut session = Session::in_memory();
8582        let id_a = session.append_message(make_test_message("Root question"));
8583
8584        let plan = session.plan_fork_from_user_message(&id_a).unwrap();
8585        assert_eq!(plan.selected_text, "Root question");
8586        assert!(plan.entries.is_empty()); // No entries before root
8587        assert!(plan.leaf_id.is_none());
8588    }
8589
8590    #[test]
8591    fn test_plan_fork_from_nonexistent_fails() {
8592        let session = Session::in_memory();
8593        assert!(session.plan_fork_from_user_message("nonexistent").is_err());
8594    }
8595
8596    #[test]
8597    fn test_plan_fork_from_assistant_message_fails() {
8598        let mut session = Session::in_memory();
8599        session.append_message(make_test_message("Q"));
8600        let assistant = AssistantMessage {
8601            content: vec![ContentBlock::Text(TextContent::new("A"))],
8602            api: "anthropic".to_string(),
8603            provider: "anthropic".to_string(),
8604            model: "test".to_string(),
8605            usage: Usage::default(),
8606            stop_reason: StopReason::Stop,
8607            error_message: None,
8608            timestamp: 0,
8609        };
8610        let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
8611
8612        assert!(session.plan_fork_from_user_message(&asst_id).is_err());
8613    }
8614
8615    // ======================================================================
8616    // Compaction in message context
8617    // ======================================================================
8618
8619    #[test]
8620    fn test_compaction_truncates_model_context() {
8621        let mut session = Session::in_memory();
8622
8623        let _id_a = session.append_message(make_test_message("old message A"));
8624        let _id_b = session.append_message(make_test_message("old message B"));
8625        let id_c = session.append_message(make_test_message("kept message C"));
8626
8627        // Compact: keep from id_c onwards
8628        session.append_compaction(
8629            "Summary of old messages".to_string(),
8630            id_c,
8631            5000,
8632            None,
8633            None,
8634        );
8635
8636        let id_d = session.append_message(make_test_message("new message D"));
8637
8638        // Ensure we're at the right leaf
8639        session.navigate_to(&id_d);
8640
8641        let messages = session.to_messages_for_current_path();
8642        // Should have: compaction summary + kept message C + new message D
8643        // (old messages A and B should be omitted)
8644        assert!(messages.len() <= 4); // compaction summary + C + compaction entry + D
8645
8646        // Verify old messages are not in context
8647        let all_text: String = messages
8648            .iter()
8649            .filter_map(|m| match m {
8650                Message::User(u) => match &u.content {
8651                    UserContent::Text(t) => Some(t.clone()),
8652                    UserContent::Blocks(blocks) => {
8653                        let texts: Vec<String> = blocks
8654                            .iter()
8655                            .filter_map(|b| {
8656                                if let ContentBlock::Text(t) = b {
8657                                    Some(t.text.clone())
8658                                } else {
8659                                    None
8660                                }
8661                            })
8662                            .collect();
8663                        Some(texts.join(" "))
8664                    }
8665                },
8666                _ => None,
8667            })
8668            .collect::<Vec<_>>()
8669            .join(" ");
8670
8671        assert!(
8672            !all_text.contains("old message A"),
8673            "compacted message A should not appear in context"
8674        );
8675        assert!(
8676            !all_text.contains("old message B"),
8677            "compacted message B should not appear in context"
8678        );
8679        assert!(
8680            all_text.contains("kept message C") || all_text.contains("new message D"),
8681            "kept messages should appear in context"
8682        );
8683    }
8684
8685    // ======================================================================
8686    // Large session handling
8687    // ======================================================================
8688
8689    #[test]
8690    fn test_large_session_append_and_path() {
8691        let mut session = Session::in_memory();
8692
8693        let mut last_id = String::new();
8694        for i in 0..500 {
8695            last_id = session.append_message(make_test_message(&format!("msg-{i}")));
8696        }
8697
8698        assert_eq!(session.entries.len(), 500);
8699        assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
8700
8701        // Path from root to leaf should include all 500 entries
8702        let path = session.get_path_to_entry(&last_id);
8703        assert_eq!(path.len(), 500);
8704
8705        // Entries for current path should also be 500
8706        let current = session.entries_for_current_path();
8707        assert_eq!(current.len(), 500);
8708    }
8709
8710    #[test]
8711    fn test_large_session_save_and_reload() {
8712        let temp = tempfile::tempdir().unwrap();
8713        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8714
8715        for i in 0..200 {
8716            session.append_message(make_test_message(&format!("message {i}")));
8717        }
8718
8719        run_async(async { session.save().await }).unwrap();
8720        let path = session.path.clone().unwrap();
8721
8722        let loaded =
8723            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8724
8725        assert_eq!(loaded.entries.len(), 200);
8726        assert_eq!(loaded.header.id, session.header.id);
8727    }
8728
8729    // ======================================================================
8730    // Entry ID generation
8731    // ======================================================================
8732
8733    #[test]
8734    fn test_ensure_entry_ids_fills_missing() {
8735        let mut entries = vec![
8736            SessionEntry::Message(MessageEntry {
8737                base: EntryBase {
8738                    id: None,
8739                    parent_id: None,
8740                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8741                },
8742                message: SessionMessage::User {
8743                    content: UserContent::Text("test".to_string()),
8744                    timestamp: Some(0),
8745                },
8746            }),
8747            SessionEntry::Message(MessageEntry {
8748                base: EntryBase {
8749                    id: Some("existing".to_string()),
8750                    parent_id: None,
8751                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8752                },
8753                message: SessionMessage::User {
8754                    content: UserContent::Text("test2".to_string()),
8755                    timestamp: Some(0),
8756                },
8757            }),
8758        ];
8759
8760        ensure_entry_ids(&mut entries);
8761
8762        // First entry should now have an ID
8763        assert!(entries[0].base().id.is_some());
8764        // Second entry should keep its existing ID
8765        assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
8766        // IDs should be unique
8767        assert_ne!(entries[0].base().id, entries[1].base().id);
8768    }
8769
8770    #[test]
8771    fn test_generate_entry_id_produces_8_char_hex() {
8772        let existing = HashSet::new();
8773        let id = generate_entry_id(&existing);
8774        assert_eq!(id.len(), 8);
8775        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
8776    }
8777
8778    // ======================================================================
8779    // set_model_header / set_branched_from
8780    // ======================================================================
8781
8782    #[test]
8783    fn test_set_model_header() {
8784        let mut session = Session::in_memory();
8785        session.set_model_header(
8786            Some("anthropic".to_string()),
8787            Some("claude-opus".to_string()),
8788            Some("high".to_string()),
8789        );
8790        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
8791        assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
8792        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
8793    }
8794
8795    #[test]
8796    fn test_effective_model_and_thinking_use_current_header_without_change_entries() {
8797        let mut session = Session::in_memory();
8798        session.set_model_header(
8799            Some("openai".to_string()),
8800            Some("gpt-5.4".to_string()),
8801            Some("medium".to_string()),
8802        );
8803
8804        assert_eq!(
8805            session.effective_model_for_current_path(),
8806            Some(("openai".to_string(), "gpt-5.4".to_string()))
8807        );
8808        assert_eq!(
8809            session
8810                .effective_thinking_level_for_current_path()
8811                .as_deref(),
8812            Some("medium")
8813        );
8814    }
8815
8816    #[test]
8817    fn test_set_branched_from() {
8818        let mut session = Session::in_memory();
8819        assert!(session.header.parent_session.is_none());
8820
8821        session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
8822        assert_eq!(
8823            session.header.parent_session.as_deref(),
8824            Some("/path/to/parent.jsonl")
8825        );
8826    }
8827
8828    // ======================================================================
8829    // to_html rendering
8830    // ======================================================================
8831
8832    #[test]
8833    fn test_to_html_contains_all_message_types() {
8834        let mut session = Session::in_memory();
8835
8836        session.append_message(make_test_message("user question"));
8837
8838        let assistant = AssistantMessage {
8839            content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
8840            api: "anthropic".to_string(),
8841            provider: "anthropic".to_string(),
8842            model: "test".to_string(),
8843            usage: Usage::default(),
8844            stop_reason: StopReason::Stop,
8845            error_message: None,
8846            timestamp: 0,
8847        };
8848        session.append_message(SessionMessage::Assistant { message: assistant });
8849        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
8850        session.set_name("test-session-html");
8851
8852        let html = session.to_html();
8853        assert!(html.contains("<!doctype html>"));
8854        assert!(html.contains("user question"));
8855        assert!(html.contains("assistant answer"));
8856        assert!(html.contains("anthropic"));
8857        assert!(html.contains("test-session-html"));
8858    }
8859
8860    // ======================================================================
8861    // to_messages conversion
8862    // ======================================================================
8863
8864    #[test]
8865    fn test_to_messages_includes_all_message_entries() {
8866        let mut session = Session::in_memory();
8867
8868        session.append_message(make_test_message("Q1"));
8869        let assistant = AssistantMessage {
8870            content: vec![ContentBlock::Text(TextContent::new("A1"))],
8871            api: "anthropic".to_string(),
8872            provider: "anthropic".to_string(),
8873            model: "test".to_string(),
8874            usage: Usage::default(),
8875            stop_reason: StopReason::Stop,
8876            error_message: None,
8877            timestamp: 0,
8878        };
8879        session.append_message(SessionMessage::Assistant { message: assistant });
8880        session.append_message(SessionMessage::ToolResult {
8881            tool_call_id: "c1".to_string(),
8882            tool_name: "edit".to_string(),
8883            content: vec![ContentBlock::Text(TextContent::new("edited"))],
8884            details: None,
8885            is_error: false,
8886            timestamp: Some(0),
8887        });
8888
8889        // Non-message entries should NOT appear in to_messages()
8890        session.append_model_change("openai".to_string(), "gpt-4".to_string());
8891        session.append_session_info(Some("name".to_string()));
8892
8893        let messages = session.to_messages();
8894        assert_eq!(messages.len(), 3); // user + assistant + tool_result
8895    }
8896
8897    // ======================================================================
8898    // JSONL format validation
8899    // ======================================================================
8900
8901    #[test]
8902    fn test_jsonl_header_is_first_line() {
8903        let temp = tempfile::tempdir().unwrap();
8904        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8905        session.append_message(make_test_message("test"));
8906
8907        run_async(async { session.save().await }).unwrap();
8908        let path = session.path.clone().unwrap();
8909
8910        let contents = std::fs::read_to_string(path).unwrap();
8911        let first_line = contents.lines().next().unwrap();
8912        let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
8913
8914        assert_eq!(header["type"], "session");
8915        assert_eq!(header["version"], SESSION_VERSION);
8916        assert!(!header["id"].as_str().unwrap().is_empty());
8917        assert!(!header["timestamp"].as_str().unwrap().is_empty());
8918    }
8919
8920    #[test]
8921    fn test_jsonl_entries_have_camelcase_fields() {
8922        let temp = tempfile::tempdir().unwrap();
8923        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8924
8925        session.append_message(make_test_message("test"));
8926        session.append_model_change("provider".to_string(), "model".to_string());
8927
8928        run_async(async { session.save().await }).unwrap();
8929        let path = session.path.clone().unwrap();
8930
8931        let contents = std::fs::read_to_string(path).unwrap();
8932        let lines: Vec<&str> = contents.lines().collect();
8933
8934        // Check message entry (line 2)
8935        let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
8936        assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
8937
8938        // Check model change entry (line 3)
8939        let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
8940        assert!(mc_value.get("modelId").is_some());
8941    }
8942
8943    // ======================================================================
8944    // Session open errors
8945    // ======================================================================
8946
8947    #[test]
8948    fn test_open_nonexistent_file_returns_error() {
8949        let result =
8950            run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
8951        assert!(result.is_err());
8952    }
8953
8954    #[test]
8955    fn test_open_empty_file_returns_error() {
8956        let temp = tempfile::tempdir().unwrap();
8957        let path = temp.path().join("empty.jsonl");
8958        std::fs::write(&path, "").unwrap();
8959
8960        let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
8961        assert!(result.is_err());
8962    }
8963
8964    #[test]
8965    fn test_open_rejects_semantically_invalid_header() {
8966        let temp = tempfile::tempdir().unwrap();
8967        let path = temp.path().join("invalid_header.jsonl");
8968        std::fs::write(
8969            &path,
8970            r#"{"type":"note","version":3,"id":"bad","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
8971        )
8972        .unwrap();
8973
8974        let err = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
8975            .expect_err("invalid header should fail");
8976        let message = err.to_string();
8977        assert!(
8978            message.contains("Invalid session header"),
8979            "expected invalid session header error, got {message}"
8980        );
8981    }
8982
8983    #[test]
8984    fn test_save_rejects_semantically_invalid_header() {
8985        let temp = tempfile::tempdir().unwrap();
8986        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8987        session.header.r#type = "note".to_string();
8988
8989        let err =
8990            run_async(async { session.save().await }).expect_err("invalid header should fail");
8991        let message = err.to_string();
8992        assert!(
8993            message.contains("Invalid session header"),
8994            "expected invalid session header error, got {message}"
8995        );
8996    }
8997
8998    // ======================================================================
8999    // get_entry / get_entry_mut
9000    // ======================================================================
9001
9002    #[test]
9003    fn test_get_entry_returns_correct_entry() {
9004        let mut session = Session::in_memory();
9005        let id = session.append_message(make_test_message("Hello"));
9006
9007        let entry = session.get_entry(&id);
9008        assert!(entry.is_some());
9009        assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
9010    }
9011
9012    #[test]
9013    fn test_get_entry_mut_allows_modification() {
9014        let mut session = Session::in_memory();
9015        let id = session.append_message(make_test_message("Original"));
9016
9017        let entry = session.get_entry_mut(&id).unwrap();
9018        if let SessionEntry::Message(msg) = entry {
9019            msg.message = SessionMessage::User {
9020                content: UserContent::Text("Modified".to_string()),
9021                timestamp: Some(0),
9022            };
9023        }
9024
9025        // Verify modification persisted
9026        let entry = session.get_entry(&id).unwrap();
9027        if let SessionEntry::Message(msg) = entry {
9028            if let SessionMessage::User { content, .. } = &msg.message {
9029                match content {
9030                    UserContent::Text(t) => assert_eq!(t, "Modified"),
9031                    UserContent::Blocks(_) => test_fail!("Expected UserContent::Text, got Blocks"),
9032                }
9033            } else {
9034                test_fail!("Expected SessionMessage::User, got {:?}", msg.message);
9035            }
9036        }
9037    }
9038
9039    #[test]
9040    fn test_get_entry_nonexistent_returns_none() {
9041        let session = Session::in_memory();
9042        assert!(session.get_entry("nonexistent").is_none());
9043    }
9044
9045    // ======================================================================
9046    // Branching round-trip (save with branches, reload, verify)
9047    // ======================================================================
9048
9049    #[test]
9050    fn test_branching_round_trip_preserves_tree_structure() {
9051        let temp = tempfile::tempdir().unwrap();
9052        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9053
9054        // Create: A -> B -> C, then branch from A: A -> D
9055        let id_a = session.append_message(make_test_message("A"));
9056        let id_b = session.append_message(make_test_message("B"));
9057        let id_c = session.append_message(make_test_message("C"));
9058
9059        session.create_branch_from(&id_a);
9060        let id_d = session.append_message(make_test_message("D"));
9061
9062        // Verify pre-save state
9063        let leaves = session.list_leaves();
9064        assert_eq!(leaves.len(), 2);
9065
9066        run_async(async { session.save().await }).unwrap();
9067        let path = session.path.clone().unwrap();
9068
9069        let loaded =
9070            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9071
9072        // Verify tree structure survived round-trip
9073        assert_eq!(loaded.entries.len(), 4);
9074        let loaded_leaves = loaded.list_leaves();
9075        assert_eq!(loaded_leaves.len(), 2);
9076        assert!(loaded_leaves.contains(&id_c));
9077        assert!(loaded_leaves.contains(&id_d));
9078
9079        // Verify parent linking
9080        let path_to_c = loaded.get_path_to_entry(&id_c);
9081        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
9082
9083        let path_to_d = loaded.get_path_to_entry(&id_d);
9084        assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
9085    }
9086
9087    // ======================================================================
9088    // Session directory resolution from CWD
9089    // ======================================================================
9090
9091    #[test]
9092    fn test_encode_cwd_strips_leading_separators() {
9093        let path = std::path::Path::new("/home/user/my-project");
9094        let encoded = encode_cwd(path);
9095        assert_eq!(encoded, "--home-user-my-project--");
9096        assert!(!encoded.contains('/'));
9097    }
9098
9099    #[test]
9100    fn test_encode_cwd_handles_deeply_nested_path() {
9101        let path = std::path::Path::new("/a/b/c/d/e/f");
9102        let encoded = encode_cwd(path);
9103        assert_eq!(encoded, "--a-b-c-d-e-f--");
9104    }
9105
9106    #[test]
9107    fn test_save_creates_project_session_dir_from_cwd() {
9108        let temp = tempfile::tempdir().unwrap();
9109        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9110        session.append_message(make_test_message("test"));
9111
9112        run_async(async { session.save().await }).unwrap();
9113        let path = session.path.clone().unwrap();
9114
9115        // The saved path should be inside a CWD-encoded subdirectory
9116        let parent = path.parent().unwrap();
9117        let dir_name = parent.file_name().unwrap().to_string_lossy();
9118        assert!(
9119            dir_name.starts_with("--"),
9120            "session dir should start with --"
9121        );
9122        assert!(dir_name.ends_with("--"), "session dir should end with --");
9123
9124        // The file should have .jsonl extension
9125        assert_eq!(path.extension().unwrap(), "jsonl");
9126    }
9127
9128    #[test]
9129    fn test_save_uses_session_header_cwd_for_project_session_dir() {
9130        let _lock = current_dir_lock();
9131        let process_cwd = tempfile::tempdir().unwrap();
9132        let _guard = CurrentDirGuard::new(process_cwd.path());
9133
9134        let sessions_root = tempfile::tempdir().unwrap();
9135        let session_cwd = tempfile::tempdir().unwrap();
9136        let mut session = Session::create_with_dir(Some(sessions_root.path().to_path_buf()));
9137        session.header.cwd = session_cwd.path().display().to_string();
9138        session.append_message(make_test_message("test"));
9139
9140        run_async(async { session.save().await }).unwrap();
9141        let path = session.path.clone().expect("session path");
9142        let expected_dir = sessions_root.path().join(encode_cwd(session_cwd.path()));
9143        let process_dir = sessions_root.path().join(encode_cwd(process_cwd.path()));
9144
9145        assert_eq!(path.parent(), Some(expected_dir.as_path()));
9146        assert_ne!(path.parent(), Some(process_dir.as_path()));
9147    }
9148
9149    #[test]
9150    fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
9151        let known_entry = SessionPickEntry {
9152            path: PathBuf::from("session.jsonl"),
9153            id: "session-id".to_string(),
9154            cwd: "/work".to_string(),
9155            timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9156            message_count: 4,
9157            name: Some("cached".to_string()),
9158            last_modified_ms: 1234,
9159            size_bytes: 4096,
9160        };
9161
9162        assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
9163        assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
9164        assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
9165    }
9166
9167    #[test]
9168    fn read_capped_utf8_line_with_limit_rejects_oversized_line_without_newline() {
9169        let oversized = "x".repeat(5);
9170        let mut reader = std::io::Cursor::new(oversized.into_bytes());
9171
9172        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9173        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9174        assert!(err.to_string().contains("JSONL line exceeds 4 bytes"));
9175    }
9176
9177    #[test]
9178    fn read_capped_utf8_line_with_limit_allows_exact_limit_before_newline() {
9179        let mut reader = std::io::Cursor::new(b"abcd\n".to_vec());
9180
9181        let line = read_capped_utf8_line_with_limit(&mut reader, 4)
9182            .expect("read line")
9183            .expect("line present");
9184        assert_eq!(line, "abcd\n");
9185        assert!(
9186            read_capped_utf8_line_with_limit(&mut reader, 4)
9187                .expect("read eof")
9188                .is_none()
9189        );
9190    }
9191
9192    #[test]
9193    fn read_capped_utf8_line_with_limit_drains_oversized_line_remainder() {
9194        let mut reader = std::io::Cursor::new(b"xxxxx\ny\n".to_vec());
9195
9196        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9197        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9198
9199        let next_line = read_capped_utf8_line_with_limit(&mut reader, 4)
9200            .expect("read next line")
9201            .expect("next line present");
9202        assert_eq!(next_line, "y\n");
9203    }
9204
9205    #[test]
9206    fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
9207        let temp = tempfile::tempdir().unwrap();
9208        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9209        session.append_message(make_test_message("first"));
9210        session.append_message(make_test_message("second"));
9211
9212        run_async(async { session.save().await }).unwrap();
9213        let path = session.path.clone().expect("session path");
9214        let metadata = std::fs::metadata(&path).expect("session metadata");
9215        let disk_size = metadata.len();
9216        #[allow(clippy::cast_possible_truncation)]
9217        let disk_ms = metadata
9218            .modified()
9219            .unwrap_or(SystemTime::UNIX_EPOCH)
9220            .duration_since(UNIX_EPOCH)
9221            .unwrap_or_default()
9222            .as_millis() as i64;
9223
9224        let stale_known_entry = SessionPickEntry {
9225            path: path.clone(),
9226            id: session.header.id.clone(),
9227            cwd: session.header.cwd.clone(),
9228            timestamp: session.header.timestamp.clone(),
9229            message_count: 999,
9230            name: Some("stale".to_string()),
9231            last_modified_ms: disk_ms,
9232            size_bytes: disk_size.saturating_add(1),
9233        };
9234
9235        let session_dir = path.parent().expect("session parent").to_path_buf();
9236        let scanned =
9237            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9238                .expect("scan sessions");
9239        assert!(scanned.failed_paths.is_empty());
9240        assert_eq!(scanned.entries.len(), 1);
9241        assert_eq!(scanned.refreshed_entries.len(), 1);
9242        assert_eq!(scanned.entries[0].path, path);
9243        assert_eq!(scanned.entries[0].message_count, 2);
9244        assert_eq!(scanned.entries[0].size_bytes, disk_size);
9245    }
9246
9247    #[test]
9248    fn test_merge_scanned_session_entries_replaces_cached_entry_when_size_changes() {
9249        let path = PathBuf::from("session.jsonl");
9250        let mut by_path = HashMap::from([(
9251            path.clone(),
9252            SessionPickEntry {
9253                path: path.clone(),
9254                id: "session-id".to_string(),
9255                cwd: "/work".to_string(),
9256                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9257                message_count: 1,
9258                name: Some("cached".to_string()),
9259                last_modified_ms: 1234,
9260                size_bytes: 4096,
9261            },
9262        )]);
9263
9264        merge_scanned_session_entries(
9265            &mut by_path,
9266            vec![SessionPickEntry {
9267                path: path.clone(),
9268                id: "session-id".to_string(),
9269                cwd: "/work".to_string(),
9270                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9271                message_count: 2,
9272                name: Some("disk".to_string()),
9273                last_modified_ms: 1234,
9274                size_bytes: 8192,
9275            }],
9276        );
9277
9278        let merged = by_path.get(&path).expect("merged entry");
9279        assert_eq!(merged.message_count, 2);
9280        assert_eq!(merged.name.as_deref(), Some("disk"));
9281        assert_eq!(merged.size_bytes, 8192);
9282    }
9283
9284    #[test]
9285    fn test_merge_scanned_session_entries_replaces_cached_entry_even_if_disk_mtime_regresses() {
9286        let path = PathBuf::from("session.jsonl");
9287        let mut by_path = HashMap::from([(
9288            path.clone(),
9289            SessionPickEntry {
9290                path: path.clone(),
9291                id: "session-id".to_string(),
9292                cwd: "/work".to_string(),
9293                timestamp: "2026-01-02T00:00:00.000Z".to_string(),
9294                message_count: 9,
9295                name: Some("cached".to_string()),
9296                last_modified_ms: 2000,
9297                size_bytes: 4096,
9298            },
9299        )]);
9300
9301        merge_scanned_session_entries(
9302            &mut by_path,
9303            vec![SessionPickEntry {
9304                path: path.clone(),
9305                id: "session-id".to_string(),
9306                cwd: "/work".to_string(),
9307                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9308                message_count: 3,
9309                name: Some("disk".to_string()),
9310                last_modified_ms: 1000,
9311                size_bytes: 2048,
9312            }],
9313        );
9314
9315        let merged = by_path.get(&path).expect("merged entry");
9316        assert_eq!(merged.message_count, 3);
9317        assert_eq!(merged.name.as_deref(), Some("disk"));
9318        assert_eq!(merged.last_modified_ms, 1000);
9319        assert_eq!(merged.size_bytes, 2048);
9320    }
9321
9322    #[test]
9323    fn test_scan_sessions_on_disk_reports_failed_paths_for_corrupt_changed_session() {
9324        let temp = tempfile::tempdir().unwrap();
9325        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9326        session.append_message(make_test_message("first"));
9327        session.append_message(make_test_message("second"));
9328
9329        run_async(async { session.save().await }).unwrap();
9330        let path = session.path.clone().expect("session path");
9331        let metadata = std::fs::metadata(&path).expect("session metadata");
9332        let disk_size = metadata.len();
9333        #[allow(clippy::cast_possible_truncation)]
9334        let disk_ms = metadata
9335            .modified()
9336            .unwrap_or(SystemTime::UNIX_EPOCH)
9337            .duration_since(UNIX_EPOCH)
9338            .unwrap_or_default()
9339            .as_millis() as i64;
9340
9341        let stale_known_entry = SessionPickEntry {
9342            path: path.clone(),
9343            id: session.header.id.clone(),
9344            cwd: session.header.cwd.clone(),
9345            timestamp: session.header.timestamp.clone(),
9346            message_count: 999,
9347            name: Some("stale".to_string()),
9348            last_modified_ms: disk_ms,
9349            size_bytes: disk_size,
9350        };
9351
9352        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9353
9354        let session_dir = path.parent().expect("session parent").to_path_buf();
9355        let scanned =
9356            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9357                .expect("scan sessions");
9358
9359        assert!(scanned.entries.is_empty());
9360        assert!(scanned.refreshed_entries.is_empty());
9361        assert_eq!(scanned.failed_paths, vec![path]);
9362    }
9363
9364    #[test]
9365    fn test_continue_recent_in_dir_prunes_corrupt_stale_index_entry() {
9366        let _lock = current_dir_lock();
9367        let process_cwd = tempfile::tempdir().unwrap();
9368        let _guard = CurrentDirGuard::new(process_cwd.path());
9369
9370        let temp = tempfile::tempdir().unwrap();
9371        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9372        session.append_message(make_test_message("first"));
9373        session.append_message(make_test_message("second"));
9374
9375        run_async(async { session.save().await }).expect("save session");
9376        let path = session.path.clone().expect("session path");
9377
9378        let index = SessionIndex::for_sessions_root(temp.path());
9379        index.index_session(&session).expect("index session");
9380        let cwd_display = session.header.cwd.clone();
9381        let expected_path = path.display().to_string();
9382        let has_indexed_path = index
9383            .list_sessions(Some(&cwd_display))
9384            .expect("list indexed sessions")
9385            .into_iter()
9386            .any(|meta| meta.path.eq(&expected_path));
9387        assert!(
9388            has_indexed_path,
9389            "expected indexed session before corruption"
9390        );
9391
9392        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9393
9394        let resumed = run_async(async {
9395            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9396        })
9397        .expect("continue recent");
9398
9399        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9400        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9401
9402        let still_indexed = index
9403            .list_sessions(Some(&cwd_display))
9404            .expect("list indexed sessions after cleanup")
9405            .into_iter()
9406            .any(|meta| meta.path.eq(&expected_path));
9407        assert!(
9408            !still_indexed,
9409            "corrupt session should be pruned from the recent-session index"
9410        );
9411    }
9412
9413    #[test]
9414    fn test_continue_recent_in_dir_prunes_missing_stale_index_entry() {
9415        let _lock = current_dir_lock();
9416        let process_cwd = tempfile::tempdir().unwrap();
9417        let _guard = CurrentDirGuard::new(process_cwd.path());
9418
9419        let temp = tempfile::tempdir().unwrap();
9420        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9421        session.append_message(make_test_message("first"));
9422
9423        run_async(async { session.save().await }).expect("save session");
9424        let path = session.path.clone().expect("session path");
9425
9426        let index = SessionIndex::for_sessions_root(temp.path());
9427        index.index_session(&session).expect("index session");
9428        let cwd_display = session.header.cwd.clone();
9429        let expected_path = path.display().to_string();
9430        let has_indexed_path = index
9431            .list_sessions(Some(&cwd_display))
9432            .expect("list indexed sessions")
9433            .into_iter()
9434            .any(|meta| meta.path.eq(&expected_path));
9435        assert!(
9436            has_indexed_path,
9437            "expected indexed session before moving file"
9438        );
9439
9440        let moved_path = path.with_extension("bak");
9441        std::fs::rename(&path, &moved_path).expect("move session away from indexed path");
9442
9443        let resumed = run_async(async {
9444            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9445        })
9446        .expect("continue recent");
9447
9448        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9449        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9450
9451        let still_indexed = index
9452            .list_sessions(Some(&cwd_display))
9453            .expect("list indexed sessions after cleanup")
9454            .into_iter()
9455            .any(|meta| meta.path.eq(&expected_path));
9456        assert!(
9457            !still_indexed,
9458            "missing session should be pruned from the recent-session index"
9459        );
9460    }
9461
9462    #[test]
9463    fn test_continue_recent_in_dir_prunes_index_when_project_dir_is_missing() {
9464        let _lock = current_dir_lock();
9465        let process_cwd = tempfile::tempdir().unwrap();
9466        let _guard = CurrentDirGuard::new(process_cwd.path());
9467
9468        let temp = tempfile::tempdir().unwrap();
9469        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9470        session.append_message(make_test_message("first"));
9471
9472        run_async(async { session.save().await }).expect("save session");
9473        let path = session.path.clone().expect("session path");
9474
9475        let index = SessionIndex::for_sessions_root(temp.path());
9476        index.index_session(&session).expect("index session");
9477        let cwd_display = session.header.cwd.clone();
9478        let expected_path = path.display().to_string();
9479        let cwd = std::path::Path::new(&cwd_display);
9480        let project_session_dir = temp.path().join(encode_cwd(cwd));
9481        let moved_project_dir = temp.path().join("moved-project-dir");
9482
9483        std::fs::rename(&project_session_dir, &moved_project_dir)
9484            .expect("move project session dir away");
9485
9486        let resumed = run_async(async {
9487            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9488        })
9489        .expect("continue recent");
9490
9491        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9492        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9493
9494        let still_indexed = index
9495            .list_sessions(Some(&cwd_display))
9496            .expect("list indexed sessions after cleanup")
9497            .into_iter()
9498            .any(|meta| meta.path.eq(&expected_path));
9499        assert!(
9500            !still_indexed,
9501            "missing project dir should prune stale rows from the recent-session index"
9502        );
9503    }
9504
9505    #[cfg(unix)]
9506    #[test]
9507    fn split_indexed_session_entries_keeps_permission_denied_path_out_of_missing_bucket() {
9508        use crate::session_index::SessionMeta;
9509        use std::os::unix::fs::PermissionsExt;
9510
9511        let temp = tempfile::tempdir().unwrap();
9512        let guarded_dir = temp.path().join("guarded");
9513        std::fs::create_dir(&guarded_dir).expect("create guarded dir");
9514        let session_path = guarded_dir.join("session.jsonl");
9515        std::fs::write(&session_path, b"{\"version\":\"3\"}\n").expect("write session file");
9516
9517        let original_mode = std::fs::metadata(&guarded_dir)
9518            .expect("guarded dir metadata")
9519            .permissions()
9520            .mode();
9521        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(0o000))
9522            .expect("chmod guarded dir");
9523
9524        assert!(
9525            session_path.try_exists().is_err(),
9526            "expected permission-denied path probe for inaccessible parent directory"
9527        );
9528
9529        let meta = SessionMeta {
9530            path: session_path.display().to_string(),
9531            id: "session-id".to_string(),
9532            cwd: temp.path().display().to_string(),
9533            timestamp: "2026-03-15T00:00:00.000Z".to_string(),
9534            message_count: 1,
9535            last_modified_ms: 0,
9536            size_bytes: 16,
9537            name: Some("guarded".to_string()),
9538        };
9539
9540        let (entries, missing_paths) = split_indexed_session_entries(vec![meta]);
9541
9542        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(original_mode))
9543            .expect("restore guarded dir permissions");
9544
9545        assert!(
9546            missing_paths.is_empty(),
9547            "permission errors must not be classified as missing indexed sessions"
9548        );
9549        assert_eq!(entries.len(), 1);
9550        assert_eq!(entries[0].path, session_path);
9551    }
9552
9553    #[cfg(unix)]
9554    #[test]
9555    fn test_continue_recent_in_dir_prunes_unreadable_cached_entry_on_open_failure() {
9556        use std::os::unix::fs::PermissionsExt;
9557
9558        let temp = tempfile::tempdir().unwrap();
9559        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9560        session.append_message(make_test_message("first"));
9561
9562        run_async(async { session.save().await }).expect("save session");
9563        let path = session.path.clone().expect("session path");
9564
9565        let original_mode = std::fs::metadata(&path)
9566            .expect("session metadata")
9567            .permissions()
9568            .mode();
9569
9570        let index = SessionIndex::for_sessions_root(temp.path());
9571        index.index_session(&session).expect("index session");
9572        let expected_path = path.display().to_string();
9573        let cwd_display = std::env::current_dir()
9574            .expect("current dir")
9575            .display()
9576            .to_string();
9577
9578        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
9579            .expect("chmod unreadable");
9580
9581        let resumed = run_async(async {
9582            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9583        })
9584        .expect("continue recent");
9585
9586        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(original_mode))
9587            .expect("restore permissions");
9588
9589        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9590        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9591
9592        let still_indexed = index
9593            .list_sessions(Some(&cwd_display))
9594            .expect("list indexed sessions after cleanup")
9595            .into_iter()
9596            .any(|meta| meta.path.eq(&expected_path));
9597        assert!(
9598            !still_indexed,
9599            "unreadable session should be pruned from the recent-session index"
9600        );
9601    }
9602
9603    #[test]
9604    fn test_continue_recent_in_dir_refreshes_index_after_changed_disk_session() {
9605        let _lock = current_dir_lock();
9606        let process_cwd = tempfile::tempdir().unwrap();
9607        let _guard = CurrentDirGuard::new(process_cwd.path());
9608
9609        let temp = tempfile::tempdir().expect("tempdir");
9610        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9611        session.append_message(make_test_message("first"));
9612
9613        run_async(async { session.save().await }).expect("save session");
9614        let path = session.path.clone().expect("session path");
9615
9616        let index = SessionIndex::for_sessions_root(temp.path());
9617        index.index_session(&session).expect("index session");
9618        let cwd_display = session.header.cwd.clone();
9619
9620        std::fs::write(
9621            &path,
9622            format!(
9623                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9624                serde_json::to_string(&session.header).expect("serialize header"),
9625            ),
9626        )
9627        .expect("rewrite session");
9628
9629        let resumed = run_async(async {
9630            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9631        })
9632        .expect("continue recent");
9633
9634        assert_eq!(resumed.path.as_ref(), Some(&path));
9635
9636        let indexed = index
9637            .list_sessions(Some(&cwd_display))
9638            .expect("list indexed sessions");
9639        assert_eq!(indexed.len(), 1);
9640        assert_eq!(indexed[0].path, path.display().to_string());
9641        assert_eq!(indexed[0].message_count, 2);
9642        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9643    }
9644
9645    #[test]
9646    fn test_resume_with_picker_refreshes_index_after_changed_disk_session() {
9647        let _lock = current_dir_lock();
9648        let process_cwd = tempfile::tempdir().unwrap();
9649        let _guard = CurrentDirGuard::new(process_cwd.path());
9650
9651        let temp = tempfile::tempdir().expect("tempdir");
9652        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9653        session.append_message(make_test_message("first"));
9654
9655        run_async(async { session.save().await }).expect("save session");
9656        let path = session.path.clone().expect("session path");
9657
9658        let index = SessionIndex::for_sessions_root(temp.path());
9659        index.index_session(&session).expect("index session");
9660        let cwd_display = session.header.cwd.clone();
9661
9662        std::fs::write(
9663            &path,
9664            format!(
9665                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9666                serde_json::to_string(&session.header).expect("serialize header"),
9667            ),
9668        )
9669        .expect("rewrite session");
9670
9671        let resumed = run_async(async {
9672            Session::resume_with_picker(
9673                Some(temp.path()),
9674                &Config::default(),
9675                Some("1".to_string()),
9676            )
9677            .await
9678        })
9679        .expect("resume with picker");
9680
9681        assert_eq!(resumed.path.as_ref(), Some(&path));
9682
9683        let indexed = index
9684            .list_sessions(Some(&cwd_display))
9685            .expect("list indexed sessions");
9686        assert_eq!(indexed.len(), 1);
9687        assert_eq!(indexed[0].path, path.display().to_string());
9688        assert_eq!(indexed[0].message_count, 2);
9689        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9690    }
9691
9692    #[test]
9693    fn test_load_session_meta_jsonl_errors_on_invalid_utf8_entry_line() {
9694        use std::io::Write;
9695
9696        let temp = tempfile::tempdir().unwrap();
9697        let session_path = temp.path().join("invalid-utf8.jsonl");
9698
9699        let mut header = SessionHeader::new();
9700        header.id = "invalid-utf8".to_string();
9701        header.cwd = temp.path().display().to_string();
9702        header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
9703
9704        std::fs::write(
9705            &session_path,
9706            format!(
9707                "{}\n",
9708                serde_json::to_string(&header).expect("serialize header")
9709            ),
9710        )
9711        .expect("write header");
9712
9713        let mut file = std::fs::OpenOptions::new()
9714            .append(true)
9715            .open(&session_path)
9716            .expect("open session");
9717        file.write_all(b"{\"type\":\"message\"}\n")
9718            .expect("write valid entry");
9719        file.write_all(b"\xFF\xFE\n").expect("write invalid utf8");
9720        file.flush().expect("flush session");
9721        drop(file);
9722
9723        let err = load_session_meta_jsonl(&session_path).expect_err("invalid utf8 should error");
9724        assert!(
9725            err.to_string().contains("Failed to read session entry"),
9726            "{err}"
9727        );
9728    }
9729
9730    #[cfg(feature = "sqlite-sessions")]
9731    #[test]
9732    fn test_scan_sessions_on_disk_reloads_sqlite_when_wal_stats_change() {
9733        let temp = tempfile::tempdir().unwrap();
9734        let mut session = Session::create_with_dir_and_store(
9735            Some(temp.path().to_path_buf()),
9736            SessionStoreKind::Sqlite,
9737        );
9738        session.append_message(make_test_message("sqlite"));
9739
9740        run_async(async { session.save().await }).unwrap();
9741        let path = session.path.clone().expect("sqlite session path");
9742        let session_dir = path.parent().expect("session parent").to_path_buf();
9743        let (base_ms, base_size) = session_file_stats(&path).expect("base stats");
9744
9745        let mut wal_path = path.as_os_str().to_os_string();
9746        wal_path.push("-wal");
9747        let wal_path = PathBuf::from(wal_path);
9748        std::thread::sleep(std::time::Duration::from_millis(1_100));
9749        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9750
9751        let stale_known_entry = SessionPickEntry {
9752            path: path.clone(),
9753            id: session.header.id.clone(),
9754            cwd: session.header.cwd.clone(),
9755            timestamp: session.header.timestamp.clone(),
9756            message_count: 999,
9757            name: Some("stale".to_string()),
9758            last_modified_ms: base_ms,
9759            size_bytes: base_size,
9760        };
9761
9762        let scanned =
9763            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9764                .expect("scan sessions");
9765        let (updated_ms, updated_size) = session_file_stats(&path).expect("updated stats");
9766
9767        assert!(scanned.failed_paths.is_empty());
9768        assert_eq!(scanned.entries.len(), 1);
9769        assert_eq!(scanned.refreshed_entries.len(), 1);
9770        assert_eq!(scanned.entries[0].path, path);
9771        assert_eq!(scanned.entries[0].message_count, 1);
9772        assert_eq!(scanned.entries[0].size_bytes, updated_size);
9773        assert_eq!(scanned.entries[0].last_modified_ms, updated_ms);
9774    }
9775
9776    #[cfg(feature = "sqlite-sessions")]
9777    #[test]
9778    fn test_load_session_meta_sqlite_uses_wal_aware_stats() {
9779        let temp = tempfile::tempdir().unwrap();
9780        let mut session = Session::create_with_dir_and_store(
9781            Some(temp.path().to_path_buf()),
9782            SessionStoreKind::Sqlite,
9783        );
9784        session.append_message(make_test_message("sqlite"));
9785
9786        run_async(async { session.save().await }).unwrap();
9787        let path = session.path.clone().expect("sqlite session path");
9788
9789        let mut wal_path = path.as_os_str().to_os_string();
9790        wal_path.push("-wal");
9791        let wal_path = PathBuf::from(wal_path);
9792        std::thread::sleep(std::time::Duration::from_millis(1_100));
9793        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9794
9795        let meta = load_session_meta_sqlite(&path).expect("load sqlite meta");
9796        let (expected_ms, expected_size) = session_file_stats(&path).expect("sqlite file stats");
9797
9798        assert_eq!(meta.path, path);
9799        assert_eq!(meta.size_bytes, expected_size);
9800        assert_eq!(meta.last_modified_ms, expected_ms);
9801    }
9802
9803    // ======================================================================
9804    // All entries corrupted (only header valid)
9805    // ======================================================================
9806
9807    #[test]
9808    fn test_all_entries_corrupted_produces_empty_session() {
9809        let temp = tempfile::tempdir().unwrap();
9810        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9811        session.append_message(make_test_message("A"));
9812        session.append_message(make_test_message("B"));
9813
9814        run_async(async { session.save().await }).unwrap();
9815        let path = session.path.clone().unwrap();
9816
9817        let mut lines: Vec<String> = std::fs::read_to_string(&path)
9818            .unwrap()
9819            .lines()
9820            .map(str::to_string)
9821            .collect();
9822        // Corrupt all entry lines (keep header at index 0)
9823        for (i, line) in lines.iter_mut().enumerate().skip(1) {
9824            *line = format!("GARBAGE_{i}");
9825        }
9826        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
9827
9828        let (loaded, diagnostics) = run_async(async {
9829            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
9830        })
9831        .unwrap();
9832
9833        assert_eq!(diagnostics.skipped_entries.len(), 2);
9834        assert!(loaded.entries.is_empty());
9835        assert!(loaded.leaf_id.is_none());
9836        // Header should still be valid
9837        assert_eq!(loaded.header.id, session.header.id);
9838    }
9839
9840    // ======================================================================
9841    // Unicode and special character content
9842    // ======================================================================
9843
9844    #[test]
9845    fn test_unicode_content_round_trip() {
9846        let temp = tempfile::tempdir().unwrap();
9847        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9848
9849        let unicode_texts = [
9850            "Hello \u{1F600} World",    // emoji
9851            "\u{4F60}\u{597D}",         // Chinese
9852            "\u{0410}\u{0411}\u{0412}", // Cyrillic
9853            "caf\u{00E9}",              // accented
9854            "tab\there\nnewline",       // control chars
9855            "\"quoted\" and \\escaped", // JSON special chars
9856        ];
9857
9858        for text in &unicode_texts {
9859            session.append_message(make_test_message(text));
9860        }
9861
9862        run_async(async { session.save().await }).unwrap();
9863        let path = session.path.clone().unwrap();
9864
9865        let loaded =
9866            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9867
9868        assert_eq!(loaded.entries.len(), unicode_texts.len());
9869
9870        for (i, entry) in loaded.entries.iter().enumerate() {
9871            if let SessionEntry::Message(msg) = entry {
9872                if let SessionMessage::User { content, .. } = &msg.message {
9873                    match content {
9874                        UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
9875                        UserContent::Blocks(_) => {
9876                            test_fail!("Expected UserContent::Text, got Blocks")
9877                        }
9878                    }
9879                }
9880            }
9881        }
9882    }
9883
9884    // ======================================================================
9885    // Multiple compactions
9886    // ======================================================================
9887
9888    #[test]
9889    fn test_multiple_compactions_latest_wins() {
9890        let mut session = Session::in_memory();
9891
9892        let _id_a = session.append_message(make_test_message("old A"));
9893        let _id_b = session.append_message(make_test_message("old B"));
9894        let id_c = session.append_message(make_test_message("kept C"));
9895
9896        // First compaction: keep from C
9897        session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
9898
9899        let _id_d = session.append_message(make_test_message("new D"));
9900        let id_e = session.append_message(make_test_message("new E"));
9901
9902        // Second compaction: keep from E
9903        session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
9904
9905        let id_f = session.append_message(make_test_message("newest F"));
9906
9907        session.navigate_to(&id_f);
9908        let messages = session.to_messages_for_current_path();
9909
9910        // Old messages A, B should definitely not appear
9911        let all_text: String = messages
9912            .iter()
9913            .filter_map(|m| match m {
9914                Message::User(u) => match &u.content {
9915                    UserContent::Text(t) => Some(t.clone()),
9916                    UserContent::Blocks(_) => None,
9917                },
9918                _ => None,
9919            })
9920            .collect::<Vec<_>>()
9921            .join(" ");
9922
9923        assert!(!all_text.contains("old A"), "A should be compacted away");
9924        assert!(!all_text.contains("old B"), "B should be compacted away");
9925    }
9926
9927    // ======================================================================
9928    // Session with only metadata entries (no messages)
9929    // ======================================================================
9930
9931    #[test]
9932    fn test_session_with_only_metadata_entries() {
9933        let mut session = Session::in_memory();
9934
9935        session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
9936        session.append_thinking_level_change("high".to_string());
9937        session.set_name("metadata-only");
9938
9939        // to_messages should return empty (no actual messages)
9940        let messages = session.to_messages();
9941        assert!(messages.is_empty());
9942
9943        // entries_for_current_path should still return the metadata entries
9944        let entries = session.entries_for_current_path();
9945        assert_eq!(entries.len(), 3);
9946    }
9947
9948    #[test]
9949    fn test_metadata_only_session_round_trip() {
9950        let temp = tempfile::tempdir().unwrap();
9951        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9952
9953        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
9954        session.append_thinking_level_change("medium".to_string());
9955
9956        run_async(async { session.save().await }).unwrap();
9957        let path = session.path.clone().unwrap();
9958
9959        let loaded =
9960            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9961
9962        assert_eq!(loaded.entries.len(), 2);
9963        assert!(
9964            loaded
9965                .entries
9966                .iter()
9967                .any(|e| matches!(e, SessionEntry::ModelChange(_)))
9968        );
9969        assert!(
9970            loaded
9971                .entries
9972                .iter()
9973                .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
9974        );
9975    }
9976
9977    // ======================================================================
9978    // Session name round-trip persistence
9979    // ======================================================================
9980
9981    #[test]
9982    fn test_session_name_survives_round_trip() {
9983        let temp = tempfile::tempdir().unwrap();
9984        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9985
9986        session.append_message(make_test_message("Hello"));
9987        session.set_name("my-important-session");
9988
9989        run_async(async { session.save().await }).unwrap();
9990        let path = session.path.clone().unwrap();
9991
9992        let loaded =
9993            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9994
9995        assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
9996    }
9997
9998    // ======================================================================
9999    // Trailing newline / whitespace in JSONL
10000    // ======================================================================
10001
10002    #[test]
10003    fn test_trailing_whitespace_in_jsonl_ignored() {
10004        let temp = tempfile::tempdir().unwrap();
10005        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
10006        session.append_message(make_test_message("test"));
10007
10008        run_async(async { session.save().await }).unwrap();
10009        let path = session.path.clone().unwrap();
10010
10011        // Append extra blank lines at the end
10012        let mut contents = std::fs::read_to_string(&path).unwrap();
10013        contents.push_str("\n\n\n");
10014        std::fs::write(&path, contents).unwrap();
10015
10016        let loaded =
10017            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10018
10019        assert_eq!(loaded.entries.len(), 1);
10020    }
10021
10022    // ======================================================================
10023    // Branching after compaction
10024    // ======================================================================
10025
10026    #[test]
10027    fn test_branching_after_compaction() {
10028        let mut session = Session::in_memory();
10029
10030        let _id_a = session.append_message(make_test_message("old A"));
10031        let id_b = session.append_message(make_test_message("kept B"));
10032
10033        session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
10034
10035        let id_c = session.append_message(make_test_message("C after compaction"));
10036
10037        // Branch from B (the compaction keep-point)
10038        session.create_branch_from(&id_b);
10039        let id_d = session.append_message(make_test_message("D branch after compaction"));
10040
10041        let leaves = session.list_leaves();
10042        assert_eq!(leaves.len(), 2);
10043        assert!(leaves.contains(&id_c));
10044        assert!(leaves.contains(&id_d));
10045    }
10046
10047    // ======================================================================
10048    // Assistant message with tool calls round-trip
10049    // ======================================================================
10050
10051    #[test]
10052    fn test_assistant_with_tool_calls_round_trip() {
10053        let temp = tempfile::tempdir().unwrap();
10054        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
10055
10056        session.append_message(make_test_message("read my file"));
10057
10058        let assistant = AssistantMessage {
10059            content: vec![
10060                ContentBlock::Text(TextContent::new("Let me read that for you.")),
10061                ContentBlock::ToolCall(crate::model::ToolCall {
10062                    id: "call_abc".to_string(),
10063                    name: "read".to_string(),
10064                    arguments: serde_json::json!({"path": "src/main.rs"}),
10065                    thought_signature: None,
10066                }),
10067            ],
10068            api: "anthropic".to_string(),
10069            provider: "anthropic".to_string(),
10070            model: "claude-test".to_string(),
10071            usage: Usage {
10072                input: 100,
10073                output: 50,
10074                cache_read: 0,
10075                cache_write: 0,
10076                total_tokens: 150,
10077                cost: Cost::default(),
10078            },
10079            stop_reason: StopReason::ToolUse,
10080            error_message: None,
10081            timestamp: 12345,
10082        };
10083        session.append_message(SessionMessage::Assistant { message: assistant });
10084
10085        session.append_message(SessionMessage::ToolResult {
10086            tool_call_id: "call_abc".to_string(),
10087            tool_name: "read".to_string(),
10088            content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
10089            details: Some(serde_json::json!({"lines": 1, "truncated": false})),
10090            is_error: false,
10091            timestamp: Some(12346),
10092        });
10093
10094        run_async(async { session.save().await }).unwrap();
10095        let path = session.path.clone().unwrap();
10096
10097        let loaded =
10098            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10099
10100        assert_eq!(loaded.entries.len(), 3);
10101
10102        // Verify tool call content survived
10103        let has_tool_call = loaded.entries.iter().any(|e| {
10104            if let SessionEntry::Message(msg) = e {
10105                if let SessionMessage::Assistant { message } = &msg.message {
10106                    return message
10107                        .content
10108                        .iter()
10109                        .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id.eq("call_abc")));
10110                }
10111            }
10112            false
10113        });
10114        assert!(has_tool_call, "tool call should survive round-trip");
10115
10116        // Verify tool result details survived
10117        let has_details = loaded.entries.iter().any(|e| {
10118            if let SessionEntry::Message(msg) = e {
10119                if let SessionMessage::ToolResult { details, .. } = &msg.message {
10120                    return details.is_some();
10121                }
10122            }
10123            false
10124        });
10125        assert!(has_details, "tool result details should survive round-trip");
10126    }
10127
10128    // ======================================================================
10129    // FUZZ-P1.4: Proptest coverage for Session JSONL parsing
10130    // ======================================================================
10131
10132    mod proptest_session {
10133        use super::*;
10134        use proptest::prelude::*;
10135        use serde_json::json;
10136
10137        /// Generate a random valid timestamp string.
10138        fn timestamp_strategy() -> impl Strategy<Value = String> {
10139            (
10140                2020u32..2030,
10141                1u32..13,
10142                1u32..29,
10143                0u32..24,
10144                0u32..60,
10145                0u32..60,
10146            )
10147                .prop_map(|(y, mo, d, h, mi, s)| {
10148                    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
10149                })
10150        }
10151
10152        /// Generate a random entry ID (8 hex chars).
10153        fn entry_id_strategy() -> impl Strategy<Value = String> {
10154            "[0-9a-f]{8}"
10155        }
10156
10157        /// Generate an arbitrary JSON value of bounded depth/size.
10158        fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
10159            if max_depth == 0 {
10160                prop_oneof![
10161                    Just(json!(null)),
10162                    any::<bool>().prop_map(|b| json!(b)),
10163                    any::<i64>().prop_map(|n| json!(n)),
10164                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10165                ]
10166                .boxed()
10167            } else {
10168                prop_oneof![
10169                    Just(json!(null)),
10170                    any::<bool>().prop_map(|b| json!(b)),
10171                    any::<i64>().prop_map(|n| json!(n)),
10172                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10173                    prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
10174                        .prop_map(serde_json::Value::Array),
10175                ]
10176                .boxed()
10177            }
10178        }
10179
10180        /// Generate a valid `SessionEntry` JSON object for one of the known types.
10181        #[allow(clippy::too_many_lines)]
10182        fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
10183            let ts = timestamp_strategy();
10184            let eid = entry_id_strategy();
10185            let parent = prop::option::of(entry_id_strategy());
10186
10187            (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
10188                let base = json!({
10189                    "id": eid,
10190                    "parentId": parent,
10191                    "timestamp": ts,
10192                });
10193
10194                match variant {
10195                    0 => {
10196                        // Message - User
10197                        "[a-zA-Z0-9 ]{1,64}"
10198                            .prop_map(move |text| {
10199                                let mut v = base.clone();
10200                                v["type"] = json!("message");
10201                                v["message"] = json!({
10202                                    "role": "user",
10203                                    "content": text,
10204                                });
10205                                v
10206                            })
10207                            .boxed()
10208                    }
10209                    1 => {
10210                        // Message - Assistant
10211                        "[a-zA-Z0-9 ]{1,64}"
10212                            .prop_map(move |text| {
10213                                let mut v = base.clone();
10214                                v["type"] = json!("message");
10215                                v["message"] = json!({
10216                                    "role": "assistant",
10217                                    "content": [{"type": "text", "text": text}],
10218                                    "api": "anthropic",
10219                                    "provider": "anthropic",
10220                                    "model": "test-model",
10221                                    "usage": {
10222                                        "input": 10,
10223                                        "output": 5,
10224                                        "cacheRead": 0,
10225                                        "cacheWrite": 0,
10226                                        "totalTokens": 15,
10227                                        "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
10228                                    },
10229                                    "stopReason": "end_turn",
10230                                    "timestamp": 12345,
10231                                });
10232                                v
10233                            })
10234                            .boxed()
10235                    }
10236                    2 => {
10237                        // ModelChange
10238                        ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
10239                            .prop_map(move |(provider, model)| {
10240                                let mut v = base.clone();
10241                                v["type"] = json!("model_change");
10242                                v["provider"] = json!(provider);
10243                                v["modelId"] = json!(model);
10244                                v
10245                            })
10246                            .boxed()
10247                    }
10248                    3 => {
10249                        // ThinkingLevelChange
10250                        prop_oneof![
10251                            Just("off".to_string()),
10252                            Just("low".to_string()),
10253                            Just("medium".to_string()),
10254                            Just("high".to_string()),
10255                        ]
10256                        .prop_map(move |level| {
10257                            let mut v = base.clone();
10258                            v["type"] = json!("thinking_level_change");
10259                            v["thinkingLevel"] = json!(level);
10260                            v
10261                        })
10262                        .boxed()
10263                    }
10264                    4 => {
10265                        // Compaction
10266                        ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
10267                            .prop_map(move |(summary, kept_id, tokens)| {
10268                                let mut v = base.clone();
10269                                v["type"] = json!("compaction");
10270                                v["summary"] = json!(summary);
10271                                v["firstKeptEntryId"] = json!(kept_id);
10272                                v["tokensBefore"] = json!(tokens);
10273                                v
10274                            })
10275                            .boxed()
10276                    }
10277                    5 => {
10278                        // Label
10279                        (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
10280                            .prop_map(move |(target, label)| {
10281                                let mut v = base.clone();
10282                                v["type"] = json!("label");
10283                                v["targetId"] = json!(target);
10284                                if let Some(l) = label {
10285                                    v["label"] = json!(l);
10286                                }
10287                                v
10288                            })
10289                            .boxed()
10290                    }
10291                    6 => {
10292                        // SessionInfo
10293                        prop::option::of("[a-zA-Z0-9 ]{1,32}")
10294                            .prop_map(move |name| {
10295                                let mut v = base.clone();
10296                                v["type"] = json!("session_info");
10297                                if let Some(n) = name {
10298                                    v["name"] = json!(n);
10299                                }
10300                                v
10301                            })
10302                            .boxed()
10303                    }
10304                    _ => {
10305                        // Custom
10306                        ("[a-z_]{3,12}", bounded_json_value(2))
10307                            .prop_map(move |(custom_type, data)| {
10308                                let mut v = base.clone();
10309                                v["type"] = json!("custom");
10310                                v["customType"] = json!(custom_type);
10311                                v["data"] = data;
10312                                v
10313                            })
10314                            .boxed()
10315                    }
10316                }
10317            })
10318        }
10319
10320        /// Generate a corrupted JSON line (valid JSON but wrong shape for `SessionEntry`).
10321        fn corrupted_entry_json() -> impl Strategy<Value = String> {
10322            prop_oneof![
10323                // Missing "type" field
10324                Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10325                // Unknown type
10326                Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10327                // Empty object
10328                Just(r"{}".to_string()),
10329                // Array instead of object
10330                Just(r"[1,2,3]".to_string()),
10331                // Scalar values
10332                Just(r"42".to_string()),
10333                Just(r#""just a string""#.to_string()),
10334                Just(r"null".to_string()),
10335                Just(r"true".to_string()),
10336                // Truncated JSON (simulating crash)
10337                Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
10338                // Valid JSON with wrong field types
10339                Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10340            ]
10341        }
10342
10343        /// Build a complete JSONL file string from header + entries.
10344        fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
10345            let mut lines = vec![header.to_string()];
10346            lines.extend(entry_lines.iter().cloned());
10347            lines.join("\n")
10348        }
10349
10350        // ------------------------------------------------------------------
10351        // Proptest 1: SessionEntry deserialization never panics
10352        // ------------------------------------------------------------------
10353        proptest! {
10354            #![proptest_config(ProptestConfig {
10355                cases: 256,
10356                max_shrink_iters: 200,
10357                .. ProptestConfig::default()
10358            })]
10359
10360            #[test]
10361            fn session_entry_deser_never_panics(
10362                entry_json in valid_session_entry_json()
10363            ) {
10364                let json_str = entry_json.to_string();
10365                // Must not panic — Ok or Err is fine
10366                let _ = serde_json::from_str::<SessionEntry>(&json_str);
10367            }
10368        }
10369
10370        // ------------------------------------------------------------------
10371        // Proptest 2: Corrupted/malformed input never panics
10372        // ------------------------------------------------------------------
10373        proptest! {
10374            #![proptest_config(ProptestConfig {
10375                cases: 256,
10376                max_shrink_iters: 200,
10377                .. ProptestConfig::default()
10378            })]
10379
10380            #[test]
10381            fn corrupted_entry_deser_never_panics(
10382                line in corrupted_entry_json()
10383            ) {
10384                let _ = serde_json::from_str::<SessionEntry>(&line);
10385            }
10386
10387            #[test]
10388            fn arbitrary_bytes_deser_never_panics(
10389                raw in prop::collection::vec(any::<u8>(), 0..512)
10390            ) {
10391                // Even random bytes must not panic serde
10392                if let Ok(s) = String::from_utf8(raw) {
10393                    let _ = serde_json::from_str::<SessionEntry>(&s);
10394                }
10395            }
10396        }
10397
10398        // ------------------------------------------------------------------
10399        // Proptest 3: Valid entries round-trip through serialization
10400        // ------------------------------------------------------------------
10401        proptest! {
10402            #![proptest_config(ProptestConfig {
10403                cases: 256,
10404                max_shrink_iters: 200,
10405                .. ProptestConfig::default()
10406            })]
10407
10408            #[test]
10409            fn valid_entry_round_trip(
10410                entry_json in valid_session_entry_json()
10411            ) {
10412                let json_str = entry_json.to_string();
10413                if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
10414                    // Serialize back
10415                    let reserialized = serde_json::to_string(&entry).unwrap();
10416                    // Deserialize again
10417                    let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
10418                    // Both should have the same entry ID
10419                    assert_eq!(entry.base_id(), re_entry.base_id());
10420                    // Both should have the same type tag
10421                    assert_eq!(
10422                        std::mem::discriminant(&entry),
10423                        std::mem::discriminant(&re_entry)
10424                    );
10425                }
10426            }
10427        }
10428
10429        // ------------------------------------------------------------------
10430        // Proptest 4: Full JSONL load with mixed valid/invalid lines
10431        //             recovers valid entries and reports diagnostics
10432        // ------------------------------------------------------------------
10433        proptest! {
10434            #![proptest_config(ProptestConfig {
10435                cases: 128,
10436                max_shrink_iters: 100,
10437                .. ProptestConfig::default()
10438            })]
10439
10440            #[test]
10441            fn jsonl_corrupted_recovery(
10442                valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
10443                corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
10444                interleave_seed in any::<u64>(),
10445            ) {
10446                let header_json = json!({
10447                    "type": "session",
10448                    "version": 3,
10449                    "id": "testid01",
10450                    "timestamp": "2024-01-01T00:00:00.000Z",
10451                    "cwd": "/tmp/test"
10452                }).to_string();
10453
10454                // Interleave valid and corrupted lines deterministically
10455                let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
10456                let total = valid_strs.len() + corrupted_lines.len();
10457                let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
10458                for s in &valid_strs {
10459                    all_lines.push((true, s.clone()));
10460                }
10461                for s in &corrupted_lines {
10462                    all_lines.push((false, s.clone()));
10463                }
10464
10465                // Deterministic shuffle based on seed
10466                let mut seed = interleave_seed;
10467                for i in (1..all_lines.len()).rev() {
10468                    seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
10469                    let j = (seed >> 33) as usize % (i + 1);
10470                    all_lines.swap(i, j);
10471                }
10472
10473                let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
10474                let content = build_jsonl(&header_json, &entry_lines);
10475
10476                // Write to temp file and load
10477                let temp_dir = tempfile::tempdir().unwrap();
10478                let file_path = temp_dir.path().join("test_session.jsonl");
10479                std::fs::write(&file_path, &content).unwrap();
10480
10481                let (session, diagnostics) = run_async(async {
10482                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10483                }).unwrap();
10484
10485                // Invariant: parsed + skipped == total lines (all non-empty)
10486                let total_parsed = session.entries.len();
10487                assert_eq!(
10488                    total_parsed + diagnostics.skipped_entries.len(),
10489                    total,
10490                    "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
10491                    diagnostics.skipped_entries.len()
10492                );
10493            }
10494        }
10495
10496        // ------------------------------------------------------------------
10497        // Proptest 5: Orphaned parent links are detected
10498        // ------------------------------------------------------------------
10499        proptest! {
10500            #![proptest_config(ProptestConfig {
10501                cases: 128,
10502                max_shrink_iters: 100,
10503                .. ProptestConfig::default()
10504            })]
10505
10506            #[test]
10507            fn orphaned_parent_links_detected(
10508                n_entries in 2usize..10,
10509                orphan_idx in 0usize..8,
10510            ) {
10511                let orphan_idx = orphan_idx % n_entries;
10512                let header_json = json!({
10513                    "type": "session",
10514                    "version": 3,
10515                    "id": "testid01",
10516                    "timestamp": "2024-01-01T00:00:00.000Z",
10517                    "cwd": "/tmp/test"
10518                }).to_string();
10519
10520                let mut entry_lines = Vec::new();
10521                let mut prev_id: Option<String> = None;
10522
10523                for i in 0..n_entries {
10524                    let eid = format!("{i:08x}");
10525                    let parent = if i.eq(&orphan_idx) {
10526                        // Point to a nonexistent parent
10527                        Some("deadbeef".to_string())
10528                    } else {
10529                        prev_id.clone()
10530                    };
10531
10532                    let entry = json!({
10533                        "type": "message",
10534                        "id": eid,
10535                        "parentId": parent,
10536                        "timestamp": "2024-01-01T00:00:00.000Z",
10537                        "message": {
10538                            "role": "user",
10539                            "content": format!("msg {i}"),
10540                        }
10541                    });
10542                    entry_lines.push(entry.to_string());
10543                    prev_id = Some(eid);
10544                }
10545
10546                let content = build_jsonl(&header_json, &entry_lines);
10547                let temp_dir = tempfile::tempdir().unwrap();
10548                let file_path = temp_dir.path().join("orphan_test.jsonl");
10549                std::fs::write(&file_path, &content).unwrap();
10550
10551                let (_session, diagnostics) = run_async(async {
10552                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10553                }).unwrap();
10554
10555                // The orphaned entry should be detected
10556                let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
10557                    o.missing_parent_id == "deadbeef"
10558                });
10559                assert!(
10560                    has_orphan,
10561                    "orphaned parent link to 'deadbeef' should be detected"
10562                );
10563            }
10564        }
10565
10566        // ------------------------------------------------------------------
10567        // Proptest 6: ensure_entry_ids assigns IDs to entries without them
10568        // ------------------------------------------------------------------
10569        proptest! {
10570            #![proptest_config(ProptestConfig {
10571                cases: 128,
10572                max_shrink_iters: 100,
10573                .. ProptestConfig::default()
10574            })]
10575
10576            #[test]
10577            fn ensure_entry_ids_fills_gaps(
10578                n_total in 1usize..20,
10579                missing_mask in prop::collection::vec(any::<bool>(), 1..20),
10580            ) {
10581                let n = n_total.min(missing_mask.len());
10582                let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
10583                    let id = if missing_mask[i] {
10584                        None
10585                    } else {
10586                        Some(format!("{i:08x}"))
10587                    };
10588                    SessionEntry::Message(MessageEntry {
10589                        base: EntryBase {
10590                            id,
10591                            parent_id: None,
10592                            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
10593                        },
10594                        message: SessionMessage::User {
10595                            content: UserContent::Text(format!("msg {i}")),
10596                            timestamp: Some(0),
10597                        },
10598                    })
10599                }).collect();
10600
10601                ensure_entry_ids(&mut entries);
10602
10603                // All entries must have IDs after the call
10604                for entry in &entries {
10605                    assert!(
10606                        entry.base_id().is_some(),
10607                        "all entries must have IDs after ensure_entry_ids"
10608                    );
10609                }
10610
10611                // All IDs must be unique
10612                let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
10613                let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
10614                assert_eq!(
10615                    ids.len(),
10616                    unique.len(),
10617                    "all entry IDs must be unique"
10618                );
10619            }
10620        }
10621
10622        // ------------------------------------------------------------------
10623        // Proptest 7: SessionHeader deserialization with boundary values
10624        // ------------------------------------------------------------------
10625        proptest! {
10626            #![proptest_config(ProptestConfig {
10627                cases: 256,
10628                max_shrink_iters: 200,
10629                .. ProptestConfig::default()
10630            })]
10631
10632            #[test]
10633            fn session_header_deser_never_panics(
10634                version in prop::option::of(0u8..255),
10635                id in "[a-zA-Z0-9-]{0,64}",
10636                ts in timestamp_strategy(),
10637                cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
10638                provider in prop::option::of("[a-z]{2,10}"),
10639                model_id in prop::option::of("[a-z0-9-]{2,20}"),
10640                thinking_level in prop::option::of("[a-z]{2,8}"),
10641            ) {
10642                let mut obj = json!({
10643                    "type": "session",
10644                    "id": id,
10645                    "timestamp": ts,
10646                    "cwd": cwd,
10647                });
10648                if let Some(v) = version {
10649                    obj["version"] = json!(v);
10650                }
10651                if let Some(p) = &provider {
10652                    obj["provider"] = json!(p);
10653                }
10654                if let Some(m) = &model_id {
10655                    obj["modelId"] = json!(m);
10656                }
10657                if let Some(t) = &thinking_level {
10658                    obj["thinkingLevel"] = json!(t);
10659                }
10660                let json_str = obj.to_string();
10661                let _ = serde_json::from_str::<SessionHeader>(&json_str);
10662            }
10663        }
10664
10665        // ------------------------------------------------------------------
10666        // Proptest 8: Edge-case JSONL files
10667        // ------------------------------------------------------------------
10668
10669        #[test]
10670        fn empty_file_returns_error() {
10671            let temp_dir = tempfile::tempdir().unwrap();
10672            let file_path = temp_dir.path().join("empty.jsonl");
10673            std::fs::write(&file_path, "").unwrap();
10674
10675            let result = run_async(async {
10676                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10677            });
10678            assert!(result.is_err(), "empty file should return error");
10679        }
10680
10681        #[test]
10682        fn header_only_file_produces_empty_session() {
10683            let header = json!({
10684                "type": "session",
10685                "version": 3,
10686                "id": "testid01",
10687                "timestamp": "2024-01-01T00:00:00.000Z",
10688                "cwd": "/tmp/test"
10689            })
10690            .to_string();
10691
10692            let temp_dir = tempfile::tempdir().unwrap();
10693            let file_path = temp_dir.path().join("header_only.jsonl");
10694            std::fs::write(&file_path, &header).unwrap();
10695
10696            let (session, diagnostics) = run_async(async {
10697                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10698            })
10699            .unwrap();
10700
10701            assert!(
10702                session.entries.is_empty(),
10703                "header-only file should have no entries"
10704            );
10705            assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
10706        }
10707
10708        #[test]
10709        fn file_with_only_invalid_lines_has_diagnostics() {
10710            let header = json!({
10711                "type": "session",
10712                "version": 3,
10713                "id": "testid01",
10714                "timestamp": "2024-01-01T00:00:00.000Z",
10715                "cwd": "/tmp/test"
10716            })
10717            .to_string();
10718
10719            let content = format!(
10720                "{}\n{}\n{}\n{}",
10721                header,
10722                r#"{"bad":"json","no":"type"}"#,
10723                r"not json at all",
10724                r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
10725            );
10726
10727            let temp_dir = tempfile::tempdir().unwrap();
10728            let file_path = temp_dir.path().join("all_invalid.jsonl");
10729            std::fs::write(&file_path, &content).unwrap();
10730
10731            let (session, diagnostics) = run_async(async {
10732                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10733            })
10734            .unwrap();
10735
10736            assert!(
10737                session.entries.is_empty(),
10738                "all-invalid file should have no entries"
10739            );
10740            assert_eq!(
10741                diagnostics.skipped_entries.len(),
10742                3,
10743                "should have 3 skipped entries"
10744            );
10745        }
10746
10747        #[test]
10748        fn duplicate_entry_ids_are_loaded_without_panic() {
10749            let header = json!({
10750                "type": "session",
10751                "version": 3,
10752                "id": "testid01",
10753                "timestamp": "2024-01-01T00:00:00.000Z",
10754                "cwd": "/tmp/test"
10755            })
10756            .to_string();
10757
10758            let entry1 = json!({
10759                "type": "message",
10760                "id": "deadbeef",
10761                "timestamp": "2024-01-01T00:00:00.000Z",
10762                "message": {"role": "user", "content": "first"}
10763            })
10764            .to_string();
10765
10766            let entry2 = json!({
10767                "type": "message",
10768                "id": "deadbeef",
10769                "timestamp": "2024-01-01T00:00:01.000Z",
10770                "message": {"role": "user", "content": "second (duplicate id)"}
10771            })
10772            .to_string();
10773
10774            let content = format!("{header}\n{entry1}\n{entry2}");
10775
10776            let temp_dir = tempfile::tempdir().unwrap();
10777            let file_path = temp_dir.path().join("dup_ids.jsonl");
10778            std::fs::write(&file_path, &content).unwrap();
10779
10780            // Must not panic
10781            let (session, _diagnostics) = run_async(async {
10782                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10783            })
10784            .unwrap();
10785
10786            assert_eq!(session.entries.len(), 2, "both entries should be loaded");
10787        }
10788    }
10789
10790    // ------------------------------------------------------------------
10791    // Incremental append tests
10792    // ------------------------------------------------------------------
10793
10794    #[test]
10795    fn test_incremental_append_writes_only_new_entries() {
10796        let temp_dir = tempfile::tempdir().expect("temp dir");
10797        let mut session = Session::create();
10798        session.session_dir = Some(temp_dir.path().to_path_buf());
10799
10800        // First save: full rewrite (persisted_entry_count == 0).
10801        session.append_message(make_test_message("msg A"));
10802        session.append_message(make_test_message("msg B"));
10803        run_async(async { session.save().await }).unwrap();
10804
10805        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10806        assert_eq!(session.appends_since_checkpoint, 0);
10807
10808        let path = session.path.clone().unwrap();
10809        let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
10810        // 1 header + 2 entries = 3 lines
10811        assert_eq!(lines_after_first, 3);
10812
10813        // Add more entries and save again (incremental append).
10814        session.append_message(make_test_message("msg C"));
10815        run_async(async { session.save().await }).unwrap();
10816
10817        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10818        assert_eq!(session.appends_since_checkpoint, 1);
10819
10820        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10821        // 1 header + 3 entries = 4 lines
10822        assert_eq!(lines_after_second, 4);
10823    }
10824
10825    #[test]
10826    fn test_header_change_forces_full_rewrite() {
10827        let temp_dir = tempfile::tempdir().expect("temp dir");
10828        let mut session = Session::create();
10829        session.session_dir = Some(temp_dir.path().to_path_buf());
10830
10831        session.append_message(make_test_message("msg A"));
10832        run_async(async { session.save().await }).unwrap();
10833        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10834        assert!(!session.header_dirty);
10835
10836        // Modify header.
10837        session.set_model_header(Some("new-provider".to_string()), None, None);
10838        assert!(session.header_dirty);
10839
10840        session.append_message(make_test_message("msg B"));
10841        run_async(async { session.save().await }).unwrap();
10842
10843        // Full rewrite resets all counters.
10844        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10845        assert!(!session.header_dirty);
10846        assert_eq!(session.appends_since_checkpoint, 0);
10847
10848        // Verify header on disk has the new provider.
10849        let path = session.path.clone().unwrap();
10850        let first_line = std::fs::read_to_string(&path)
10851            .unwrap()
10852            .lines()
10853            .next()
10854            .unwrap()
10855            .to_string();
10856        let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
10857        assert_eq!(header["provider"], "new-provider");
10858    }
10859
10860    #[test]
10861    fn test_compaction_entry_uses_incremental_append() {
10862        let temp_dir = tempfile::tempdir().expect("temp dir");
10863        let mut session = Session::create();
10864        session.session_dir = Some(temp_dir.path().to_path_buf());
10865
10866        let id_a = session.append_message(make_test_message("msg A"));
10867        run_async(async { session.save().await }).unwrap();
10868        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10869
10870        // Append a compaction entry. This should still be eligible for
10871        // incremental append; checkpoint rewrite cadence handles periodic
10872        // full rewrites for cleanup/corruption recovery.
10873        session.append_compaction("summary".to_string(), id_a, 100, None, None);
10874        session.append_message(make_test_message("msg B"));
10875
10876        run_async(async { session.save().await }).unwrap();
10877
10878        // Incremental append: persisted count advances and checkpoint counter increments.
10879        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10880        assert_eq!(session.appends_since_checkpoint, 1);
10881
10882        let path = session.path.clone().unwrap();
10883        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10884        // 1 header + 3 entries = 4 lines
10885        assert_eq!(lines_after_second, 4);
10886    }
10887
10888    #[test]
10889    fn test_checkpoint_interval_forces_full_rewrite() {
10890        let temp_dir = tempfile::tempdir().expect("temp dir");
10891        let mut session = Session::create();
10892        session.session_dir = Some(temp_dir.path().to_path_buf());
10893
10894        // First save (full rewrite).
10895        session.append_message(make_test_message("initial"));
10896        run_async(async { session.save().await }).unwrap();
10897
10898        // Simulate many incremental appends by setting the counter near threshold.
10899        let interval = compaction_checkpoint_interval();
10900        session.appends_since_checkpoint = interval;
10901
10902        // Next save should trigger full rewrite due to checkpoint.
10903        session.append_message(make_test_message("triggers checkpoint"));
10904        run_async(async { session.save().await }).unwrap();
10905
10906        // Full rewrite resets counters.
10907        assert_eq!(session.appends_since_checkpoint, 0);
10908        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10909    }
10910
10911    #[test]
10912    fn test_incremental_append_load_round_trip() {
10913        let temp_dir = tempfile::tempdir().expect("temp dir");
10914        let mut session = Session::create();
10915        session.session_dir = Some(temp_dir.path().to_path_buf());
10916
10917        // First save.
10918        session.append_message(make_test_message("msg A"));
10919        session.append_message(make_test_message("msg B"));
10920        run_async(async { session.save().await }).unwrap();
10921
10922        // Incremental append.
10923        session.append_message(make_test_message("msg C"));
10924        run_async(async { session.save().await }).unwrap();
10925
10926        let path = session.path.clone().unwrap();
10927
10928        // Reload and verify all entries present.
10929        let loaded =
10930            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10931
10932        assert_eq!(loaded.entries.len(), 3);
10933        // Verify the entry content by checking that we have messages A, B, C.
10934        let texts: Vec<&str> = loaded
10935            .entries
10936            .iter()
10937            .filter_map(|e| match e {
10938                SessionEntry::Message(m) => match &m.message {
10939                    SessionMessage::User {
10940                        content: UserContent::Text(t),
10941                        ..
10942                    } => Some(t.as_str()),
10943                    _ => None,
10944                },
10945                _ => None,
10946            })
10947            .collect();
10948        assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
10949    }
10950
10951    #[test]
10952    fn test_persisted_entry_count_set_on_open() {
10953        let temp_dir = tempfile::tempdir().expect("temp dir");
10954        let mut session = Session::create();
10955        session.session_dir = Some(temp_dir.path().to_path_buf());
10956
10957        session.append_message(make_test_message("msg A"));
10958        session.append_message(make_test_message("msg B"));
10959        session.append_message(make_test_message("msg C"));
10960        run_async(async { session.save().await }).unwrap();
10961
10962        let path = session.path.clone().unwrap();
10963        let loaded =
10964            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10965
10966        assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
10967        assert!(!loaded.header_dirty);
10968        assert_eq!(loaded.appends_since_checkpoint, 0);
10969    }
10970
10971    #[test]
10972    fn test_no_new_entries_is_noop() {
10973        let temp_dir = tempfile::tempdir().expect("temp dir");
10974        let mut session = Session::create();
10975        session.session_dir = Some(temp_dir.path().to_path_buf());
10976
10977        session.append_message(make_test_message("msg A"));
10978        run_async(async { session.save().await }).unwrap();
10979
10980        let path = session.path.clone().unwrap();
10981        let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
10982
10983        // Sleep briefly to ensure mtime would change if file was written.
10984        std::thread::sleep(std::time::Duration::from_millis(50));
10985
10986        // Save again with no changes.
10987        run_async(async { session.save().await }).unwrap();
10988
10989        let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
10990        assert_eq!(
10991            mtime_before, mtime_after,
10992            "file should not be modified on no-op save"
10993        );
10994        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10995    }
10996
10997    #[test]
10998    fn test_incremental_append_caches_stay_valid() {
10999        let temp_dir = tempfile::tempdir().expect("temp dir");
11000        let mut session = Session::create();
11001        session.session_dir = Some(temp_dir.path().to_path_buf());
11002
11003        session.append_message(make_test_message("msg A"));
11004        run_async(async { session.save().await }).unwrap();
11005
11006        // After full rewrite, caches rebuilt.
11007        assert_eq!(session.entry_index.len(), 1);
11008
11009        // Incremental append: add more entries.
11010        let id_b = session.append_message(make_test_message("msg B"));
11011        let id_c = session.append_message(make_test_message("msg C"));
11012        run_async(async { session.save().await }).unwrap();
11013
11014        // Caches should still be valid (not rebuilt, but maintained incrementally).
11015        assert_eq!(session.entry_index.len(), 3);
11016        assert!(session.entry_index.contains_key(&id_b));
11017        assert!(session.entry_index.contains_key(&id_c));
11018        assert_eq!(session.cached_message_count, 3);
11019    }
11020
11021    #[test]
11022    fn test_set_branched_from_marks_header_dirty() {
11023        let mut session = Session::create();
11024        assert!(!session.header_dirty);
11025
11026        session.set_branched_from(Some("/some/path".to_string()));
11027        assert!(session.header_dirty);
11028    }
11029
11030    // ====================================================================
11031    // Crash-consistency and recovery tests (bd-3ar8v.2.7)
11032    // ====================================================================
11033
11034    /// Helper: build a valid JSONL session file string with header + N entries.
11035    fn build_crash_test_session_file(num_entries: usize) -> String {
11036        let header = serde_json::json!({
11037            "type": "session",
11038            "version": 3,
11039            "id": "crash-test",
11040            "timestamp": "2024-06-01T00:00:00.000Z",
11041            "cwd": "/tmp/test"
11042        });
11043        let mut lines = vec![serde_json::to_string(&header).unwrap()];
11044        for i in 0..num_entries {
11045            let entry = serde_json::json!({
11046                "type": "message",
11047                "id": format!("entry-{i}"),
11048                "timestamp": "2024-06-01T00:00:00.000Z",
11049                "message": {"role": "user", "content": format!("message {i}")}
11050            });
11051            lines.push(serde_json::to_string(&entry).unwrap());
11052        }
11053        lines.join("\n")
11054    }
11055
11056    #[test]
11057    fn crash_empty_file_returns_error() {
11058        let temp_dir = tempfile::tempdir().unwrap();
11059        let file_path = temp_dir.path().join("empty.jsonl");
11060        std::fs::write(&file_path, "").unwrap();
11061
11062        let result = run_async(async {
11063            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11064        });
11065        assert!(result.is_err(), "empty file should fail to open");
11066    }
11067
11068    #[test]
11069    fn crash_corrupted_header_returns_error() {
11070        let temp_dir = tempfile::tempdir().unwrap();
11071        let file_path = temp_dir.path().join("bad_header.jsonl");
11072        std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
11073
11074        let result = run_async(async {
11075            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11076        });
11077        assert!(result.is_err(), "corrupted header should fail");
11078    }
11079
11080    #[test]
11081    fn crash_header_only_loads_empty() {
11082        let temp_dir = tempfile::tempdir().unwrap();
11083        let file_path = temp_dir.path().join("header_only.jsonl");
11084        let header = serde_json::json!({
11085            "type": "session",
11086            "version": 3,
11087            "id": "hdr-only",
11088            "timestamp": "2024-06-01T00:00:00.000Z",
11089            "cwd": "/tmp/test"
11090        });
11091        std::fs::write(
11092            &file_path,
11093            format!("{}\n", serde_json::to_string(&header).unwrap()),
11094        )
11095        .unwrap();
11096
11097        let (session, diagnostics) = run_async(async {
11098            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11099        })
11100        .unwrap();
11101
11102        assert!(session.entries.is_empty());
11103        assert!(diagnostics.skipped_entries.is_empty());
11104    }
11105
11106    #[test]
11107    fn crash_truncated_last_entry_recovers_preceding() {
11108        let temp_dir = tempfile::tempdir().unwrap();
11109        let file_path = temp_dir.path().join("truncated.jsonl");
11110
11111        let mut content = build_crash_test_session_file(3);
11112        let truncation_point = content.rfind('\n').unwrap();
11113        content.truncate(truncation_point);
11114        content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
11115
11116        std::fs::write(&file_path, &content).unwrap();
11117
11118        let (session, diagnostics) = run_async(async {
11119            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11120        })
11121        .unwrap();
11122
11123        assert_eq!(session.entries.len(), 2);
11124        assert_eq!(diagnostics.skipped_entries.len(), 1);
11125    }
11126
11127    #[test]
11128    fn crash_multiple_corrupted_entries_recovers_valid() {
11129        let temp_dir = tempfile::tempdir().unwrap();
11130        let file_path = temp_dir.path().join("multi_corrupt.jsonl");
11131
11132        let header = serde_json::json!({
11133            "type": "session",
11134            "version": 3,
11135            "id": "multi-corrupt",
11136            "timestamp": "2024-06-01T00:00:00.000Z",
11137            "cwd": "/tmp/test"
11138        });
11139
11140        let valid_entry = |id: &str, text: &str| {
11141            serde_json::json!({
11142                "type": "message",
11143                "id": id,
11144                "timestamp": "2024-06-01T00:00:00.000Z",
11145                "message": {"role": "user", "content": text}
11146            })
11147            .to_string()
11148        };
11149
11150        let lines = [
11151            serde_json::to_string(&header).unwrap(),
11152            valid_entry("v1", "first"),
11153            "GARBAGE LINE 1".to_string(),
11154            valid_entry("v2", "second"),
11155            "{incomplete json".to_string(),
11156            valid_entry("v3", "third"),
11157        ];
11158
11159        std::fs::write(&file_path, lines.join("\n")).unwrap();
11160
11161        let (session, diagnostics) = run_async(async {
11162            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11163        })
11164        .unwrap();
11165
11166        assert_eq!(session.entries.len(), 3, "3 valid entries survive");
11167        assert_eq!(diagnostics.skipped_entries.len(), 2);
11168    }
11169
11170    #[test]
11171    fn crash_incremental_append_survives_partial_write() {
11172        use std::io::Write;
11173
11174        let temp_dir = tempfile::tempdir().unwrap();
11175        let mut session = Session::create();
11176        session.session_dir = Some(temp_dir.path().to_path_buf());
11177
11178        session.append_message(make_test_message("msg A"));
11179        session.append_message(make_test_message("msg B"));
11180        run_async(async { session.save().await }).unwrap();
11181        let path = session.path.clone().unwrap();
11182
11183        // Simulate crash during append: write truncated entry.
11184        let mut file = std::fs::OpenOptions::new()
11185            .append(true)
11186            .open(&path)
11187            .unwrap();
11188        write!(
11189            file,
11190            "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
11191        )
11192        .unwrap();
11193        drop(file);
11194
11195        let (loaded, diagnostics) = run_async(async {
11196            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11197        })
11198        .unwrap();
11199
11200        assert_eq!(loaded.entries.len(), 2, "original entries recovered");
11201        assert_eq!(diagnostics.skipped_entries.len(), 1);
11202    }
11203
11204    #[test]
11205    fn crash_full_rewrite_atomic_persist() {
11206        let temp_dir = tempfile::tempdir().unwrap();
11207        let mut session = Session::create();
11208        session.session_dir = Some(temp_dir.path().to_path_buf());
11209
11210        session.append_message(make_test_message("original"));
11211        run_async(async { session.save().await }).unwrap();
11212        let path = session.path.clone().unwrap();
11213
11214        let original_content = std::fs::read_to_string(&path).unwrap();
11215
11216        session.set_model_header(Some("new-provider".to_string()), None, None);
11217        session.append_message(make_test_message("second"));
11218        run_async(async { session.save().await }).unwrap();
11219
11220        let new_content = std::fs::read_to_string(&path).unwrap();
11221        assert_ne!(original_content, new_content);
11222
11223        let loaded =
11224            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11225        assert_eq!(loaded.entries.len(), 2);
11226    }
11227
11228    #[cfg(unix)]
11229    #[test]
11230    fn crash_full_rewrite_preserves_existing_file_permissions() {
11231        use std::os::unix::fs::PermissionsExt;
11232
11233        let temp_dir = tempfile::tempdir().unwrap();
11234        let mut session = Session::create();
11235        session.session_dir = Some(temp_dir.path().to_path_buf());
11236
11237        session.append_message(make_test_message("original"));
11238        run_async(async { session.save().await }).unwrap();
11239        let path = session.path.clone().unwrap();
11240
11241        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11242
11243        session.set_model_header(Some("new-provider".to_string()), None, None);
11244        session.append_message(make_test_message("second"));
11245        run_async(async { session.save().await }).unwrap();
11246
11247        let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
11248        assert_eq!(mode, 0o444, "full rewrite must preserve existing mode bits");
11249    }
11250
11251    #[test]
11252    fn full_rewrite_preserves_entries_appended_by_other_writer() {
11253        let temp_dir = tempfile::tempdir().unwrap();
11254        let mut session = Session::create();
11255        session.session_dir = Some(temp_dir.path().to_path_buf());
11256
11257        session.append_message(make_test_message("original"));
11258        run_async(async { session.save().await }).unwrap();
11259        let path = session.path.clone().unwrap();
11260
11261        let mut stale_rewriter =
11262            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11263        let mut appender =
11264            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11265
11266        appender.append_message(make_test_message("from appender"));
11267        run_async(async { appender.save().await }).unwrap();
11268
11269        stale_rewriter.set_model_header(Some("new-provider".to_string()), None, None);
11270        run_async(async { stale_rewriter.save().await }).unwrap();
11271
11272        let loaded =
11273            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11274        let entry_texts = loaded
11275            .entries
11276            .iter()
11277            .filter_map(|entry| match entry {
11278                SessionEntry::Message(message) => match &message.message {
11279                    SessionMessage::User { content, .. } => match content {
11280                        UserContent::Text(text) => Some(text.clone()),
11281                        UserContent::Blocks(_) => None,
11282                    },
11283                    SessionMessage::Assistant { message } => {
11284                        message.content.iter().find_map(|block| match block {
11285                            ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()),
11286                            _ => None,
11287                        })
11288                    }
11289                    SessionMessage::ToolResult { .. } => None,
11290                    SessionMessage::Custom { .. } => None,
11291                    SessionMessage::BashExecution { .. } => None,
11292                    SessionMessage::BranchSummary { .. } => None,
11293                    SessionMessage::CompactionSummary { .. } => None,
11294                },
11295                _ => None,
11296            })
11297            .collect::<Vec<_>>();
11298
11299        assert!(
11300            entry_texts.iter().any(|text| text.eq("from appender")),
11301            "full rewrite should preserve entries appended after this session was opened"
11302        );
11303        assert_eq!(loaded.header.provider.as_deref(), Some("new-provider"));
11304    }
11305
11306    #[test]
11307    fn crash_flush_failure_restores_pending_mutations() {
11308        let mut queue = AutosaveQueue::with_limit(10);
11309
11310        queue.enqueue_mutation(AutosaveMutationKind::Message);
11311        queue.enqueue_mutation(AutosaveMutationKind::Message);
11312        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11313        assert_eq!(queue.pending_mutations, 3);
11314
11315        let ticket = queue
11316            .begin_flush(AutosaveFlushTrigger::Periodic)
11317            .expect("should have ticket");
11318        assert_eq!(queue.pending_mutations, 0);
11319
11320        queue.finish_flush(ticket, false);
11321        assert_eq!(queue.pending_mutations, 3, "mutations restored");
11322        assert_eq!(queue.flush_failed, 1);
11323    }
11324
11325    #[test]
11326    fn crash_flush_failure_respects_queue_capacity() {
11327        let mut queue = AutosaveQueue::with_limit(3);
11328
11329        for _ in 0..3 {
11330            queue.enqueue_mutation(AutosaveMutationKind::Message);
11331        }
11332        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11333
11334        queue.enqueue_mutation(AutosaveMutationKind::Message);
11335        queue.enqueue_mutation(AutosaveMutationKind::Message);
11336        assert_eq!(queue.pending_mutations, 2);
11337
11338        queue.finish_flush(ticket, false);
11339        assert_eq!(queue.pending_mutations, 3, "capped at max");
11340        assert!(queue.backpressure_events >= 2);
11341    }
11342
11343    #[test]
11344    fn crash_shutdown_strict_propagates_error() {
11345        let temp_dir = tempfile::tempdir().unwrap();
11346        let mut session = Session::create();
11347        session.path = Some(
11348            temp_dir
11349                .path()
11350                .join("nonexistent_dir")
11351                .join("session.jsonl"),
11352        );
11353        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
11354        session.append_message(make_test_message("must save"));
11355        session
11356            .autosave_queue
11357            .enqueue_mutation(AutosaveMutationKind::Message);
11358
11359        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11360        assert!(result.is_err(), "strict mode propagates errors");
11361    }
11362
11363    #[test]
11364    fn crash_shutdown_balanced_swallows_error() {
11365        let temp_dir = tempfile::tempdir().unwrap();
11366        let mut session = Session::create();
11367        session.path = Some(
11368            temp_dir
11369                .path()
11370                .join("nonexistent_dir")
11371                .join("session.jsonl"),
11372        );
11373        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
11374        session.append_message(make_test_message("best effort"));
11375        session
11376            .autosave_queue
11377            .enqueue_mutation(AutosaveMutationKind::Message);
11378
11379        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11380        assert!(result.is_ok(), "balanced mode swallows errors");
11381    }
11382
11383    #[test]
11384    fn crash_shutdown_throughput_skips_flush() {
11385        let temp_dir = tempfile::tempdir().unwrap();
11386        let mut session = Session::create();
11387        session.path = Some(
11388            temp_dir
11389                .path()
11390                .join("nonexistent_dir")
11391                .join("session.jsonl"),
11392        );
11393        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
11394        session.append_message(make_test_message("no flush"));
11395        session
11396            .autosave_queue
11397            .enqueue_mutation(AutosaveMutationKind::Message);
11398
11399        let result = run_async(async { session.flush_autosave_on_shutdown().await });
11400        assert!(result.is_ok());
11401        assert!(session.autosave_queue.pending_mutations > 0);
11402    }
11403
11404    #[test]
11405    fn crash_save_reload_preserves_all_entry_types() {
11406        let temp_dir = tempfile::tempdir().unwrap();
11407        let mut session = Session::create();
11408        session.session_dir = Some(temp_dir.path().to_path_buf());
11409
11410        let id_a = session.append_message(make_test_message("msg A"));
11411        session.append_model_change("provider-x".to_string(), "model-y".to_string());
11412        session.append_thinking_level_change("high".to_string());
11413        session.append_compaction("summary".to_string(), id_a, 500, None, None);
11414        session.append_message(make_test_message("msg B"));
11415
11416        run_async(async { session.save().await }).unwrap();
11417        let path = session.path.clone().unwrap();
11418
11419        let loaded =
11420            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11421        assert_eq!(loaded.entries.len(), session.entries.len());
11422    }
11423
11424    #[test]
11425    fn crash_checkpoint_rewrite_cleans_corruption() {
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("initial"));
11431        run_async(async { session.save().await }).unwrap();
11432        let path = session.path.clone().unwrap();
11433
11434        for i in 0..5 {
11435            session.append_message(make_test_message(&format!("msg {i}")));
11436            run_async(async { session.save().await }).unwrap();
11437        }
11438
11439        // Corrupt an appended entry on disk.
11440        let content = std::fs::read_to_string(&path).unwrap();
11441        let mut lines: Vec<String> = content.lines().map(String::from).collect();
11442        lines[3] = "CORRUPTED_ENTRY".to_string();
11443        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11444
11445        // Force checkpoint: full rewrite replaces corrupted file with clean data.
11446        session.appends_since_checkpoint = compaction_checkpoint_interval();
11447        session.append_message(make_test_message("post checkpoint"));
11448        run_async(async { session.save().await }).unwrap();
11449        assert_eq!(session.appends_since_checkpoint, 0);
11450
11451        let (reloaded, diagnostics) = run_async(async {
11452            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11453        })
11454        .unwrap();
11455        assert!(diagnostics.skipped_entries.is_empty());
11456        assert_eq!(reloaded.entries.len(), 7);
11457    }
11458
11459    #[test]
11460    fn crash_trailing_newlines_loads_cleanly() {
11461        let temp_dir = tempfile::tempdir().unwrap();
11462        let file_path = temp_dir.path().join("trailing_nl.jsonl");
11463
11464        let mut content = build_crash_test_session_file(2);
11465        content.push_str("\n\n\n");
11466        std::fs::write(&file_path, &content).unwrap();
11467
11468        let (session, diagnostics) = run_async(async {
11469            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11470        })
11471        .unwrap();
11472
11473        assert_eq!(session.entries.len(), 2);
11474        assert!(diagnostics.skipped_entries.is_empty());
11475    }
11476
11477    #[test]
11478    fn crash_noop_save_after_reload_is_idempotent() {
11479        let temp_dir = tempfile::tempdir().unwrap();
11480        let mut session = Session::create();
11481        session.session_dir = Some(temp_dir.path().to_path_buf());
11482
11483        session.append_message(make_test_message("hello"));
11484        run_async(async { session.save().await }).unwrap();
11485        let path = session.path.clone().unwrap();
11486        let content_before = std::fs::read_to_string(&path).unwrap();
11487
11488        let mut loaded =
11489            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11490        run_async(async { loaded.save().await }).unwrap();
11491
11492        let content_after = std::fs::read_to_string(&path).unwrap();
11493        assert_eq!(content_before, content_after);
11494    }
11495
11496    #[test]
11497    fn crash_corrupt_then_continue_operation() {
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        session.append_message(make_test_message("msg B"));
11504        run_async(async { session.save().await }).unwrap();
11505        let path = session.path.clone().unwrap();
11506
11507        // Corrupt last entry.
11508        let content = std::fs::read_to_string(&path).unwrap();
11509        let mut lines: Vec<String> = content.lines().map(String::from).collect();
11510        *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
11511        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11512
11513        let (mut recovered, diagnostics) = run_async(async {
11514            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11515        })
11516        .unwrap();
11517        assert_eq!(diagnostics.skipped_entries.len(), 1);
11518        assert_eq!(recovered.entries.len(), 1);
11519
11520        // Continue: add and save.
11521        recovered.path = Some(path.clone());
11522        recovered.session_dir = Some(temp_dir.path().to_path_buf());
11523        recovered.append_message(make_test_message("msg C"));
11524        run_async(async { recovered.save().await }).unwrap();
11525
11526        let reloaded =
11527            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11528        assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
11529    }
11530
11531    #[test]
11532    fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
11533        let temp_dir = tempfile::tempdir().unwrap();
11534        let mut session = Session::create();
11535        session.session_dir = Some(temp_dir.path().to_path_buf());
11536
11537        session.append_message(make_test_message("msg A"));
11538        run_async(async { session.save().await }).unwrap();
11539
11540        session.persisted_entry_count.store(999, Ordering::SeqCst);
11541        assert!(session.should_full_rewrite());
11542
11543        session.append_message(make_test_message("msg B"));
11544        run_async(async { session.save().await }).unwrap();
11545        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11546        assert_eq!(session.appends_since_checkpoint, 0);
11547    }
11548
11549    #[test]
11550    fn crash_persisted_count_unchanged_on_append_failure() {
11551        let temp_dir = tempfile::tempdir().unwrap();
11552        let mut session = Session::create();
11553        session.session_dir = Some(temp_dir.path().to_path_buf());
11554
11555        session.append_message(make_test_message("msg A"));
11556        run_async(async { session.save().await }).unwrap();
11557        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11558
11559        let path = session.path.clone().unwrap();
11560        session.append_message(make_test_message("msg B"));
11561
11562        #[cfg(unix)]
11563        {
11564            use std::os::unix::fs::PermissionsExt;
11565            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11566            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11567                // Some environments (for example root-run test runners) bypass chmod restrictions.
11568                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11569                return;
11570            }
11571        }
11572        #[cfg(not(unix))]
11573        {
11574            return;
11575        }
11576
11577        let result = run_async(async { session.save().await });
11578
11579        #[cfg(unix)]
11580        {
11581            use std::os::unix::fs::PermissionsExt;
11582            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11583        }
11584
11585        assert!(result.is_err());
11586        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11587
11588        run_async(async { session.save().await }).unwrap();
11589        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11590    }
11591
11592    #[test]
11593    fn crash_missing_session_file_forces_full_rewrite_recovery() {
11594        let temp_dir = tempfile::tempdir().unwrap();
11595        let mut session = Session::create();
11596        session.session_dir = Some(temp_dir.path().to_path_buf());
11597
11598        session.append_message(make_test_message("msg A"));
11599        run_async(async { session.save().await }).unwrap();
11600
11601        let path = session.path.clone().unwrap();
11602        std::fs::remove_file(&path).unwrap();
11603        assert!(session.should_full_rewrite());
11604
11605        session.append_message(make_test_message("msg B"));
11606        run_async(async { session.save().await }).unwrap();
11607
11608        let reloaded =
11609            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11610        assert_eq!(reloaded.entries.len(), 2);
11611        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11612        assert_eq!(session.appends_since_checkpoint, 0);
11613    }
11614
11615    #[test]
11616    fn crash_queue_backpressure_at_limit() {
11617        let mut queue = AutosaveQueue::with_limit(3);
11618
11619        queue.enqueue_mutation(AutosaveMutationKind::Message);
11620        queue.enqueue_mutation(AutosaveMutationKind::Message);
11621        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11622        assert_eq!(queue.pending_mutations, 3);
11623
11624        queue.enqueue_mutation(AutosaveMutationKind::Label);
11625        assert_eq!(queue.pending_mutations, 3, "capped");
11626        assert_eq!(queue.backpressure_events, 1);
11627    }
11628
11629    #[test]
11630    fn crash_flush_failure_with_intervening_mutations() {
11631        let mut queue = AutosaveQueue::with_limit(8);
11632
11633        for _ in 0..4 {
11634            queue.enqueue_mutation(AutosaveMutationKind::Message);
11635        }
11636        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11637
11638        queue.enqueue_mutation(AutosaveMutationKind::Message);
11639        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11640        assert_eq!(queue.pending_mutations, 2);
11641
11642        // restore_budget = 8 - 2 = 6, restored = min(4, 6) = 4
11643        queue.finish_flush(ticket, false);
11644        assert_eq!(queue.pending_mutations, 6);
11645        assert_eq!(queue.flush_failed, 1);
11646    }
11647
11648    #[test]
11649    fn crash_queue_metrics_snapshot() {
11650        let mut queue = AutosaveQueue::with_limit(5);
11651        queue.enqueue_mutation(AutosaveMutationKind::Message);
11652        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11653        queue.enqueue_mutation(AutosaveMutationKind::Label);
11654
11655        let metrics = queue.metrics();
11656        assert_eq!(metrics.pending_mutations, 3);
11657        assert_eq!(metrics.max_pending_mutations, 5);
11658        assert_eq!(metrics.coalesced_mutations, 2);
11659        assert_eq!(metrics.flush_started, 0);
11660        assert!(metrics.last_flush_duration_ms.is_none());
11661    }
11662
11663    #[test]
11664    fn crash_double_flush_is_noop() {
11665        let mut queue = AutosaveQueue::with_limit(10);
11666        queue.enqueue_mutation(AutosaveMutationKind::Message);
11667        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11668        queue.finish_flush(ticket, true);
11669
11670        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
11671    }
11672
11673    #[test]
11674    fn crash_finish_worker_result_propagates_panic_before_cancellation() {
11675        let handle = thread::spawn(|| -> () {
11676            test_fail!("jsonl worker panic");
11677        });
11678
11679        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11680            let _: Result<()> =
11681                finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled");
11682        }));
11683
11684        assert!(
11685            panic.is_err(),
11686            "worker panic should not be masked as cancellation"
11687        );
11688    }
11689
11690    #[test]
11691    fn crash_finish_worker_result_maps_nonpanic_cancellation_to_session_error() {
11692        let handle = thread::spawn(|| {});
11693
11694        let err = finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled")
11695            .expect_err("error");
11696
11697        assert!(
11698            err.to_string().contains("Save task cancelled"),
11699            "unexpected error: {err}"
11700        );
11701    }
11702
11703    #[test]
11704    fn crash_finish_worker_result_returns_success_payload() {
11705        let handle = thread::spawn(|| {});
11706
11707        let value =
11708            finish_worker_result::<usize, ()>(handle, Ok(Ok(7usize)), "task cancelled").unwrap();
11709
11710        assert_eq!(value, 7);
11711    }
11712
11713    #[test]
11714    fn crash_entries_survive_failed_full_rewrite() {
11715        // Entries are cloned during full rewrite to avoid losing them if the async future drops.
11716        // On error, the session must still contain the entries in memory.
11717        let temp_dir = tempfile::tempdir().unwrap();
11718        let mut session = Session::create();
11719        session.session_dir = Some(temp_dir.path().to_path_buf());
11720
11721        session.append_message(make_test_message("msg A"));
11722        run_async(async { session.save().await }).unwrap();
11723        let path = session.path.clone().unwrap();
11724
11725        session.set_model_header(Some("new-provider".to_string()), None, None);
11726        session.append_message(make_test_message("msg B"));
11727
11728        #[cfg(unix)]
11729        {
11730            use std::os::unix::fs::PermissionsExt;
11731            let parent = path.parent().unwrap();
11732            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
11733            if tempfile::NamedTempFile::new_in(parent).is_ok() {
11734                // Some environments (for example root-run test runners) bypass chmod restrictions.
11735                std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11736                return;
11737            }
11738        }
11739        #[cfg(not(unix))]
11740        {
11741            return;
11742        }
11743
11744        let result = run_async(async { session.save().await });
11745        assert!(result.is_err());
11746
11747        assert_eq!(session.entries.len(), 2, "entries restored");
11748        assert_eq!(session.entry_index.len(), 2);
11749        assert!(session.header_dirty);
11750
11751        #[cfg(unix)]
11752        {
11753            use std::os::unix::fs::PermissionsExt;
11754            let parent = path.parent().unwrap();
11755            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11756        }
11757
11758        run_async(async { session.save().await }).unwrap();
11759        assert!(!session.header_dirty);
11760    }
11761
11762    #[test]
11763    fn crash_metrics_accumulate_across_failure_recovery() {
11764        let temp_dir = tempfile::tempdir().unwrap();
11765        let mut session = Session::create();
11766        session.session_dir = Some(temp_dir.path().to_path_buf());
11767
11768        session.append_message(make_test_message("msg A"));
11769        run_async(async { session.save().await }).unwrap();
11770        let path = session.path.clone().unwrap();
11771
11772        let m = session.autosave_metrics();
11773        assert_eq!(m.flush_succeeded, 1);
11774        assert_eq!(m.flush_failed, 0);
11775
11776        #[cfg(unix)]
11777        {
11778            use std::os::unix::fs::PermissionsExt;
11779            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11780            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11781                // Some environments (for example root-run test runners) bypass chmod restrictions.
11782                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11783                return;
11784            }
11785        }
11786        #[cfg(not(unix))]
11787        {
11788            return;
11789        }
11790
11791        session.append_message(make_test_message("msg B"));
11792        let _ = run_async(async { session.save().await });
11793
11794        let m = session.autosave_metrics();
11795        assert_eq!(m.flush_failed, 1);
11796        assert!(m.pending_mutations > 0);
11797
11798        #[cfg(unix)]
11799        {
11800            use std::os::unix::fs::PermissionsExt;
11801            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11802        }
11803        run_async(async { session.save().await }).unwrap();
11804
11805        let m = session.autosave_metrics();
11806        assert_eq!(m.flush_succeeded, 2);
11807        assert_eq!(m.flush_failed, 1);
11808        assert_eq!(m.pending_mutations, 0);
11809        assert_eq!(m.flush_started, 3);
11810    }
11811
11812    #[test]
11813    fn crash_many_sequential_appends_accumulate() {
11814        let temp_dir = tempfile::tempdir().unwrap();
11815        let mut session = Session::create();
11816        session.session_dir = Some(temp_dir.path().to_path_buf());
11817
11818        session.append_message(make_test_message("initial"));
11819        run_async(async { session.save().await }).unwrap();
11820
11821        for i in 0..10 {
11822            session.append_message(make_test_message(&format!("append-{i}")));
11823            run_async(async { session.save().await }).unwrap();
11824        }
11825
11826        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
11827        assert_eq!(session.appends_since_checkpoint, 10);
11828
11829        let path = session.path.clone().unwrap();
11830        let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
11831        assert_eq!(line_count, 12, "1 header + 11 entries");
11832
11833        let loaded =
11834            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11835        assert_eq!(loaded.entries.len(), 11);
11836    }
11837
11838    #[test]
11839    fn crash_load_unsaved_entry_absent() {
11840        let temp_dir = tempfile::tempdir().unwrap();
11841        let mut session = Session::create();
11842        session.session_dir = Some(temp_dir.path().to_path_buf());
11843
11844        session.append_message(make_test_message("saved A"));
11845        session.append_message(make_test_message("saved B"));
11846        run_async(async { session.save().await }).unwrap();
11847        let path = session.path.clone().unwrap();
11848
11849        session.append_message(make_test_message("unsaved C"));
11850        assert_eq!(session.entries.len(), 3);
11851
11852        let loaded =
11853            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11854        assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
11855    }
11856
11857    #[test]
11858    fn test_clone_has_independent_persisted_entry_count() {
11859        let session = Session::create();
11860        // Set initial count
11861        session.persisted_entry_count.store(10, Ordering::SeqCst);
11862
11863        // Clone the session
11864        let clone = session.clone();
11865
11866        // Verify clone sees initial value
11867        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11868
11869        // Update original
11870        session.persisted_entry_count.store(20, Ordering::SeqCst);
11871
11872        // Verify clone is UNCHANGED (independent atomic)
11873        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11874
11875        // Update clone
11876        clone.persisted_entry_count.store(30, Ordering::SeqCst);
11877
11878        // Verify original is UNCHANGED
11879        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
11880    }
11881
11882    #[test]
11883    fn crash_append_retry_after_transient_failure() {
11884        let temp_dir = tempfile::tempdir().unwrap();
11885        let mut session = Session::create();
11886        session.session_dir = Some(temp_dir.path().to_path_buf());
11887
11888        session.append_message(make_test_message("msg A"));
11889        run_async(async { session.save().await }).unwrap();
11890        let path = session.path.clone().unwrap();
11891
11892        session.append_message(make_test_message("msg B"));
11893
11894        #[cfg(unix)]
11895        {
11896            use std::os::unix::fs::PermissionsExt;
11897            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11898            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11899                // Some environments (for example root-run test runners) bypass chmod restrictions.
11900                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11901                return;
11902            }
11903        }
11904        #[cfg(not(unix))]
11905        {
11906            return;
11907        }
11908
11909        let result = run_async(async { session.save().await });
11910        assert!(result.is_err());
11911        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11912
11913        #[cfg(unix)]
11914        {
11915            use std::os::unix::fs::PermissionsExt;
11916            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11917        }
11918
11919        run_async(async { session.save().await }).unwrap();
11920        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11921
11922        let loaded =
11923            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11924        assert_eq!(loaded.entries.len(), 2);
11925    }
11926
11927    #[test]
11928    fn crash_durability_mode_parsing() {
11929        assert_eq!(
11930            AutosaveDurabilityMode::parse("strict"),
11931            Some(AutosaveDurabilityMode::Strict)
11932        );
11933        assert_eq!(
11934            AutosaveDurabilityMode::parse("BALANCED"),
11935            Some(AutosaveDurabilityMode::Balanced)
11936        );
11937        assert_eq!(
11938            AutosaveDurabilityMode::parse("  Throughput  "),
11939            Some(AutosaveDurabilityMode::Throughput)
11940        );
11941        assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
11942        assert_eq!(AutosaveDurabilityMode::parse(""), None);
11943    }
11944
11945    #[test]
11946    fn crash_durability_resolve_precedence() {
11947        assert_eq!(
11948            resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
11949            AutosaveDurabilityMode::Strict
11950        );
11951        assert_eq!(
11952            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
11953            AutosaveDurabilityMode::Throughput
11954        );
11955        assert_eq!(
11956            resolve_autosave_durability_mode(None, None, Some("strict")),
11957            AutosaveDurabilityMode::Strict
11958        );
11959        assert_eq!(
11960            resolve_autosave_durability_mode(None, None, None),
11961            AutosaveDurabilityMode::Balanced
11962        );
11963    }
11964
11965    // =========================================================================
11966    // bd-3ar8v.2.9: Comprehensive autosave queue and durability state machine
11967    // =========================================================================
11968
11969    // --- Queue boundary: minimum capacity (limit=1) ---
11970
11971    #[test]
11972    fn autosave_queue_limit_one_accepts_single_mutation() {
11973        let mut queue = AutosaveQueue::with_limit(1);
11974        queue.enqueue_mutation(AutosaveMutationKind::Message);
11975        assert_eq!(queue.pending_mutations, 1);
11976        assert_eq!(queue.coalesced_mutations, 0);
11977        assert_eq!(queue.backpressure_events, 0);
11978    }
11979
11980    #[test]
11981    fn autosave_queue_limit_one_backpressures_second_mutation() {
11982        let mut queue = AutosaveQueue::with_limit(1);
11983        queue.enqueue_mutation(AutosaveMutationKind::Message);
11984        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11985        assert_eq!(queue.pending_mutations, 1, "capped at 1");
11986        assert_eq!(queue.backpressure_events, 1);
11987        assert_eq!(queue.coalesced_mutations, 1);
11988    }
11989
11990    #[test]
11991    fn autosave_queue_limit_one_flush_and_refill() {
11992        let mut queue = AutosaveQueue::with_limit(1);
11993        queue.enqueue_mutation(AutosaveMutationKind::Message);
11994
11995        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
11996        assert_eq!(queue.pending_mutations, 0);
11997        assert_eq!(ticket.batch_size, 1);
11998        queue.finish_flush(ticket, true);
11999
12000        // Refill works after flush.
12001        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12002        assert_eq!(queue.pending_mutations, 1);
12003        assert_eq!(queue.flush_succeeded, 1);
12004    }
12005
12006    // --- Queue boundary: with_limit enforces minimum of 1 ---
12007
12008    #[test]
12009    fn autosave_queue_with_limit_zero_clamps_to_one() {
12010        let queue = AutosaveQueue::with_limit(0);
12011        assert_eq!(queue.max_pending_mutations, 1);
12012    }
12013
12014    // --- Empty queue operations ---
12015
12016    #[test]
12017    fn autosave_queue_begin_flush_on_empty_returns_none() {
12018        let mut queue = AutosaveQueue::with_limit(10);
12019        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
12020        assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
12021    }
12022
12023    #[test]
12024    fn autosave_queue_metrics_on_fresh_queue() {
12025        let queue = AutosaveQueue::with_limit(256);
12026        let m = queue.metrics();
12027        assert_eq!(m.pending_mutations, 0);
12028        assert_eq!(m.max_pending_mutations, 256);
12029        assert_eq!(m.coalesced_mutations, 0);
12030        assert_eq!(m.backpressure_events, 0);
12031        assert_eq!(m.flush_started, 0);
12032        assert_eq!(m.flush_succeeded, 0);
12033        assert_eq!(m.flush_failed, 0);
12034        assert_eq!(m.last_flush_batch_size, 0);
12035        assert!(m.last_flush_duration_ms.is_none());
12036        assert!(m.last_flush_trigger.is_none());
12037    }
12038
12039    // --- All three mutation kinds ---
12040
12041    #[test]
12042    fn autosave_queue_all_mutation_kinds() {
12043        let mut queue = AutosaveQueue::with_limit(10);
12044        queue.enqueue_mutation(AutosaveMutationKind::Message);
12045        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12046        queue.enqueue_mutation(AutosaveMutationKind::Label);
12047        assert_eq!(queue.pending_mutations, 3);
12048        // First mutation has no coalescing; subsequent two do.
12049        assert_eq!(queue.coalesced_mutations, 2);
12050    }
12051
12052    // --- Multiple consecutive flushes with mixed outcomes ---
12053
12054    #[test]
12055    fn autosave_queue_consecutive_success_flushes() {
12056        let mut queue = AutosaveQueue::with_limit(5);
12057
12058        for round in 1..=3_u64 {
12059            queue.enqueue_mutation(AutosaveMutationKind::Message);
12060            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12061            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12062            queue.finish_flush(ticket, true);
12063            assert_eq!(queue.pending_mutations, 0);
12064            assert_eq!(queue.flush_succeeded, round);
12065            assert_eq!(queue.flush_started, round);
12066            assert_eq!(queue.last_flush_batch_size, 2);
12067        }
12068        assert_eq!(queue.flush_failed, 0);
12069    }
12070
12071    #[test]
12072    fn autosave_queue_alternating_success_failure() {
12073        let mut queue = AutosaveQueue::with_limit(10);
12074
12075        // Round 1: success
12076        queue.enqueue_mutation(AutosaveMutationKind::Message);
12077        let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12078        queue.finish_flush(t1, true);
12079        assert_eq!(queue.flush_succeeded, 1);
12080        assert_eq!(queue.flush_failed, 0);
12081        assert_eq!(queue.pending_mutations, 0);
12082
12083        // Round 2: failure (mutations restored)
12084        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12085        queue.enqueue_mutation(AutosaveMutationKind::Label);
12086        let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12087        queue.finish_flush(t2, false);
12088        assert_eq!(queue.flush_succeeded, 1);
12089        assert_eq!(queue.flush_failed, 1);
12090        assert_eq!(queue.pending_mutations, 2, "restored from failure");
12091
12092        // Round 3: success (clears the restored mutations)
12093        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12094        assert_eq!(t3.batch_size, 2);
12095        queue.finish_flush(t3, true);
12096        assert_eq!(queue.flush_succeeded, 2);
12097        assert_eq!(queue.flush_failed, 1);
12098        assert_eq!(queue.pending_mutations, 0);
12099        assert_eq!(queue.flush_started, 3);
12100    }
12101
12102    // --- Failure when queue is completely full (zero capacity to restore) ---
12103
12104    #[test]
12105    fn autosave_queue_failure_drops_all_when_full() {
12106        let mut queue = AutosaveQueue::with_limit(3);
12107
12108        // Fill to capacity and flush.
12109        for _ in 0..3 {
12110            queue.enqueue_mutation(AutosaveMutationKind::Message);
12111        }
12112        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12113        assert_eq!(ticket.batch_size, 3);
12114        assert_eq!(queue.pending_mutations, 0);
12115
12116        // Fill queue completely while flush is in flight.
12117        for _ in 0..3 {
12118            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12119        }
12120        assert_eq!(queue.pending_mutations, 3);
12121
12122        // Flush fails — no capacity to restore, all 3 batch mutations are dropped.
12123        let bp_before = queue.backpressure_events;
12124        queue.finish_flush(ticket, false);
12125        assert_eq!(queue.pending_mutations, 3, "capped at max");
12126        assert_eq!(queue.flush_failed, 1);
12127        assert_eq!(
12128            queue.backpressure_events,
12129            bp_before + 3,
12130            "dropped mutations counted as backpressure"
12131        );
12132    }
12133
12134    // --- Flush trigger tracking ---
12135
12136    #[test]
12137    fn autosave_queue_tracks_trigger_across_flushes() {
12138        let mut queue = AutosaveQueue::with_limit(10);
12139
12140        // Manual trigger.
12141        queue.enqueue_mutation(AutosaveMutationKind::Message);
12142        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12143        assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
12144        queue.finish_flush(t1, true);
12145        assert_eq!(
12146            queue.metrics().last_flush_trigger,
12147            Some(AutosaveFlushTrigger::Manual)
12148        );
12149
12150        // Periodic trigger.
12151        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12152        let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12153        queue.finish_flush(t2, true);
12154        assert_eq!(
12155            queue.metrics().last_flush_trigger,
12156            Some(AutosaveFlushTrigger::Periodic)
12157        );
12158
12159        // Shutdown trigger.
12160        queue.enqueue_mutation(AutosaveMutationKind::Label);
12161        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12162        queue.finish_flush(t3, true);
12163        assert_eq!(
12164            queue.metrics().last_flush_trigger,
12165            Some(AutosaveFlushTrigger::Shutdown)
12166        );
12167    }
12168
12169    // --- Flush records duration ---
12170
12171    #[test]
12172    fn autosave_queue_flush_records_duration() {
12173        let mut queue = AutosaveQueue::with_limit(10);
12174        queue.enqueue_mutation(AutosaveMutationKind::Message);
12175        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12176        queue.finish_flush(ticket, true);
12177        // Duration should be recorded (>= 0ms).
12178        assert!(queue.metrics().last_flush_duration_ms.is_some());
12179    }
12180
12181    // --- Rapid enqueue-flush cycles ---
12182
12183    #[test]
12184    fn autosave_queue_rapid_single_mutation_flushes() {
12185        let mut queue = AutosaveQueue::with_limit(10);
12186        let rounds = 20;
12187
12188        for _ in 0..rounds {
12189            queue.enqueue_mutation(AutosaveMutationKind::Message);
12190            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12191            queue.finish_flush(ticket, true);
12192        }
12193
12194        let m = queue.metrics();
12195        assert_eq!(m.flush_started, rounds);
12196        assert_eq!(m.flush_succeeded, rounds);
12197        assert_eq!(m.flush_failed, 0);
12198        assert_eq!(m.pending_mutations, 0);
12199        assert_eq!(m.last_flush_batch_size, 1);
12200    }
12201
12202    // --- Saturating counter behavior under heavy load ---
12203
12204    #[test]
12205    fn autosave_queue_many_backpressure_events_accumulate() {
12206        let mut queue = AutosaveQueue::with_limit(1);
12207        let excess: u64 = 100;
12208
12209        // First enqueue goes into the queue; rest are backpressure.
12210        for _ in 0..=excess {
12211            queue.enqueue_mutation(AutosaveMutationKind::Message);
12212        }
12213        assert_eq!(queue.pending_mutations, 1);
12214        assert_eq!(queue.backpressure_events, excess);
12215    }
12216
12217    // --- Durability mode: as_str roundtrip ---
12218
12219    #[test]
12220    fn autosave_durability_mode_as_str_roundtrip() {
12221        for mode in [
12222            AutosaveDurabilityMode::Strict,
12223            AutosaveDurabilityMode::Balanced,
12224            AutosaveDurabilityMode::Throughput,
12225        ] {
12226            let s = mode.as_str();
12227            let parsed = AutosaveDurabilityMode::parse(s);
12228            assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
12229        }
12230    }
12231
12232    // --- Durability mode: should_flush/best_effort truth table ---
12233
12234    #[test]
12235    fn autosave_durability_mode_shutdown_behavior_truth_table() {
12236        assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
12237        assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
12238
12239        assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
12240        assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
12241
12242        assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
12243        assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
12244    }
12245
12246    // --- Durability mode: case-insensitive parsing ---
12247
12248    #[test]
12249    fn autosave_durability_mode_parse_case_insensitive() {
12250        assert_eq!(
12251            AutosaveDurabilityMode::parse("STRICT"),
12252            Some(AutosaveDurabilityMode::Strict)
12253        );
12254        assert_eq!(
12255            AutosaveDurabilityMode::parse("Balanced"),
12256            Some(AutosaveDurabilityMode::Balanced)
12257        );
12258        assert_eq!(
12259            AutosaveDurabilityMode::parse("tHrOuGhPuT"),
12260            Some(AutosaveDurabilityMode::Throughput)
12261        );
12262    }
12263
12264    // --- Durability mode: whitespace trimming ---
12265
12266    #[test]
12267    fn autosave_durability_mode_parse_trims_whitespace() {
12268        assert_eq!(
12269            AutosaveDurabilityMode::parse("  strict  "),
12270            Some(AutosaveDurabilityMode::Strict)
12271        );
12272        assert_eq!(
12273            AutosaveDurabilityMode::parse("\tbalanced\n"),
12274            Some(AutosaveDurabilityMode::Balanced)
12275        );
12276    }
12277
12278    // --- Session-level: save on empty queue is no-op ---
12279
12280    #[test]
12281    fn autosave_session_save_on_empty_queue_is_noop() {
12282        let temp_dir = tempfile::tempdir().unwrap();
12283        let mut session = Session::create();
12284        session.session_dir = Some(temp_dir.path().to_path_buf());
12285
12286        // Save without any mutations — should succeed and not change metrics.
12287        let m_before = session.autosave_metrics();
12288        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
12289        let m_after = session.autosave_metrics();
12290
12291        assert_eq!(m_before.flush_started, m_after.flush_started);
12292        assert_eq!(m_after.pending_mutations, 0);
12293    }
12294
12295    // --- Session-level: mode change mid-session ---
12296
12297    #[test]
12298    fn autosave_session_mode_change_mid_session() {
12299        let mut session = Session::create();
12300        assert_eq!(
12301            session.autosave_durability_mode(),
12302            AutosaveDurabilityMode::Balanced,
12303            "default is balanced"
12304        );
12305
12306        session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
12307        assert_eq!(
12308            session.autosave_durability_mode(),
12309            AutosaveDurabilityMode::Strict
12310        );
12311
12312        session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
12313        assert_eq!(
12314            session.autosave_durability_mode(),
12315            AutosaveDurabilityMode::Throughput
12316        );
12317    }
12318
12319    // --- Session-level: all mutation types enqueue correctly ---
12320
12321    #[test]
12322    fn autosave_session_all_mutation_types_enqueue() {
12323        let mut session = Session::create();
12324
12325        let first_entry_id = session.append_message(make_test_message("msg"));
12326        assert_eq!(session.autosave_metrics().pending_mutations, 1);
12327
12328        session.append_model_change("prov".to_string(), "model".to_string());
12329        assert_eq!(session.autosave_metrics().pending_mutations, 2);
12330
12331        session.append_thinking_level_change("high".to_string());
12332        assert_eq!(session.autosave_metrics().pending_mutations, 3);
12333
12334        session.append_session_info(Some("test-session".to_string()));
12335        assert_eq!(session.autosave_metrics().pending_mutations, 4);
12336
12337        session.append_custom_entry("custom".to_string(), None);
12338        assert_eq!(session.autosave_metrics().pending_mutations, 5);
12339
12340        // Label mutation (needs existing entry to target).
12341        session.add_label(&first_entry_id, Some("test-label".to_string()));
12342        assert_eq!(session.autosave_metrics().pending_mutations, 6);
12343    }
12344
12345    // --- Session-level: flush then verify metrics ---
12346
12347    #[test]
12348    fn autosave_session_manual_save_resets_pending() {
12349        let temp_dir = tempfile::tempdir().unwrap();
12350        let mut session = Session::create();
12351        session.session_dir = Some(temp_dir.path().to_path_buf());
12352
12353        session.append_message(make_test_message("a"));
12354        session.append_message(make_test_message("b"));
12355        session.append_message(make_test_message("c"));
12356        assert_eq!(session.autosave_metrics().pending_mutations, 3);
12357
12358        run_async(async { session.save().await }).unwrap();
12359
12360        let m = session.autosave_metrics();
12361        assert_eq!(m.pending_mutations, 0);
12362        assert_eq!(m.flush_succeeded, 1);
12363        assert_eq!(m.last_flush_batch_size, 3);
12364        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
12365    }
12366
12367    // --- Session-level: periodic flush trigger tracking ---
12368
12369    #[test]
12370    fn autosave_session_periodic_flush_tracks_trigger() {
12371        let temp_dir = tempfile::tempdir().unwrap();
12372        let mut session = Session::create();
12373        session.session_dir = Some(temp_dir.path().to_path_buf());
12374
12375        session.append_message(make_test_message("periodic msg"));
12376        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
12377
12378        let m = session.autosave_metrics();
12379        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
12380        assert_eq!(m.flush_succeeded, 1);
12381    }
12382
12383    // --- Session-level: shutdown flush with balanced mode success ---
12384
12385    #[test]
12386    fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
12387        let temp_dir = tempfile::tempdir().unwrap();
12388        let mut session = Session::create();
12389        session.session_dir = Some(temp_dir.path().to_path_buf());
12390        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
12391
12392        session.append_message(make_test_message("balanced ok"));
12393        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12394
12395        let m = session.autosave_metrics();
12396        assert_eq!(m.flush_succeeded, 1);
12397        assert_eq!(m.pending_mutations, 0);
12398        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
12399    }
12400
12401    // --- Queue: partial restoration on failure with various fill levels ---
12402
12403    #[test]
12404    fn autosave_queue_failure_partial_restoration() {
12405        let mut queue = AutosaveQueue::with_limit(5);
12406
12407        // Fill to 4 and flush (batch=4).
12408        for _ in 0..4 {
12409            queue.enqueue_mutation(AutosaveMutationKind::Message);
12410        }
12411        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12412        assert_eq!(ticket.batch_size, 4);
12413
12414        // Add 2 while flush is in flight.
12415        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12416        queue.enqueue_mutation(AutosaveMutationKind::Label);
12417        assert_eq!(queue.pending_mutations, 2);
12418
12419        // Fail: available_capacity = 5 - 2 = 3, restored = min(4,3) = 3, dropped = 1.
12420        let bp_before = queue.backpressure_events;
12421        let coal_before = queue.coalesced_mutations;
12422        queue.finish_flush(ticket, false);
12423        assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
12424        assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
12425        assert_eq!(
12426            queue.coalesced_mutations,
12427            coal_before + 1,
12428            "1 dropped coalesced"
12429        );
12430    }
12431
12432    // --- Queue: success flush does not restore ---
12433
12434    #[test]
12435    fn autosave_queue_success_does_not_restore_pending() {
12436        let mut queue = AutosaveQueue::with_limit(10);
12437
12438        queue.enqueue_mutation(AutosaveMutationKind::Message);
12439        queue.enqueue_mutation(AutosaveMutationKind::Message);
12440        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12441
12442        // Add 1 mutation while flush is in flight.
12443        queue.enqueue_mutation(AutosaveMutationKind::Label);
12444        assert_eq!(queue.pending_mutations, 1);
12445
12446        // Success: only the in-flight mutation remains.
12447        queue.finish_flush(ticket, true);
12448        assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
12449        assert_eq!(queue.flush_succeeded, 1);
12450    }
12451
12452    // --- Queue: large batch size tracking ---
12453
12454    #[test]
12455    fn autosave_queue_large_batch_tracking() {
12456        let mut queue = AutosaveQueue::with_limit(500);
12457
12458        for _ in 0..200 {
12459            queue.enqueue_mutation(AutosaveMutationKind::Message);
12460        }
12461
12462        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12463        assert_eq!(ticket.batch_size, 200);
12464        queue.finish_flush(ticket, true);
12465
12466        let m = queue.metrics();
12467        assert_eq!(m.last_flush_batch_size, 200);
12468        assert_eq!(m.flush_succeeded, 1);
12469        assert_eq!(m.pending_mutations, 0);
12470    }
12471
12472    // --- Durability resolve: all invalid falls through to default ---
12473
12474    #[test]
12475    fn autosave_resolve_all_invalid_returns_balanced() {
12476        assert_eq!(
12477            resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
12478            AutosaveDurabilityMode::Balanced
12479        );
12480    }
12481
12482    // --- Session-level: metrics accumulate across many save/flush cycles ---
12483
12484    #[test]
12485    fn autosave_session_metrics_accumulate_over_many_cycles() {
12486        let temp_dir = tempfile::tempdir().unwrap();
12487        let mut session = Session::create();
12488        session.session_dir = Some(temp_dir.path().to_path_buf());
12489
12490        let cycles: u64 = 10;
12491        for i in 0..cycles {
12492            session.append_message(make_test_message(&format!("cycle-{i}")));
12493            run_async(async { session.save().await }).unwrap();
12494        }
12495
12496        let m = session.autosave_metrics();
12497        assert_eq!(m.flush_started, cycles);
12498        assert_eq!(m.flush_succeeded, cycles);
12499        assert_eq!(m.flush_failed, 0);
12500        assert_eq!(m.pending_mutations, 0);
12501        assert_eq!(m.last_flush_batch_size, 1);
12502    }
12503
12504    // --- Queue: coalesced count is cumulative (not per-flush) ---
12505
12506    #[test]
12507    fn autosave_queue_coalesced_is_cumulative() {
12508        let mut queue = AutosaveQueue::with_limit(10);
12509
12510        // Batch 1: 3 mutations => 2 coalesced.
12511        queue.enqueue_mutation(AutosaveMutationKind::Message);
12512        queue.enqueue_mutation(AutosaveMutationKind::Message);
12513        queue.enqueue_mutation(AutosaveMutationKind::Message);
12514        assert_eq!(queue.coalesced_mutations, 2);
12515
12516        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12517        queue.finish_flush(t1, true);
12518
12519        // Batch 2: 2 mutations => 1 more coalesced (total 3).
12520        queue.enqueue_mutation(AutosaveMutationKind::Label);
12521        queue.enqueue_mutation(AutosaveMutationKind::Label);
12522        assert_eq!(queue.coalesced_mutations, 3);
12523    }
12524
12525    // --- Session-level: autosave_queue_limit changes batch size behavior ---
12526
12527    #[test]
12528    fn autosave_session_respects_queue_limit() {
12529        let temp_dir = tempfile::tempdir().unwrap();
12530        let mut session = Session::create();
12531        session.session_dir = Some(temp_dir.path().to_path_buf());
12532        session.set_autosave_queue_limit_for_test(3);
12533
12534        for i in 0..10 {
12535            session.append_message(make_test_message(&format!("lim-{i}")));
12536        }
12537
12538        let m = session.autosave_metrics();
12539        assert_eq!(m.pending_mutations, 3);
12540        assert_eq!(m.max_pending_mutations, 3);
12541        assert_eq!(m.backpressure_events, 7);
12542
12543        // Flush should only capture 3 (the capped count).
12544        run_async(async { session.save().await }).unwrap();
12545        let m = session.autosave_metrics();
12546        assert_eq!(m.last_flush_batch_size, 3);
12547        assert_eq!(m.pending_mutations, 0);
12548    }
12549
12550    // --- Session-level: throughput mode shutdown with successful prior manual save ---
12551
12552    #[test]
12553    fn autosave_session_throughput_shutdown_skips_after_manual_save() {
12554        let temp_dir = tempfile::tempdir().unwrap();
12555        let mut session = Session::create();
12556        session.session_dir = Some(temp_dir.path().to_path_buf());
12557        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
12558
12559        session.append_message(make_test_message("saved"));
12560        run_async(async { session.save().await }).unwrap();
12561        assert_eq!(session.autosave_metrics().flush_succeeded, 1);
12562
12563        // Add more mutations but don't save.
12564        session.append_message(make_test_message("unsaved"));
12565        assert_eq!(session.autosave_metrics().pending_mutations, 1);
12566
12567        // Shutdown skips flush in throughput mode.
12568        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12569        assert_eq!(
12570            session.autosave_metrics().pending_mutations,
12571            1,
12572            "unsaved mutation remains"
12573        );
12574        assert_eq!(
12575            session.autosave_metrics().flush_succeeded,
12576            1,
12577            "no new flush"
12578        );
12579    }
12580
12581    // --- Queue: begin_flush atomically clears pending ---
12582
12583    #[test]
12584    fn autosave_queue_begin_flush_is_atomic_clear() {
12585        let mut queue = AutosaveQueue::with_limit(10);
12586
12587        queue.enqueue_mutation(AutosaveMutationKind::Message);
12588        queue.enqueue_mutation(AutosaveMutationKind::Message);
12589        queue.enqueue_mutation(AutosaveMutationKind::Message);
12590        assert_eq!(queue.pending_mutations, 3);
12591
12592        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12593
12594        // Pending is immediately 0, even before finish_flush.
12595        assert_eq!(queue.pending_mutations, 0);
12596        assert_eq!(ticket.batch_size, 3);
12597
12598        // New mutations start fresh.
12599        queue.enqueue_mutation(AutosaveMutationKind::Label);
12600        assert_eq!(queue.pending_mutations, 1);
12601
12602        queue.finish_flush(ticket, true);
12603        assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
12604    }
12605
12606    // --- Queue: multiple failures accumulate flush_failed ---
12607
12608    #[test]
12609    fn autosave_queue_multiple_failures_accumulate() {
12610        let mut queue = AutosaveQueue::with_limit(10);
12611
12612        // Each round: enqueue 1 new + restored from prior failure.
12613        // Round 1: enqueue → pending=1, flush fails → restore 1 → pending=1
12614        // Round 2: enqueue → pending=2, flush fails → restore 2 → pending=2
12615        // Round N: pending grows by 1 each round because failures restore.
12616        for round in 1..=5_u64 {
12617            queue.enqueue_mutation(AutosaveMutationKind::Message);
12618            #[allow(clippy::cast_possible_truncation)]
12619            let expected_batch = round as usize;
12620            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12621            assert_eq!(ticket.batch_size, expected_batch);
12622            queue.finish_flush(ticket, false);
12623            assert_eq!(queue.flush_failed, round);
12624            assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
12625        }
12626        assert_eq!(queue.flush_succeeded, 0);
12627        assert_eq!(queue.flush_started, 5);
12628    }
12629
12630    // --- ExportSnapshot and non-blocking export ---
12631
12632    #[test]
12633    fn export_snapshot_captures_header_and_entries() {
12634        let mut session = Session::create();
12635        session.append_message(make_test_message("hello world"));
12636        session.append_message(make_test_message("second message"));
12637
12638        let snapshot = session.export_snapshot();
12639        assert_eq!(snapshot.header.id, session.header.id);
12640        assert_eq!(snapshot.header.timestamp, session.header.timestamp);
12641        assert_eq!(snapshot.header.cwd, session.header.cwd);
12642        assert_eq!(snapshot.entries.len(), session.entries.len());
12643        assert_eq!(snapshot.path, session.path);
12644    }
12645
12646    #[test]
12647    fn export_snapshot_does_not_include_internal_caches() {
12648        let mut session = Session::create();
12649        for i in 0..10 {
12650            session.append_message(make_test_message(&format!("msg {i}")));
12651        }
12652        // The snapshot should be lighter than a full Session clone because
12653        // it skips autosave_queue, entry_index, entry_ids, and other caches.
12654        let snapshot = session.export_snapshot();
12655        assert_eq!(snapshot.entries.len(), 10);
12656        // Verify the snapshot is a distinct copy (not sharing references).
12657        assert_eq!(snapshot.header.id, session.header.id);
12658    }
12659
12660    #[test]
12661    fn export_snapshot_html_matches_session_html() {
12662        let mut session = Session::create();
12663        session.append_message(make_test_message("hello"));
12664        session.append_message(make_test_message("world"));
12665
12666        let session_html = session.to_html();
12667        let snapshot_html = session.export_snapshot().to_html();
12668        assert_eq!(session_html, snapshot_html);
12669    }
12670
12671    #[test]
12672    fn export_snapshot_empty_session() {
12673        let session = Session::create();
12674        let snapshot = session.export_snapshot();
12675        assert!(snapshot.entries.is_empty());
12676        let html = snapshot.to_html();
12677        assert!(html.contains("Pi Session"));
12678        assert!(html.contains("</html>"));
12679    }
12680
12681    #[test]
12682    fn render_session_html_contains_header_info() {
12683        let mut session = Session::create();
12684        session.header.id = "test-session-id-xyz".to_string();
12685        session.header.cwd = "/test/cwd/path".to_string();
12686
12687        let html = render_session_html(&session.header, &session.entries);
12688        assert!(html.contains("test-session-id-xyz"));
12689        assert!(html.contains("/test/cwd/path"));
12690    }
12691
12692    #[test]
12693    fn render_session_html_renders_all_entry_types() {
12694        let mut session = Session::create();
12695
12696        // Message entry.
12697        session.append_message(make_test_message("user text here"));
12698
12699        // Model change entry.
12700        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
12701
12702        // Thinking level change entry.
12703        session.entries.push(SessionEntry::ThinkingLevelChange(
12704            ThinkingLevelChangeEntry {
12705                base: EntryBase::new(None, "tlc1".to_string()),
12706                thinking_level: "high".to_string(),
12707            },
12708        ));
12709
12710        let html = render_session_html(&session.header, &session.entries);
12711        assert!(html.contains("user text here"));
12712        assert!(html.contains("anthropic"));
12713        assert!(html.contains("claude-sonnet-4-5"));
12714        assert!(html.contains("high"));
12715    }
12716
12717    #[test]
12718    fn export_snapshot_with_path() {
12719        let mut session = Session::create();
12720        session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
12721        session.append_message(make_test_message("msg"));
12722
12723        let snapshot = session.export_snapshot();
12724        assert_eq!(
12725            snapshot.path.as_deref(),
12726            Some(Path::new("/tmp/my-session.jsonl"))
12727        );
12728    }
12729
12730    #[test]
12731    fn fork_plan_snapshot_consistency() {
12732        let mut session = Session::create();
12733        let msg1 = make_test_message("first message");
12734        session.append_message(msg1);
12735        let msg1_id = session.entries[0].base_id().unwrap().clone();
12736
12737        let msg2 = make_test_message("second message");
12738        session.append_message(msg2);
12739        let msg2_id = session.entries[1].base_id().unwrap().clone();
12740
12741        // Plan fork from the second message.
12742        let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
12743
12744        // Fork plan entries should include the path up to the parent.
12745        assert_eq!(plan.leaf_id, Some(msg1_id));
12746        // The plan captures a snapshot of entries — modifying session shouldn't affect plan.
12747        let plan_entry_count = plan.entries.len();
12748        session.append_message(make_test_message("third message"));
12749        assert_eq!(plan.entries.len(), plan_entry_count);
12750    }
12751}