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 tracing::warn;
8use crate::cli::Cli;
9use crate::config::Config;
10use crate::error::{Error, Result};
11use crate::extensions::ExtensionSession;
12use crate::model::{
13    AssistantMessage, ContentBlock, Message, TextContent, ToolResultMessage, UserContent,
14    UserMessage,
15};
16use crate::provider_metadata::{canonical_provider_id, provider_ids_match};
17use crate::session_index::{
18    SessionIndex, enqueue_session_index_snapshot_update, 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};
39
40/// Current session file format version.
41pub const SESSION_VERSION: u8 = 3;
42const MAX_JSONL_LINE_BYTES: usize = 100 * 1024 * 1024;
43const V2_CHAIN_HASH_GENESIS: &str =
44    "0000000000000000000000000000000000000000000000000000000000000000";
45const ROOT_LEAF_OVERRIDE_SENTINEL: &str = "";
46
47fn finish_worker_result<T, E>(
48    handle: thread::JoinHandle<()>,
49    recv_result: std::result::Result<Result<T>, E>,
50    cancelled_message: &'static str,
51) -> Result<T> {
52    if let Err(panic_payload) = handle.join() {
53        std::panic::resume_unwind(panic_payload);
54    }
55    recv_result.map_err(|_| crate::Error::session(cancelled_message))?
56}
57
58fn read_capped_utf8_line_with_limit<R: std::io::BufRead>(
59    reader: &mut R,
60    max_bytes: usize,
61) -> std::io::Result<Option<String>> {
62    use std::io::BufRead;
63
64    let limit = u64::try_from(max_bytes)
65        .unwrap_or(u64::MAX.saturating_sub(2))
66        .saturating_add(2);
67    let mut bytes = Vec::new();
68    let bytes_read = reader.take(limit).read_until(b'\n', &mut bytes)?;
69    if bytes_read == 0 {
70        return Ok(None);
71    }
72
73    let content_len = bytes.strip_suffix(b"\n").map_or(bytes.len(), <[u8]>::len);
74    if content_len > max_bytes {
75        if !bytes.ends_with(b"\n") {
76            let mut discard = Vec::new();
77            loop {
78                discard.clear();
79                let discarded = reader.read_until(b'\n', &mut discard)?;
80                if discarded == 0 || discard.ends_with(b"\n") {
81                    break;
82                }
83            }
84        }
85        return Err(std::io::Error::new(
86            std::io::ErrorKind::InvalidData,
87            format!("JSONL line exceeds {max_bytes} bytes"),
88        ));
89    }
90
91    String::from_utf8(bytes)
92        .map(Some)
93        .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
94}
95
96fn read_capped_utf8_line<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<Option<String>> {
97    read_capped_utf8_line_with_limit(reader, MAX_JSONL_LINE_BYTES)
98}
99
100#[cfg(unix)]
101fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
102    let Some(parent) = path.parent() else {
103        return Ok(());
104    };
105    std::fs::File::open(parent)?.sync_all()
106}
107
108#[cfg(not(unix))]
109fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
110    Ok(())
111}
112
113fn save_jsonl_full_rewrite_blocking(
114    path: &Path,
115    sessions_root: &Path,
116    header: &SessionHeader,
117    entries: &[SessionEntry],
118    persisted_entry_count: usize,
119    header_dirty: bool,
120) -> Result<(SessionHeader, Vec<SessionEntry>)> {
121    let _lock = lock_session_persistence(path)?;
122    let (header_to_write, entries_to_write) =
123        prepare_jsonl_full_rewrite(path, header, entries, persisted_entry_count, header_dirty)?;
124    let parent = path.parent().unwrap_or_else(|| Path::new("."));
125    let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
126    {
127        let mut writer = std::io::BufWriter::with_capacity(1 << 20, temp_file.as_file());
128        serde_json::to_writer(&mut writer, &header_to_write)?;
129        writer.write_all(b"\n")?;
130        for entry in &entries_to_write {
131            serde_json::to_writer(&mut writer, entry)?;
132            writer.write_all(b"\n")?;
133        }
134        writer.flush()?;
135    }
136    temp_file
137        .as_file_mut()
138        .sync_all()
139        .map_err(|e| crate::Error::Io(Box::new(e)))?;
140    temp_file
141        .persist(path)
142        .map_err(|e| crate::Error::Io(Box::new(e.error)))?;
143    sync_parent_dir(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
144    let mut entries_for_stats = entries_to_write.clone();
145    let finalized = finalize_loaded_entries(&mut entries_for_stats);
146    let message_count = finalized.message_count;
147    let session_name = finalized.name;
148    enqueue_session_index_snapshot_update(
149        sessions_root,
150        path,
151        &header_to_write,
152        message_count,
153        session_name,
154    );
155    Ok((header_to_write, entries_to_write))
156}
157
158fn append_jsonl_entries_blocking(
159    path: &Path,
160    sessions_root: &Path,
161    header: &SessionHeader,
162    serialized_entries: &[u8],
163    message_count: u64,
164    session_name: Option<String>,
165) -> Result<()> {
166    let _lock = lock_session_persistence(path)?;
167    let mut file = std::fs::OpenOptions::new()
168        .append(true)
169        .open(path)
170        .map_err(|e| crate::Error::Io(Box::new(e)))?;
171    file.write_all(serialized_entries)?;
172    file.sync_all().map_err(|e| crate::Error::Io(Box::new(e)))?;
173
174    enqueue_session_index_snapshot_update(sessions_root, path, header, message_count, session_name);
175    Ok(())
176}
177
178fn session_persistence_lock_path(path: &Path) -> PathBuf {
179    let mut lock_path = path.as_os_str().to_os_string();
180    lock_path.push(".lock");
181    PathBuf::from(lock_path)
182}
183
184fn lock_session_persistence(path: &Path) -> Result<SessionPersistenceLockGuard> {
185    let lock_path = session_persistence_lock_path(path);
186    let file = std::fs::OpenOptions::new()
187        .read(true)
188        .write(true)
189        .create(true)
190        .truncate(false)
191        .open(&lock_path)
192        .map_err(|e| crate::Error::Io(Box::new(e)))?;
193    file.lock_exclusive()?;
194    Ok(SessionPersistenceLockGuard { file })
195}
196
197#[derive(Debug)]
198struct SessionPersistenceLockGuard {
199    file: std::fs::File,
200}
201
202impl Drop for SessionPersistenceLockGuard {
203    fn drop(&mut self) {
204        let _ = FileExt::unlock(&self.file);
205    }
206}
207
208fn prepare_jsonl_full_rewrite(
209    path: &Path,
210    header: &SessionHeader,
211    entries: &[SessionEntry],
212    persisted_entry_count: usize,
213    header_dirty: bool,
214) -> Result<(SessionHeader, Vec<SessionEntry>)> {
215    let pending_start = persisted_entry_count.min(entries.len());
216    let mut merged_entries = entries[..pending_start].to_vec();
217    let local_pending = &entries[pending_start..];
218    let mut header_to_write = header.clone();
219
220    if path
221        .try_exists()
222        .map_err(|e| crate::Error::Io(Box::new(e)))?
223    {
224        let (disk_session, _) = open_jsonl_blocking(path.to_path_buf())?;
225        if !header_dirty {
226            header_to_write = disk_session.header;
227        }
228
229        let known_ids: HashSet<&str> = entries
230            .iter()
231            .filter_map(|entry| entry.base_id().map(String::as_str))
232            .collect();
233
234        for disk_entry in disk_session.entries.into_iter().skip(pending_start) {
235            let should_merge = disk_entry
236                .base_id()
237                .is_none_or(|id| !known_ids.contains(id.as_str()));
238            if should_merge {
239                merged_entries.push(disk_entry);
240            }
241        }
242    }
243
244    merged_entries.extend_from_slice(local_pending);
245    Ok((header_to_write, merged_entries))
246}
247
248fn resolve_loaded_leaf_id(
249    header: &SessionHeader,
250    natural_leaf_id: Option<String>,
251    entry_index: &HashMap<String, usize>,
252) -> Option<String> {
253    match header.current_leaf.as_deref() {
254        Some(ROOT_LEAF_OVERRIDE_SENTINEL) => None,
255        Some(leaf_id) if entry_index.contains_key(leaf_id) => Some(leaf_id.to_string()),
256        _ => natural_leaf_id,
257    }
258}
259
260fn normalize_loaded_header(mut header: SessionHeader) -> (SessionHeader, bool) {
261    let header_dirty = header.materialize_branch_fallbacks();
262    (header, header_dirty)
263}
264
265fn total_v2_message_count(store: &SessionStoreV2) -> Result<Option<u64>> {
266    if let Some(manifest) = store.read_manifest()? {
267        return Ok(Some(manifest.counters.messages_total));
268    }
269
270    let mut total = 0u64;
271    for frame in store.read_all_entries()? {
272        if frame.entry_type == "message" {
273            total = total.saturating_add(1);
274        }
275    }
276    Ok(Some(total))
277}
278
279/// Handle to a thread-safe shared session.
280#[derive(Clone, Debug)]
281pub struct SessionHandle(pub Arc<Mutex<Session>>);
282
283fn current_path_model_pair(session: &Session) -> Option<(String, String)> {
284    session.effective_model_for_current_path()
285}
286
287fn current_path_model_fields(session: &Session) -> (Option<String>, Option<String>) {
288    if let Some((provider, model_id)) = current_path_model_pair(session) {
289        (Some(provider), Some(model_id))
290    } else {
291        session.header.branch_fallback_model_fields()
292    }
293}
294
295fn current_path_thinking_level(session: &Session) -> Option<String> {
296    session.effective_thinking_level_for_current_path()
297}
298
299#[async_trait]
300impl ExtensionSession for SessionHandle {
301    async fn get_state(&self) -> Value {
302        let cx = AgentCx::for_current_or_request();
303        let Ok(session) = self.0.lock(cx.cx()).await else {
304            return serde_json::json!({
305                "model": null,
306                "thinkingLevel": "off",
307                "durabilityMode": "balanced",
308                "isStreaming": false,
309                "isCompacting": false,
310                "steeringMode": "one-at-a-time",
311                "followUpMode": "one-at-a-time",
312                "sessionFile": null,
313                "sessionId": "",
314                "sessionName": null,
315                "autoCompactionEnabled": false,
316                "messageCount": 0,
317                "pendingMessageCount": 0,
318            });
319        };
320        let session_file = session.path.as_ref().map(|p| p.display().to_string());
321        let session_id = session.header.id.clone();
322        let session_name = session.get_name();
323        let model =
324            current_path_model_pair(&session).map_or(Value::Null, |(provider, model_id)| {
325                serde_json::json!({
326                    "provider": provider,
327                    "id": model_id,
328                })
329            });
330        let thinking_level =
331            current_path_thinking_level(&session).unwrap_or_else(|| "off".to_string());
332        let message_count = session
333            .entries_for_current_path()
334            .iter()
335            .filter(|entry| matches!(entry, SessionEntry::Message(_)))
336            .count();
337        let pending_message_count = session.autosave_metrics().pending_mutations;
338        let durability_mode = session.autosave_durability_mode().as_str();
339        serde_json::json!({
340            "model": model,
341            "thinkingLevel": thinking_level,
342            "durabilityMode": durability_mode,
343            "isStreaming": false,
344            "isCompacting": false,
345            "steeringMode": "one-at-a-time",
346            "followUpMode": "one-at-a-time",
347            "sessionFile": session_file,
348            "sessionId": session_id,
349            "sessionName": session_name,
350            "autoCompactionEnabled": false,
351            "messageCount": message_count,
352            "pendingMessageCount": pending_message_count,
353        })
354    }
355
356    async fn get_messages(&self) -> Vec<SessionMessage> {
357        let cx = AgentCx::for_current_or_request();
358        let Ok(session) = self.0.lock(cx.cx()).await else {
359            return Vec::new();
360        };
361        // Return messages for the current branch only, filtered to
362        // user/assistant/toolResult/bashExecution/custom per spec §3.3.
363        session
364            .entries_for_current_path()
365            .iter()
366            .filter_map(|entry| match entry {
367                SessionEntry::Message(msg) => match msg.message {
368                    SessionMessage::User { .. }
369                    | SessionMessage::Assistant { .. }
370                    | SessionMessage::ToolResult { .. }
371                    | SessionMessage::BashExecution { .. }
372                    | SessionMessage::Custom { .. } => Some(msg.message.clone()),
373                    _ => None,
374                },
375                _ => None,
376            })
377            .collect()
378    }
379
380    async fn get_entries(&self) -> Vec<Value> {
381        let cx = AgentCx::for_current_or_request();
382        let Ok(session) = self.0.lock(cx.cx()).await else {
383            return Vec::new();
384        };
385        session
386            .entries
387            .iter()
388            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
389            .collect()
390    }
391
392    async fn get_branch(&self) -> Vec<Value> {
393        let cx = AgentCx::for_current_or_request();
394        let Ok(session) = self.0.lock(cx.cx()).await else {
395            return Vec::new();
396        };
397        session
398            .entries_for_current_path()
399            .iter()
400            .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
401            .collect()
402    }
403
404    async fn set_name(&self, name: String) -> Result<()> {
405        let cx = AgentCx::for_current_or_request();
406        #[cfg(test)]
407        emit_set_name_deadline_probe(cx.budget().deadline);
408        let mut session = self
409            .0
410            .lock(cx.cx())
411            .await
412            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
413        session.set_name(&name);
414        Ok(())
415    }
416
417    async fn append_message(&self, message: SessionMessage) -> Result<()> {
418        let cx = AgentCx::for_current_or_request();
419        let mut session = self
420            .0
421            .lock(cx.cx())
422            .await
423            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
424        session.append_message(message);
425        Ok(())
426    }
427
428    async fn append_custom_entry(&self, custom_type: String, data: Option<Value>) -> Result<()> {
429        let cx = AgentCx::for_current_or_request();
430        let mut session = self
431            .0
432            .lock(cx.cx())
433            .await
434            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
435        if custom_type.trim().is_empty() {
436            return Err(Error::validation("customType must not be empty"));
437        }
438        session.append_custom_entry(custom_type, data);
439        Ok(())
440    }
441
442    async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
443        let cx = AgentCx::for_current_or_request();
444        let mut session = self
445            .0
446            .lock(cx.cx())
447            .await
448            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
449        let normalized_provider = canonical_provider_id(&provider)
450            .unwrap_or(&provider)
451            .to_string();
452        let (stored_provider, stored_model_id, changed) = match current_path_model_pair(&session) {
453            Some((current_provider, current_model_id))
454                if provider_ids_match(&current_provider, &provider)
455                    && current_model_id.eq_ignore_ascii_case(&model_id) =>
456            {
457                (current_provider, current_model_id, false)
458            }
459            _ => (normalized_provider, model_id.clone(), true),
460        };
461        if changed {
462            session.append_model_change(stored_provider.clone(), stored_model_id.clone());
463        }
464        session.set_model_header(Some(stored_provider), Some(stored_model_id), None);
465        Ok(())
466    }
467
468    async fn get_model(&self) -> (Option<String>, Option<String>) {
469        let cx = AgentCx::for_current_or_request();
470        let Ok(session) = self.0.lock(cx.cx()).await else {
471            return (None, None);
472        };
473        current_path_model_fields(&session)
474    }
475
476    async fn set_thinking_level(&self, level: String) -> Result<()> {
477        let cx = AgentCx::for_current_or_request();
478        let mut session = self
479            .0
480            .lock(cx.cx())
481            .await
482            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
483        let changed = current_path_thinking_level(&session).as_deref() != Some(level.as_str());
484        if changed {
485            session.append_thinking_level_change(level.clone());
486        }
487        session.set_model_header(None, None, Some(level));
488        Ok(())
489    }
490
491    async fn get_thinking_level(&self) -> Option<String> {
492        let cx = AgentCx::for_current_or_request();
493        let Ok(session) = self.0.lock(cx.cx()).await else {
494            return None;
495        };
496        current_path_thinking_level(&session)
497    }
498
499    async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
500        let cx = AgentCx::for_current_or_request();
501        let mut session = self
502            .0
503            .lock(cx.cx())
504            .await
505            .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
506        if session.add_label(&target_id, label).is_none() {
507            return Err(Error::validation(format!(
508                "target entry '{target_id}' not found in session"
509            )));
510        }
511        Ok(())
512    }
513}
514
515/// Default base URL for the Pi session share viewer.
516pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://buildwithpi.ai/session/";
517
518fn build_share_viewer_url(base_url: Option<&str>, gist_id: &str) -> String {
519    let base_url = base_url
520        .filter(|value| !value.is_empty())
521        .unwrap_or(DEFAULT_SHARE_VIEWER_URL);
522    format!("{base_url}#{gist_id}")
523}
524
525/// Get the share viewer URL for a gist ID.
526///
527/// Matches legacy Pi Agent semantics:
528/// - Use `PI_SHARE_VIEWER_URL` env var when set and non-empty
529/// - Otherwise fall back to `DEFAULT_SHARE_VIEWER_URL`
530/// - Final URL is `{base}#{gist_id}` (no trailing-slash normalization)
531#[must_use]
532pub fn get_share_viewer_url(gist_id: &str) -> String {
533    let base_url = std::env::var("PI_SHARE_VIEWER_URL").ok();
534    build_share_viewer_url(base_url.as_deref(), gist_id)
535}
536
537/// Session persistence backend.
538#[derive(Debug, Clone, Copy, PartialEq, Eq)]
539pub enum SessionStoreKind {
540    Jsonl,
541    #[cfg(feature = "sqlite-sessions")]
542    Sqlite,
543}
544
545impl SessionStoreKind {
546    fn from_config(config: &Config) -> Self {
547        let Some(value) = config.session_store.as_deref() else {
548            return Self::Jsonl;
549        };
550
551        if value.eq_ignore_ascii_case("jsonl") {
552            return Self::Jsonl;
553        }
554
555        if value.eq_ignore_ascii_case("sqlite") {
556            #[cfg(feature = "sqlite-sessions")]
557            {
558                return Self::Sqlite;
559            }
560
561            #[cfg(not(feature = "sqlite-sessions"))]
562            {
563                tracing::warn!(
564                    "Config requests session_store=sqlite but binary lacks `sqlite-sessions`; falling back to jsonl"
565                );
566                return Self::Jsonl;
567            }
568        }
569
570        tracing::warn!("Unknown session_store `{value}`, falling back to jsonl");
571        Self::Jsonl
572    }
573
574    const fn extension(self) -> &'static str {
575        match self {
576            Self::Jsonl => "jsonl",
577            #[cfg(feature = "sqlite-sessions")]
578            Self::Sqlite => "sqlite",
579        }
580    }
581}
582
583/// Default upper bound for queued autosave mutations before backpressure coalescing kicks in.
584const DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS: usize = 256;
585
586fn autosave_max_pending_mutations() -> usize {
587    std::env::var("PI_SESSION_AUTOSAVE_MAX_PENDING")
588        .ok()
589        .and_then(|raw| raw.parse::<usize>().ok())
590        .filter(|value| *value > 0)
591        .unwrap_or(DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS)
592}
593
594/// Default number of incremental appends before forcing a full checkpoint rewrite.
595const DEFAULT_COMPACTION_CHECKPOINT_INTERVAL: u64 = 50;
596
597fn compaction_checkpoint_interval() -> u64 {
598    std::env::var("PI_SESSION_COMPACTION_INTERVAL")
599        .ok()
600        .and_then(|raw| raw.parse::<u64>().ok())
601        .filter(|value| *value > 0)
602        .unwrap_or(DEFAULT_COMPACTION_CHECKPOINT_INTERVAL)
603}
604
605/// Durability mode for write-behind autosave behavior.
606#[derive(Debug, Clone, Copy, PartialEq, Eq)]
607pub enum AutosaveDurabilityMode {
608    Strict,
609    Balanced,
610    Throughput,
611}
612
613impl AutosaveDurabilityMode {
614    fn parse(raw: &str) -> Option<Self> {
615        match raw.trim().to_ascii_lowercase().as_str() {
616            "strict" => Some(Self::Strict),
617            "balanced" => Some(Self::Balanced),
618            "throughput" => Some(Self::Throughput),
619            _ => None,
620        }
621    }
622
623    fn from_env() -> Self {
624        std::env::var("PI_SESSION_DURABILITY_MODE")
625            .ok()
626            .as_deref()
627            .and_then(Self::parse)
628            .unwrap_or(Self::Balanced)
629    }
630
631    const fn should_flush_on_shutdown(self) -> bool {
632        matches!(self, Self::Strict | Self::Balanced)
633    }
634
635    const fn best_effort_on_shutdown(self) -> bool {
636        matches!(self, Self::Balanced)
637    }
638
639    pub const fn as_str(self) -> &'static str {
640        match self {
641            Self::Strict => "strict",
642            Self::Balanced => "balanced",
643            Self::Throughput => "throughput",
644        }
645    }
646}
647
648fn resolve_autosave_durability_mode(
649    cli_mode: Option<&str>,
650    config_mode: Option<&str>,
651    env_mode: Option<&str>,
652) -> AutosaveDurabilityMode {
653    cli_mode
654        .and_then(AutosaveDurabilityMode::parse)
655        .or_else(|| config_mode.and_then(AutosaveDurabilityMode::parse))
656        .or_else(|| env_mode.and_then(AutosaveDurabilityMode::parse))
657        .unwrap_or(AutosaveDurabilityMode::Balanced)
658}
659
660/// Autosave flush trigger used for observability.
661#[derive(Debug, Clone, Copy, PartialEq, Eq)]
662pub enum AutosaveFlushTrigger {
663    Manual,
664    Periodic,
665    Shutdown,
666}
667
668#[derive(Debug, Clone, Copy, PartialEq, Eq)]
669enum AutosaveMutationKind {
670    Message,
671    Metadata,
672    Label,
673}
674
675#[derive(Debug, Clone, Copy, PartialEq, Eq)]
676struct AutosaveFlushTicket {
677    batch_size: usize,
678    started_at: Instant,
679    trigger: AutosaveFlushTrigger,
680}
681
682/// Snapshot of autosave queue state and lifecycle counters.
683#[derive(Debug, Clone, Copy, Default)]
684pub struct AutosaveQueueMetrics {
685    pub pending_mutations: usize,
686    pub max_pending_mutations: usize,
687    pub coalesced_mutations: u64,
688    pub backpressure_events: u64,
689    pub flush_started: u64,
690    pub flush_succeeded: u64,
691    pub flush_failed: u64,
692    pub last_flush_batch_size: usize,
693    pub last_flush_duration_ms: Option<u64>,
694    pub last_flush_trigger: Option<AutosaveFlushTrigger>,
695}
696
697#[derive(Debug, Clone)]
698struct AutosaveQueue {
699    pending_mutations: usize,
700    max_pending_mutations: usize,
701    coalesced_mutations: u64,
702    backpressure_events: u64,
703    flush_started: u64,
704    flush_succeeded: u64,
705    flush_failed: u64,
706    last_flush_batch_size: usize,
707    last_flush_duration_ms: Option<u64>,
708    last_flush_trigger: Option<AutosaveFlushTrigger>,
709}
710
711impl AutosaveQueue {
712    fn new() -> Self {
713        Self {
714            pending_mutations: 0,
715            max_pending_mutations: autosave_max_pending_mutations(),
716            coalesced_mutations: 0,
717            backpressure_events: 0,
718            flush_started: 0,
719            flush_succeeded: 0,
720            flush_failed: 0,
721            last_flush_batch_size: 0,
722            last_flush_duration_ms: None,
723            last_flush_trigger: None,
724        }
725    }
726
727    #[cfg(test)]
728    fn with_limit(max_pending_mutations: usize) -> Self {
729        let mut queue = Self::new();
730        queue.max_pending_mutations = max_pending_mutations.max(1);
731        queue
732    }
733
734    const fn metrics(&self) -> AutosaveQueueMetrics {
735        AutosaveQueueMetrics {
736            pending_mutations: self.pending_mutations,
737            max_pending_mutations: self.max_pending_mutations,
738            coalesced_mutations: self.coalesced_mutations,
739            backpressure_events: self.backpressure_events,
740            flush_started: self.flush_started,
741            flush_succeeded: self.flush_succeeded,
742            flush_failed: self.flush_failed,
743            last_flush_batch_size: self.last_flush_batch_size,
744            last_flush_duration_ms: self.last_flush_duration_ms,
745            last_flush_trigger: self.last_flush_trigger,
746        }
747    }
748
749    const fn enqueue_mutation(&mut self, _kind: AutosaveMutationKind) {
750        if self.pending_mutations == 0 {
751            self.pending_mutations = 1;
752            return;
753        }
754        self.coalesced_mutations = self.coalesced_mutations.saturating_add(1);
755        if self.pending_mutations < self.max_pending_mutations {
756            self.pending_mutations += 1;
757        } else {
758            self.backpressure_events = self.backpressure_events.saturating_add(1);
759        }
760    }
761
762    fn begin_flush(&mut self, trigger: AutosaveFlushTrigger) -> Option<AutosaveFlushTicket> {
763        if self.pending_mutations == 0 {
764            return None;
765        }
766        let batch_size = self.pending_mutations;
767        self.pending_mutations = 0;
768        self.flush_started = self.flush_started.saturating_add(1);
769        self.last_flush_batch_size = batch_size;
770        self.last_flush_trigger = Some(trigger);
771        Some(AutosaveFlushTicket {
772            batch_size,
773            started_at: Instant::now(),
774            trigger,
775        })
776    }
777
778    fn finish_flush(&mut self, ticket: AutosaveFlushTicket, success: bool) {
779        let elapsed = ticket.started_at.elapsed().as_millis();
780        let elapsed = u64::try_from(elapsed.min(u128::from(u64::MAX)))
781            .expect("elapsed milliseconds clamped to u64::MAX");
782        self.last_flush_duration_ms = Some(elapsed);
783        self.last_flush_trigger = Some(ticket.trigger);
784        if success {
785            self.flush_succeeded = self.flush_succeeded.saturating_add(1);
786            return;
787        }
788
789        self.flush_failed = self.flush_failed.saturating_add(1);
790        // New mutations may have arrived while the flush was in flight.
791        // Restore only into remaining capacity so pending count never exceeds
792        // `max_pending_mutations`.
793        let available_capacity = self
794            .max_pending_mutations
795            .saturating_sub(self.pending_mutations);
796        let restored = ticket.batch_size.min(available_capacity);
797        self.pending_mutations = self.pending_mutations.saturating_add(restored);
798        let dropped = ticket.batch_size.saturating_sub(restored);
799        if dropped > 0 {
800            let dropped = dropped as u64;
801            self.backpressure_events = self.backpressure_events.saturating_add(dropped);
802            self.coalesced_mutations = self.coalesced_mutations.saturating_add(dropped);
803        }
804    }
805}
806
807// ============================================================================
808// Session
809// ============================================================================
810
811/// A session manages conversation state and persistence.
812#[derive(Debug)]
813#[allow(clippy::struct_excessive_bools)]
814pub struct Session {
815    /// Session header
816    pub header: SessionHeader,
817    /// Session entries (messages, changes, etc.)
818    pub entries: Vec<SessionEntry>,
819    /// Path to the session file (None for in-memory)
820    pub path: Option<PathBuf>,
821    /// Current leaf entry ID. Direct modification outside of `session.rs`
822    /// is forbidden because it can desynchronize the `is_linear` optimization cache.
823    pub(crate) leaf_id: Option<String>,
824    /// Base directory for session storage (optional override)
825    pub session_dir: Option<PathBuf>,
826    store_kind: SessionStoreKind,
827    /// Cached entry IDs for O(1) uniqueness checks when appending.
828    entry_ids: HashSet<String>,
829
830    // -- Performance caches (Gaps A/B/C) --
831    /// True when all entries form a linear chain (no branching).
832    /// When true, `entries_for_current_path()` returns all entries without
833    /// building a parent map — the 99% fast path.
834    is_linear: bool,
835    /// Map from entry ID to index in `self.entries` for O(1) lookup.
836    entry_index: HashMap<String, usize>,
837    /// Incrementally maintained message count (avoids O(n) scan on save).
838    cached_message_count: u64,
839    /// Most recent session name from `SessionInfo` entries.
840    cached_name: Option<String>,
841    /// Write-behind autosave queue state and lifecycle counters.
842    autosave_queue: AutosaveQueue,
843    /// Current durability policy for shutdown final flush behavior.
844    autosave_durability: AutosaveDurabilityMode,
845
846    // -- Incremental append state --
847    /// Number of entries already persisted to disk (high-water mark).
848    /// Uses Arc<AtomicUsize> to allow atomic updates from detached background threads,
849    /// ensuring state consistency even if the save future is dropped/cancelled.
850    persisted_entry_count: Arc<AtomicUsize>,
851    /// True when header was modified since last save (forces full rewrite).
852    header_dirty: bool,
853    /// Incremental appends since last full rewrite (checkpoint counter).
854    appends_since_checkpoint: u64,
855    /// Sidecar root when session was loaded from V2 storage.
856    v2_sidecar_root: Option<PathBuf>,
857    /// True when current in-memory entries are a partial hydration view from V2.
858    v2_partial_hydration: bool,
859    /// Resume mode used when loading from V2 sidecar.
860    v2_resume_mode: Option<V2OpenMode>,
861    /// True when the JSONL file has advanced beyond the loaded V2 sidecar.
862    v2_sidecar_stale: bool,
863    /// Offset to add to `cached_message_count` to account for messages not loaded in memory
864    /// (e.g. when using V2 tail hydration).
865    v2_message_count_offset: u64,
866}
867
868impl Clone for Session {
869    fn clone(&self) -> Self {
870        Self {
871            header: self.header.clone(),
872            entries: self.entries.clone(),
873            path: self.path.clone(),
874            leaf_id: self.leaf_id.clone(),
875            session_dir: self.session_dir.clone(),
876            store_kind: self.store_kind,
877            entry_ids: self.entry_ids.clone(),
878            is_linear: self.is_linear,
879            entry_index: self.entry_index.clone(),
880            cached_message_count: self.cached_message_count,
881            cached_name: self.cached_name.clone(),
882            autosave_queue: self.autosave_queue.clone(),
883            autosave_durability: self.autosave_durability,
884            // Deep copy the atomic value to preserve value semantics for clones.
885            // If we just cloned the Arc, a save on the clone would increment the
886            // counter on the original, desynchronizing it from its own entries.
887            persisted_entry_count: Arc::new(AtomicUsize::new(
888                self.persisted_entry_count.load(Ordering::SeqCst),
889            )),
890            header_dirty: self.header_dirty,
891            appends_since_checkpoint: self.appends_since_checkpoint,
892            v2_sidecar_root: self.v2_sidecar_root.clone(),
893            v2_partial_hydration: self.v2_partial_hydration,
894            v2_resume_mode: self.v2_resume_mode,
895            v2_sidecar_stale: self.v2_sidecar_stale,
896            v2_message_count_offset: self.v2_message_count_offset,
897        }
898    }
899}
900
901/// Result of planning a `/fork` operation from a specific user message.
902///
903/// Mirrors legacy semantics:
904/// - The new session's leaf is the *parent* of the selected user message (or `None` if root),
905///   so the selected message can be re-submitted as a new branch without creating consecutive
906///   user messages.
907/// - The selected user message text is returned for editor pre-fill.
908#[derive(Debug, Clone)]
909pub struct ForkPlan {
910    /// Entries to copy into the new session file (path to the fork leaf, inclusive).
911    pub entries: Vec<SessionEntry>,
912    /// Leaf ID to set in the new session (parent of selected user entry).
913    pub leaf_id: Option<String>,
914    /// Text of the selected user message (for editor pre-fill).
915    pub selected_text: String,
916}
917
918/// Lightweight snapshot of session data for non-blocking export.
919///
920/// Captures only the header and entries needed for HTML rendering,
921/// avoiding a full `Session` clone (which includes caches, autosave
922/// queue, and other internal state).
923#[derive(Debug, Clone)]
924pub struct ExportSnapshot {
925    /// Session header (id, timestamp, cwd).
926    pub header: SessionHeader,
927    /// Session entries to render.
928    pub entries: Vec<SessionEntry>,
929    /// Session file path (for default output filename).
930    pub path: Option<PathBuf>,
931}
932
933impl ExportSnapshot {
934    /// Render this snapshot as a standalone HTML document.
935    ///
936    /// Delegates to the shared rendering logic used by `Session::to_html()`.
937    pub fn to_html(&self) -> String {
938        render_session_html(&self.header, &self.entries)
939    }
940}
941
942/// Diagnostics captured while opening a session file.
943#[derive(Debug, Clone, Default)]
944pub struct SessionOpenDiagnostics {
945    pub skipped_entries: Vec<SessionOpenSkippedEntry>,
946    pub orphaned_parent_links: Vec<SessionOpenOrphanedParentLink>,
947}
948
949#[derive(Debug, Clone)]
950pub struct SessionOpenSkippedEntry {
951    /// 1-based line number in the session file.
952    pub line_number: usize,
953    pub error: String,
954}
955
956#[derive(Debug, Clone)]
957pub struct SessionOpenOrphanedParentLink {
958    pub entry_id: String,
959    pub missing_parent_id: String,
960}
961
962/// Loading strategy for reconstructing a `Session` from a V2 store.
963#[derive(Debug, Clone, Copy, PartialEq, Eq)]
964pub enum V2OpenMode {
965    Full,
966    ActivePath,
967    Tail(u64),
968}
969
970const DEFAULT_V2_LAZY_HYDRATION_THRESHOLD: u64 = 10_000;
971const DEFAULT_V2_TAIL_HYDRATION_COUNT: u64 = 256;
972
973fn parse_v2_open_mode(raw: &str) -> Option<V2OpenMode> {
974    let normalized = raw.trim().to_ascii_lowercase();
975    if normalized.is_empty() {
976        return None;
977    }
978    match normalized.as_str() {
979        "full" => Some(V2OpenMode::Full),
980        "active" | "active_path" | "active-path" => Some(V2OpenMode::ActivePath),
981        "tail" => Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT)),
982        _ => normalized
983            .strip_prefix("tail:")
984            .and_then(|value| value.parse::<u64>().ok().map(V2OpenMode::Tail)),
985    }
986}
987
988fn resolve_v2_lazy_hydration_threshold(env_raw: Option<&str>) -> u64 {
989    env_raw
990        .and_then(|raw| raw.trim().parse::<u64>().ok())
991        .unwrap_or(DEFAULT_V2_LAZY_HYDRATION_THRESHOLD)
992}
993
994fn select_v2_open_mode_for_resume(
995    entry_count: u64,
996    mode_override_raw: Option<&str>,
997    threshold_override_raw: Option<&str>,
998) -> (V2OpenMode, &'static str, u64) {
999    let lazy_threshold = resolve_v2_lazy_hydration_threshold(threshold_override_raw);
1000    if let Some(raw) = mode_override_raw {
1001        if let Some(mode) = parse_v2_open_mode(raw) {
1002            return (mode, "env_override", lazy_threshold);
1003        }
1004    }
1005
1006    if lazy_threshold > 0 && entry_count > lazy_threshold {
1007        return (
1008            V2OpenMode::ActivePath,
1009            "entry_count_above_lazy_threshold",
1010            lazy_threshold,
1011        );
1012    }
1013
1014    (V2OpenMode::Full, "default_full", lazy_threshold)
1015}
1016
1017impl SessionOpenDiagnostics {
1018    fn warning_lines(&self) -> Vec<String> {
1019        let mut lines = Vec::new();
1020        for skipped in &self.skipped_entries {
1021            lines.push(format!(
1022                "Warning: Skipping corrupted entry at line {} in session file: {}",
1023                skipped.line_number, skipped.error
1024            ));
1025        }
1026
1027        if !self.skipped_entries.is_empty() {
1028            lines.push(format!(
1029                "Warning: Skipped {} corrupted entries while loading session",
1030                self.skipped_entries.len()
1031            ));
1032        }
1033
1034        for orphan in &self.orphaned_parent_links {
1035            lines.push(format!(
1036                "Warning: Entry {} references missing parent {}",
1037                orphan.entry_id, orphan.missing_parent_id
1038            ));
1039        }
1040
1041        if !self.orphaned_parent_links.is_empty() {
1042            lines.push(format!(
1043                "Warning: Detected {} orphaned parent links while loading session",
1044                self.orphaned_parent_links.len()
1045            ));
1046        }
1047
1048        lines
1049    }
1050}
1051
1052impl Session {
1053    /// Create a new session from CLI args and config.
1054    pub async fn new(cli: &Cli, config: &Config) -> Result<Self> {
1055        let session_dir = cli.session_dir.as_ref().map(PathBuf::from);
1056        let durability_mode = resolve_autosave_durability_mode(
1057            cli.session_durability.as_deref(),
1058            config.session_durability.as_deref(),
1059            std::env::var("PI_SESSION_DURABILITY_MODE").ok().as_deref(),
1060        );
1061        if cli.no_session {
1062            let mut session = Self::in_memory();
1063            session.set_autosave_durability_mode(durability_mode);
1064            return Ok(session);
1065        }
1066
1067        if let Some(path) = &cli.session {
1068            let mut session = Self::open(path).await?;
1069            session.session_dir = session_dir
1070                .clone()
1071                .or_else(|| infer_session_root_from_path(Path::new(path)));
1072            session.set_autosave_durability_mode(durability_mode);
1073            return Ok(session);
1074        }
1075
1076        if cli.resume {
1077            let picker_input_override = config
1078                .session_picker_input
1079                .filter(|value| *value > 0)
1080                .map(|value| value.to_string());
1081            let mut session = Box::pin(Self::resume_with_picker(
1082                session_dir.as_deref(),
1083                config,
1084                picker_input_override,
1085            ))
1086            .await?;
1087            session.set_autosave_durability_mode(durability_mode);
1088            return Ok(session);
1089        }
1090
1091        if cli.r#continue {
1092            let mut session = Self::continue_recent_in_dir(session_dir.as_deref(), config).await?;
1093            session.set_autosave_durability_mode(durability_mode);
1094            return Ok(session);
1095        }
1096
1097        let store_kind = SessionStoreKind::from_config(config);
1098        let mut session = Self::create_with_dir_and_store(session_dir, store_kind);
1099        session.set_autosave_durability_mode(durability_mode);
1100
1101        // Create a new session
1102        Ok(session)
1103    }
1104
1105    /// Resume a session by prompting the user to select from recent sessions.
1106    #[allow(clippy::too_many_lines)]
1107    pub async fn resume_with_picker(
1108        override_dir: Option<&Path>,
1109        config: &Config,
1110        picker_input_override: Option<String>,
1111    ) -> Result<Self> {
1112        let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
1113        let mut picker_input_override = picker_input_override;
1114        if picker_input_override.is_none() && is_interactive {
1115            if let Some(session) = crate::session_picker::pick_session(override_dir).await {
1116                return Ok(session);
1117            }
1118        }
1119
1120        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1121        let store_kind = SessionStoreKind::from_config(config);
1122        let cwd = std::env::current_dir()?;
1123        let encoded_cwd = encode_cwd(&cwd);
1124        let project_session_dir = base_dir.join(&encoded_cwd);
1125        let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
1126
1127        let base_dir_clone = base_dir.clone();
1128        let cwd_display = cwd.display().to_string();
1129        let (tx, mut rx) = oneshot::channel();
1130
1131        let handle = thread::spawn(move || {
1132            let indexed_meta = SessionIndex::for_sessions_root(&base_dir_clone)
1133                .list_sessions(Some(&cwd_display))
1134                .unwrap_or_default();
1135            let cx = AgentCx::for_request();
1136            let _ = tx.send(cx.cx(), Ok(indexed_meta));
1137        });
1138
1139        let cx = AgentCx::for_request();
1140        let recv_result = rx.recv(cx.cx()).await;
1141        let indexed_meta =
1142            finish_worker_result(handle, recv_result, "Session picker index task cancelled")
1143                .unwrap_or_default();
1144        let session_index = SessionIndex::for_sessions_root(&base_dir);
1145        let (entries, missing_paths) = split_indexed_session_entries(indexed_meta);
1146        for path in &missing_paths {
1147            prune_session_index_path(
1148                &session_index,
1149                path,
1150                "Failed to prune missing session from index during picker refresh",
1151            );
1152        }
1153
1154        if project_session_dir_missing {
1155            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1156        }
1157
1158        let scanned = scan_sessions_on_disk(&project_session_dir, entries.clone()).await?;
1159        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1160        for entry in entries {
1161            by_path.insert(entry.path.clone(), entry);
1162        }
1163        for path in &scanned.failed_paths {
1164            prune_session_index_path(
1165                &session_index,
1166                path,
1167                "Failed to prune unreadable session from index during picker refresh",
1168            );
1169            by_path.remove(path);
1170        }
1171        refresh_session_index_entries(
1172            &session_index,
1173            &scanned.refreshed_entries,
1174            "Failed to refresh session metadata in index during picker refresh",
1175        );
1176        merge_scanned_session_entries(&mut by_path, scanned.entries);
1177        let mut entries = by_path.into_values().collect::<Vec<_>>();
1178
1179        if entries.is_empty() {
1180            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1181        }
1182
1183        entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1184        let max_entries = 20usize.min(entries.len());
1185        let mut entries = entries.into_iter().take(max_entries).collect::<Vec<_>>();
1186
1187        let console = PiConsole::new();
1188        console.render_info("Select a session to resume:");
1189
1190        let headers = ["#", "Timestamp", "Messages", "Name", "Path"];
1191
1192        let mut attempts = 0;
1193        loop {
1194            if entries.is_empty() {
1195                console.render_warning("No resumable sessions available. Starting a new session.");
1196                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1197            }
1198
1199            let mut rows: Vec<Vec<String>> = Vec::new();
1200            for (idx, entry) in entries.iter().enumerate() {
1201                rows.push(vec![
1202                    format!("{}", idx + 1),
1203                    entry.timestamp.clone(),
1204                    entry.message_count.to_string(),
1205                    entry.name.clone().unwrap_or_else(|| entry.id.clone()),
1206                    entry.path.display().to_string(),
1207                ]);
1208            }
1209            let row_refs: Vec<Vec<&str>> = rows
1210                .iter()
1211                .map(|row| row.iter().map(String::as_str).collect())
1212                .collect();
1213            console.render_table(&headers, &row_refs);
1214
1215            attempts += 1;
1216            if attempts > 3 {
1217                console.render_warning("No selection made. Starting a new session.");
1218                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1219            }
1220
1221            print!(
1222                "Enter selection (1-{}, blank to start new): ",
1223                entries.len()
1224            );
1225            let _ = std::io::stdout().flush();
1226
1227            let input = if let Some(override_input) = picker_input_override.take() {
1228                override_input
1229            } else {
1230                let mut input = String::new();
1231                std::io::stdin().read_line(&mut input)?;
1232                input
1233            };
1234            let input = input.trim();
1235            if input.is_empty() {
1236                console.render_info("Starting a new session.");
1237                return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1238            }
1239
1240            match input.parse::<usize>() {
1241                Ok(selection) if selection > 0 && selection <= entries.len() => {
1242                    let selected = &entries[selection - 1];
1243                    match Self::open(selected.path.to_string_lossy().as_ref()).await {
1244                        Ok(mut session) => {
1245                            session.session_dir = Some(base_dir.clone());
1246                            return Ok(session);
1247                        }
1248                        Err(err) => {
1249                            tracing::warn!(
1250                                path = %selected.path.display(),
1251                                error = %err,
1252                                "Failed to open selected session while resuming"
1253                            );
1254                            prune_session_index_path(
1255                                &session_index,
1256                                &selected.path,
1257                                "Failed to prune unreadable selected session after picker open failure",
1258                            );
1259                            entries.remove(selection - 1);
1260
1261                            if is_interactive {
1262                                console.render_warning(
1263                                    "Selected session could not be opened. Pick another session.",
1264                                );
1265                                continue;
1266                            }
1267
1268                            console.render_warning(
1269                                "Selected session could not be opened. Starting a new session.",
1270                            );
1271                            return Ok(Self::create_with_dir_and_store(
1272                                Some(base_dir.clone()),
1273                                store_kind,
1274                            ));
1275                        }
1276                    }
1277                }
1278                _ => {
1279                    console.render_warning("Invalid selection. Try again.");
1280                }
1281            }
1282        }
1283    }
1284
1285    /// Create an in-memory (ephemeral) session.
1286    pub fn in_memory() -> Self {
1287        Self {
1288            header: SessionHeader::new(),
1289            entries: Vec::new(),
1290            path: None,
1291            leaf_id: None,
1292            session_dir: None,
1293            store_kind: SessionStoreKind::Jsonl,
1294            entry_ids: HashSet::new(),
1295            is_linear: true,
1296            entry_index: HashMap::new(),
1297            cached_message_count: 0,
1298            cached_name: None,
1299            autosave_queue: AutosaveQueue::new(),
1300            autosave_durability: AutosaveDurabilityMode::from_env(),
1301            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1302            header_dirty: false,
1303            appends_since_checkpoint: 0,
1304            v2_sidecar_root: None,
1305            v2_partial_hydration: false,
1306            v2_resume_mode: None,
1307            v2_sidecar_stale: false,
1308            v2_message_count_offset: 0,
1309        }
1310    }
1311
1312    /// Create a new session.
1313    pub fn create() -> Self {
1314        Self::create_with_dir(None)
1315    }
1316
1317    /// Create a new session with an optional base directory override.
1318    pub fn create_with_dir(session_dir: Option<PathBuf>) -> Self {
1319        Self::create_with_dir_and_store(session_dir, SessionStoreKind::Jsonl)
1320    }
1321
1322    pub fn create_with_dir_and_store(
1323        session_dir: Option<PathBuf>,
1324        store_kind: SessionStoreKind,
1325    ) -> Self {
1326        let header = SessionHeader::new();
1327        Self {
1328            header,
1329            entries: Vec::new(),
1330            path: None,
1331            leaf_id: None,
1332            session_dir,
1333            store_kind,
1334            entry_ids: HashSet::new(),
1335            is_linear: true,
1336            entry_index: HashMap::new(),
1337            cached_message_count: 0,
1338            cached_name: None,
1339            autosave_queue: AutosaveQueue::new(),
1340            autosave_durability: AutosaveDurabilityMode::from_env(),
1341            persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1342            header_dirty: false,
1343            appends_since_checkpoint: 0,
1344            v2_sidecar_root: None,
1345            v2_partial_hydration: false,
1346            v2_resume_mode: None,
1347            v2_sidecar_stale: false,
1348            v2_message_count_offset: 0,
1349        }
1350    }
1351
1352    /// Open an existing session.
1353    pub async fn open(path: &str) -> Result<Self> {
1354        let (session, diagnostics) = Self::open_with_diagnostics(path).await?;
1355        for warning in diagnostics.warning_lines() {
1356            warn!("{warning}");
1357        }
1358        Ok(session)
1359    }
1360
1361    /// Open an existing session and return diagnostics about any recovered corruption.
1362    pub async fn open_with_diagnostics(path: &str) -> Result<(Self, SessionOpenDiagnostics)> {
1363        let path = PathBuf::from(path);
1364        if !path.exists() {
1365            return Err(crate::Error::SessionNotFound {
1366                path: path.display().to_string(),
1367            });
1368        }
1369
1370        if path.extension().is_some_and(|ext| ext == "sqlite") {
1371            #[cfg(feature = "sqlite-sessions")]
1372            {
1373                let session = Self::open_sqlite(&path).await?;
1374                return Ok((session, SessionOpenDiagnostics::default()));
1375            }
1376
1377            #[cfg(not(feature = "sqlite-sessions"))]
1378            {
1379                return Err(Error::session(
1380                    "SQLite session files require building with `--features sqlite-sessions`",
1381                ));
1382            }
1383        }
1384
1385        // Check for V2 sidecar store — enables O(index+tail) resume.
1386        if session_store_v2::has_v2_sidecar(&path) {
1387            let v2_root = session_store_v2::v2_sidecar_path(&path);
1388            let is_stale = is_v2_sidecar_stale(&path, &v2_root);
1389
1390            if is_stale {
1391                tracing::warn!(
1392                    path = %path.display(),
1393                    "V2 sidecar is stale (source JSONL newer); skipping V2 resume"
1394                );
1395            } else {
1396                match Self::open_v2_with_diagnostics(&path).await {
1397                    Ok(result) => return Ok(result),
1398                    Err(e) => {
1399                        tracing::warn!(
1400                            path = %path.display(),
1401                            error = %e,
1402                            "V2 sidecar resume failed, falling back to full JSONL parse"
1403                        );
1404                    }
1405                }
1406            }
1407        }
1408
1409        Self::open_jsonl_with_diagnostics(&path).await
1410    }
1411
1412    /// Open a session from an already-open V2 store with an explicit read mode.
1413    pub fn open_from_v2(
1414        store: &SessionStoreV2,
1415        header: SessionHeader,
1416        mode: V2OpenMode,
1417    ) -> Result<(Self, SessionOpenDiagnostics)> {
1418        header
1419            .validate()
1420            .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
1421        let (header, normalized_header_dirty) = normalize_loaded_header(header);
1422        let frames = match mode {
1423            V2OpenMode::Full => store.read_all_entries()?,
1424            V2OpenMode::ActivePath => match store.head() {
1425                Some(head) => store.read_active_path(&head.entry_id)?,
1426                None => Vec::new(),
1427            },
1428            V2OpenMode::Tail(count) => store.read_tail_entries(count)?,
1429        };
1430
1431        let mut diagnostics = SessionOpenDiagnostics::default();
1432        let mut entries = Vec::with_capacity(frames.len());
1433        for frame in &frames {
1434            match session_store_v2::frame_to_session_entry(frame) {
1435                Ok(entry) => entries.push(entry),
1436                Err(e) => {
1437                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
1438                        line_number: usize::try_from(frame.entry_seq).unwrap_or(0),
1439                        error: e.to_string(),
1440                    });
1441                }
1442            }
1443        }
1444
1445        let finalized = finalize_loaded_entries(&mut entries);
1446        for orphan in &finalized.orphans {
1447            diagnostics
1448                .orphaned_parent_links
1449                .push(SessionOpenOrphanedParentLink {
1450                    entry_id: orphan.0.clone(),
1451                    missing_parent_id: orphan.1.clone(),
1452                });
1453        }
1454
1455        let mut v2_message_count_offset = 0;
1456        if matches!(mode, V2OpenMode::Tail(_) | V2OpenMode::ActivePath) {
1457            if let Ok(Some(total)) = total_v2_message_count(store) {
1458                let loaded = finalized.message_count;
1459                v2_message_count_offset = total.saturating_sub(loaded);
1460            }
1461        }
1462
1463        let entry_count = entries.len();
1464        let natural_leaf_id = finalized.leaf_id.clone();
1465        let leaf_id =
1466            resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
1467        Ok((
1468            Self {
1469                header,
1470                entries,
1471                path: None,
1472                leaf_id: leaf_id.clone(),
1473                session_dir: None,
1474                store_kind: SessionStoreKind::Jsonl,
1475                entry_ids: finalized.entry_ids,
1476                is_linear: finalized.is_linear && leaf_id == natural_leaf_id,
1477                entry_index: finalized.entry_index,
1478                cached_message_count: finalized
1479                    .message_count
1480                    .saturating_add(v2_message_count_offset),
1481                cached_name: finalized.name,
1482                autosave_queue: AutosaveQueue::new(),
1483                autosave_durability: AutosaveDurabilityMode::from_env(),
1484                persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1485                header_dirty: normalized_header_dirty,
1486                appends_since_checkpoint: 0,
1487                v2_sidecar_root: None,
1488                v2_partial_hydration: !matches!(mode, V2OpenMode::Full),
1489                v2_resume_mode: Some(mode),
1490                v2_sidecar_stale: false,
1491                v2_message_count_offset,
1492            },
1493            diagnostics,
1494        ))
1495    }
1496
1497    /// Open using the V2 sidecar store (async wrapper around blocking read).
1498    async fn open_v2_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1499        let path_buf = path.to_path_buf();
1500        let (tx, mut rx) = oneshot::channel();
1501
1502        let handle = thread::spawn(move || {
1503            let res = crate::session::open_from_v2_store_blocking(path_buf);
1504            let cx = AgentCx::for_request();
1505            let _ = tx.send(cx.cx(), res);
1506        });
1507
1508        let cx = AgentCx::for_request();
1509        let recv_result = rx.recv(cx.cx()).await;
1510        finish_worker_result(handle, recv_result, "V2 open task cancelled")
1511    }
1512
1513    async fn open_jsonl_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1514        let path_buf = path.to_path_buf();
1515        let (tx, mut rx) = oneshot::channel();
1516
1517        let handle = thread::spawn(move || {
1518            let res = open_jsonl_blocking(path_buf);
1519            let cx = AgentCx::for_request();
1520            let _ = tx.send(cx.cx(), res);
1521        });
1522
1523        let cx = AgentCx::for_request();
1524        let recv_result = rx.recv(cx.cx()).await;
1525        finish_worker_result(handle, recv_result, "Open task cancelled")
1526    }
1527
1528    #[cfg(feature = "sqlite-sessions")]
1529    async fn open_sqlite(path: &Path) -> Result<Self> {
1530        let (header, mut entries) = crate::session_sqlite::load_session(path).await?;
1531        let (header, normalized_header_dirty) = normalize_loaded_header(header);
1532        let finalized = finalize_loaded_entries(&mut entries);
1533        let entry_count = entries.len();
1534        let natural_leaf_id = finalized.leaf_id.clone();
1535        let leaf_id =
1536            resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
1537
1538        Ok(Self {
1539            header,
1540            entries,
1541            path: Some(path.to_path_buf()),
1542            leaf_id: leaf_id.clone(),
1543            session_dir: None,
1544            store_kind: SessionStoreKind::Sqlite,
1545            entry_ids: finalized.entry_ids,
1546            is_linear: finalized.is_linear && leaf_id == natural_leaf_id,
1547            entry_index: finalized.entry_index,
1548            cached_message_count: finalized.message_count,
1549            cached_name: finalized.name,
1550            autosave_queue: AutosaveQueue::new(),
1551            autosave_durability: AutosaveDurabilityMode::from_env(),
1552            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1553            header_dirty: normalized_header_dirty,
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    /// Continue the most recent session.
1564    pub async fn continue_recent_in_dir(
1565        override_dir: Option<&Path>,
1566        config: &Config,
1567    ) -> Result<Self> {
1568        let store_kind = SessionStoreKind::from_config(config);
1569        let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1570        let cwd = std::env::current_dir()?;
1571        let cwd_display = cwd.display().to_string();
1572        let encoded_cwd = encode_cwd(&cwd);
1573        let project_session_dir = base_dir.join(&encoded_cwd);
1574        let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
1575
1576        // Prefer the session index for fast lookup.
1577        let base_dir_clone = base_dir.clone();
1578        let cwd_display_clone = cwd_display.clone();
1579        let (tx, mut rx) = oneshot::channel();
1580
1581        let handle = thread::spawn(move || {
1582            let index = SessionIndex::for_sessions_root(&base_dir_clone);
1583            let mut indexed_sessions = index
1584                .list_sessions(Some(&cwd_display_clone))
1585                .unwrap_or_default();
1586
1587            if indexed_sessions.is_empty() && index.reindex_all().is_ok() {
1588                indexed_sessions = index
1589                    .list_sessions(Some(&cwd_display_clone))
1590                    .unwrap_or_default();
1591            }
1592            let cx = AgentCx::for_request();
1593            let _ = tx.send(cx.cx(), Ok(indexed_sessions));
1594        });
1595
1596        let cx = AgentCx::for_request();
1597        let recv_result = rx.recv(cx.cx()).await;
1598        let indexed_meta =
1599            finish_worker_result(handle, recv_result, "Recent session index task cancelled")
1600                .unwrap_or_default();
1601
1602        let index = SessionIndex::for_sessions_root(&base_dir);
1603        let (indexed_sessions, missing_paths) = split_indexed_session_entries(indexed_meta);
1604        for path in &missing_paths {
1605            prune_session_index_path(
1606                &index,
1607                path,
1608                "Failed to prune missing session from index during recent-session refresh",
1609            );
1610        }
1611
1612        if project_session_dir_missing {
1613            return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1614        }
1615
1616        let scanned = scan_sessions_on_disk(&project_session_dir, indexed_sessions.clone()).await?;
1617
1618        let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1619        for entry in indexed_sessions {
1620            by_path.insert(entry.path.clone(), entry);
1621        }
1622        for path in &scanned.failed_paths {
1623            prune_session_index_path(
1624                &index,
1625                path,
1626                "Failed to prune unreadable session from index during recent-session refresh",
1627            );
1628            by_path.remove(path);
1629        }
1630        refresh_session_index_entries(
1631            &index,
1632            &scanned.refreshed_entries,
1633            "Failed to refresh session metadata in index during recent-session refresh",
1634        );
1635        merge_scanned_session_entries(&mut by_path, scanned.entries);
1636
1637        let mut candidates = by_path.into_values().collect::<Vec<_>>();
1638        candidates.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1639
1640        for entry in &candidates {
1641            match Self::open(entry.path.to_string_lossy().as_ref()).await {
1642                Ok(mut session) => {
1643                    session.session_dir = Some(base_dir.clone());
1644                    return Ok(session);
1645                }
1646                Err(err) => {
1647                    tracing::warn!(
1648                        path = %entry.path.display(),
1649                        error = %err,
1650                        "Skipping unreadable session candidate while continuing"
1651                    );
1652                    prune_session_index_path(
1653                        &index,
1654                        &entry.path,
1655                        "Failed to prune unreadable session after resume candidate open failure",
1656                    );
1657                }
1658            }
1659        }
1660
1661        Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind))
1662    }
1663
1664    /// Save the session to disk.
1665    pub async fn save(&mut self) -> Result<()> {
1666        let ticket = self
1667            .autosave_queue
1668            .begin_flush(AutosaveFlushTrigger::Manual);
1669        let result = self.save_inner().await;
1670        if let Some(ticket) = ticket {
1671            self.autosave_queue.finish_flush(ticket, result.is_ok());
1672        }
1673        result
1674    }
1675
1676    /// Flush queued autosave mutations using the requested trigger.
1677    ///
1678    /// This is the write-behind entry point: no-op when there are no pending
1679    /// mutations, and one persistence operation for all coalesced mutations when
1680    /// pending work exists.
1681    pub async fn flush_autosave(&mut self, trigger: AutosaveFlushTrigger) -> Result<()> {
1682        let Some(ticket) = self.autosave_queue.begin_flush(trigger) else {
1683            return Ok(());
1684        };
1685        let result = self.save_inner().await;
1686        self.autosave_queue.finish_flush(ticket, result.is_ok());
1687        result
1688    }
1689
1690    /// Final shutdown flush respecting the configured durability mode.
1691    pub async fn flush_autosave_on_shutdown(&mut self) -> Result<()> {
1692        if !self.autosave_durability.should_flush_on_shutdown() {
1693            return Ok(());
1694        }
1695        let result = self.flush_autosave(AutosaveFlushTrigger::Shutdown).await;
1696        if result.is_err() && self.autosave_durability.best_effort_on_shutdown() {
1697            if let Err(err) = &result {
1698                tracing::warn!(error = %err, "best-effort autosave flush failed during shutdown");
1699            }
1700            return Ok(());
1701        }
1702        result
1703    }
1704
1705    /// Current autosave queue and lifecycle counters for observability.
1706    pub const fn autosave_metrics(&self) -> AutosaveQueueMetrics {
1707        self.autosave_queue.metrics()
1708    }
1709
1710    pub const fn autosave_durability_mode(&self) -> AutosaveDurabilityMode {
1711        self.autosave_durability
1712    }
1713
1714    pub const fn set_autosave_durability_mode(&mut self, mode: AutosaveDurabilityMode) {
1715        self.autosave_durability = mode;
1716    }
1717
1718    #[cfg(test)]
1719    fn set_autosave_queue_limit_for_test(&mut self, max_pending_mutations: usize) {
1720        self.autosave_queue = AutosaveQueue::with_limit(max_pending_mutations);
1721    }
1722
1723    #[cfg(test)]
1724    const fn set_autosave_durability_for_test(&mut self, mode: AutosaveDurabilityMode) {
1725        self.autosave_durability = mode;
1726    }
1727
1728    /// Ensure a lazily hydrated V2 session is fully hydrated before persisting.
1729    ///
1730    /// Partial V2 hydration intentionally loads only a subset of entries for fast
1731    /// resume. Before any save path that could trigger a full JSONL rewrite, we
1732    /// must rehydrate all V2 entries to preserve non-active branches.
1733    fn ensure_full_v2_hydration_before_save(&mut self) -> Result<()> {
1734        if !self.v2_partial_hydration {
1735            return Ok(());
1736        }
1737
1738        let Some(v2_root) = self.v2_sidecar_root.clone() else {
1739            tracing::warn!(
1740                "session marked as partially hydrated from V2 but sidecar root is unavailable; disabling partial flag"
1741            );
1742            self.v2_partial_hydration = false;
1743            return Ok(());
1744        };
1745
1746        let pending_start = self
1747            .persisted_entry_count
1748            .load(Ordering::SeqCst)
1749            .min(self.entries.len());
1750        let previous_mode = self.v2_resume_mode;
1751
1752        let use_jsonl_rehydration = self
1753            .path
1754            .as_ref()
1755            .is_some_and(|path| self.v2_sidecar_stale || is_v2_sidecar_stale(path, &v2_root));
1756        let (fully_hydrated, diagnostics, rehydration_source) = if use_jsonl_rehydration {
1757            let path = self.path.clone().ok_or_else(|| {
1758                Error::session("missing JSONL path while rehydrating stale V2 session")
1759            })?;
1760            let (session, diagnostics) = open_jsonl_blocking(path)?;
1761            (session, diagnostics, "jsonl")
1762        } else {
1763            let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
1764            let (session, diagnostics) =
1765                Self::open_from_v2(&store, self.header.clone(), V2OpenMode::Full)?;
1766            (session, diagnostics, "v2")
1767        };
1768        if !diagnostics.skipped_entries.is_empty() || !diagnostics.orphaned_parent_links.is_empty()
1769        {
1770            tracing::error!(
1771                skipped_entries = diagnostics.skipped_entries.len(),
1772                orphaned_parent_links = diagnostics.orphaned_parent_links.len(),
1773                rehydration_source,
1774                "full V2 rehydration before save failed integrity check; aborting save to prevent data loss"
1775            );
1776            return Err(Error::session(format!(
1777                "V2 rehydration failed with {} skipped entries and {} orphaned links",
1778                diagnostics.skipped_entries.len(),
1779                diagnostics.orphaned_parent_links.len()
1780            )));
1781        }
1782
1783        // Extract pending in-memory entries by moving them out of `self.entries`
1784        // only after full hydration succeeds, preserving fail-safe behavior on
1785        // early-return errors and avoiding per-entry clone cost.
1786        let pending_entries = if pending_start >= self.entries.len() {
1787            Vec::new()
1788        } else {
1789            self.entries.split_off(pending_start)
1790        };
1791
1792        let persisted_entry_count = fully_hydrated.entries.len();
1793        let mut merged_entries = fully_hydrated.entries;
1794        merged_entries.extend(pending_entries);
1795
1796        let finalized = finalize_loaded_entries(&mut merged_entries);
1797        self.entries = merged_entries;
1798        self.leaf_id = finalized.leaf_id;
1799        self.entry_ids = finalized.entry_ids;
1800        self.is_linear = finalized.is_linear;
1801        self.entry_index = finalized.entry_index;
1802        self.cached_message_count = finalized.message_count;
1803        self.cached_name = finalized.name;
1804        self.persisted_entry_count
1805            .store(persisted_entry_count, Ordering::SeqCst);
1806        self.v2_partial_hydration = false;
1807        self.v2_resume_mode = Some(V2OpenMode::Full);
1808        self.v2_sidecar_stale = false;
1809        self.v2_message_count_offset = 0;
1810
1811        tracing::debug!(
1812            previous_mode = ?previous_mode,
1813            rehydration_source,
1814            persisted_entry_count,
1815            pending_entries = self.entries.len().saturating_sub(persisted_entry_count),
1816            "fully rehydrated V2 session before save"
1817        );
1818
1819        Ok(())
1820    }
1821
1822    /// Returns `true` when a full rewrite is required instead of incremental append.
1823    fn should_full_rewrite(&self) -> bool {
1824        let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
1825
1826        // First save — no file exists yet.
1827        if persisted_count == 0 {
1828            return true;
1829        }
1830        // If the backing file disappeared between saves, recover by rewriting
1831        // the full in-memory session instead of attempting an append.
1832        if self
1833            .path
1834            .as_ref()
1835            .is_some_and(|path| path.try_exists().is_ok_and(|exists| !exists))
1836        {
1837            return true;
1838        }
1839        // Header was modified since last save.
1840        if self.header_dirty {
1841            return true;
1842        }
1843        // Periodic checkpoint to clean up accumulated partial writes.
1844        if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
1845            return true;
1846        }
1847        // Defensive: if persisted count somehow exceeds entries, force full rewrite.
1848        if persisted_count > self.entries.len() {
1849            return true;
1850        }
1851        false
1852    }
1853
1854    /// Save the session to disk.
1855    #[allow(clippy::too_many_lines)]
1856    async fn save_inner(&mut self) -> Result<()> {
1857        self.ensure_entry_ids();
1858
1859        let store_kind = match self
1860            .path
1861            .as_ref()
1862            .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
1863        {
1864            Some("jsonl") => SessionStoreKind::Jsonl,
1865            Some("sqlite") => {
1866                #[cfg(feature = "sqlite-sessions")]
1867                {
1868                    SessionStoreKind::Sqlite
1869                }
1870
1871                #[cfg(not(feature = "sqlite-sessions"))]
1872                {
1873                    return Err(Error::session(
1874                        "SQLite session files require building with `--features sqlite-sessions`",
1875                    ));
1876                }
1877            }
1878            _ => self.store_kind,
1879        };
1880
1881        if self.path.is_none() {
1882            // Create a new path
1883            let base_dir = self
1884                .session_dir
1885                .clone()
1886                .unwrap_or_else(Config::sessions_dir);
1887            let cwd = if self.header.cwd.trim().is_empty() {
1888                std::env::current_dir()?
1889            } else {
1890                let configured_cwd = PathBuf::from(self.header.cwd.trim());
1891                if configured_cwd.is_absolute() {
1892                    configured_cwd
1893                } else {
1894                    std::env::current_dir()?.join(configured_cwd)
1895                }
1896            };
1897            let encoded_cwd = encode_cwd(&cwd);
1898            let project_session_dir = base_dir.join(&encoded_cwd);
1899
1900            asupersync::fs::create_dir_all(&project_session_dir).await?;
1901
1902            let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ");
1903            // Robust against malformed/legacy session ids: keep a short, filename-safe suffix.
1904            let short_id = {
1905                let prefix: String = self
1906                    .header
1907                    .id
1908                    .chars()
1909                    .take(8)
1910                    .map(|ch| {
1911                        if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1912                            ch
1913                        } else {
1914                            '_'
1915                        }
1916                    })
1917                    .collect();
1918                if prefix.trim_matches('_').is_empty() {
1919                    "session".to_string()
1920                } else {
1921                    prefix
1922                }
1923            };
1924            let filename = format!("{}_{}.{}", timestamp, short_id, store_kind.extension());
1925            self.path = Some(project_session_dir.join(filename));
1926        }
1927
1928        // Persist a repaired id for legacy or manually corrupted in-memory headers.
1929        // The filename fallback above still keeps empty ids on-disk-path-safe.
1930        if self.header.id.trim().is_empty() {
1931            self.header.id = uuid::Uuid::new_v4().to_string();
1932            self.header_dirty = true;
1933        }
1934        let desired_leaf_override = self.persisted_leaf_override();
1935        if self.header.current_leaf != desired_leaf_override {
1936            self.header.current_leaf = desired_leaf_override;
1937            self.header_dirty = true;
1938        }
1939        self.header
1940            .validate()
1941            .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
1942
1943        let session_dir_clone = self.session_dir.clone();
1944        let path = self.path.clone().ok_or_else(|| {
1945            Error::session("Session path not set - cannot save session".to_string())
1946        })?;
1947        let path_clone = path.clone();
1948
1949        match store_kind {
1950            SessionStoreKind::Jsonl => {
1951                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
1952
1953                if self.should_full_rewrite() {
1954                    if self.v2_partial_hydration {
1955                        self.ensure_full_v2_hydration_before_save()?;
1956                    }
1957                    // Gap C: use incrementally maintained stats instead of O(n) scan.
1958                    // === Full rewrite path (first save, header change, checkpoint) ===
1959                    let header_snapshot = self.header.clone();
1960                    let entries_to_save = self.entries.clone();
1961                    let persisted_entry_count = self.persisted_entry_count.load(Ordering::SeqCst);
1962                    let header_dirty = self.header_dirty;
1963                    let path_for_task = path_clone.clone();
1964                    let sessions_root_for_task = sessions_root.clone();
1965                    let (saved_header, saved_entries) =
1966                        asupersync::runtime::spawn_blocking(move || {
1967                            save_jsonl_full_rewrite_blocking(
1968                                &path_for_task,
1969                                &sessions_root_for_task,
1970                                &header_snapshot,
1971                                &entries_to_save,
1972                                persisted_entry_count,
1973                                header_dirty,
1974                            )
1975                        })
1976                        .await?;
1977
1978                    let previous_leaf = self.leaf_id.clone();
1979                    self.header = saved_header;
1980                    self.entries = saved_entries;
1981                    let finalized = finalize_loaded_entries(&mut self.entries);
1982                    self.entry_ids = finalized.entry_ids;
1983                    self.entry_index = finalized.entry_index;
1984                    self.cached_message_count = finalized
1985                        .message_count
1986                        .saturating_add(self.v2_message_count_offset);
1987                    self.cached_name = finalized.name;
1988                    self.leaf_id = previous_leaf
1989                        .filter(|id| self.entry_index.contains_key(id))
1990                        .or_else(|| finalized.leaf_id.clone());
1991                    self.is_linear = finalized.is_linear && self.leaf_id == finalized.leaf_id;
1992                    self.persisted_entry_count
1993                        .store(self.entries.len(), Ordering::SeqCst);
1994                    self.header_dirty = false;
1995                    self.appends_since_checkpoint = 0;
1996                    self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
1997                } else {
1998                    let message_count = self.cached_message_count;
1999                    // === Incremental append path ===
2000                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2001                    if new_start < self.entries.len() {
2002                        let session_name = self.cached_name.clone();
2003                        // Pre-serialize new entries into a single buffer (typically 1-3 entries).
2004                        let new_entries = &self.entries[new_start..];
2005                        // Scale buffer reservation from observed on-disk average entry size to
2006                        // avoid repeated growth/copy when appending large entries.
2007                        let estimated_entry_bytes = asupersync::fs::metadata(&path_clone)
2008                            .await
2009                            .ok()
2010                            .and_then(|meta| usize::try_from(meta.len()).ok())
2011                            .map_or(512, |file_bytes| {
2012                                let avg = file_bytes / new_start.max(1);
2013                                avg.clamp(512, 256 * 1024)
2014                            });
2015                        let mut serialized_buf = Vec::with_capacity(
2016                            new_entries
2017                                .len()
2018                                .saturating_mul(estimated_entry_bytes.saturating_add(1)),
2019                        );
2020                        for entry in new_entries {
2021                            serde_json::to_writer(&mut serialized_buf, entry)?;
2022                            serialized_buf.push(b'\n');
2023                        }
2024                        let new_count = self.entries.len();
2025
2026                        let header_snapshot = self.header.clone();
2027                        let path_for_task = path_clone.clone();
2028                        let sessions_root_for_task = sessions_root.clone();
2029                        asupersync::runtime::spawn_blocking(move || {
2030                            append_jsonl_entries_blocking(
2031                                &path_for_task,
2032                                &sessions_root_for_task,
2033                                &header_snapshot,
2034                                &serialized_buf,
2035                                message_count,
2036                                session_name,
2037                            )
2038                        })
2039                        .await?;
2040
2041                        self.persisted_entry_count
2042                            .store(new_count, Ordering::SeqCst);
2043                        self.appends_since_checkpoint += 1;
2044                        self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
2045                    }
2046                    // No new entries → no-op, nothing to write.
2047                }
2048            }
2049            #[cfg(feature = "sqlite-sessions")]
2050            SessionStoreKind::Sqlite => {
2051                let message_count = self.cached_message_count;
2052                let session_name = self.cached_name.clone();
2053
2054                if self.should_full_rewrite() {
2055                    // === Full rewrite path (first save, header change, checkpoint) ===
2056                    crate::session_sqlite::save_session(&path_clone, &self.header, &self.entries)
2057                        .await?;
2058                    self.persisted_entry_count
2059                        .store(self.entries.len(), Ordering::SeqCst);
2060                    self.header_dirty = false;
2061                    self.appends_since_checkpoint = 0;
2062                } else {
2063                    // === Incremental append path ===
2064                    let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2065                    if new_start < self.entries.len() {
2066                        crate::session_sqlite::append_entries(
2067                            &path_clone,
2068                            &self.entries[new_start..],
2069                            new_start,
2070                            message_count,
2071                            session_name.as_deref(),
2072                        )
2073                        .await?;
2074                        self.persisted_entry_count
2075                            .store(self.entries.len(), Ordering::SeqCst);
2076                        self.appends_since_checkpoint += 1;
2077                    }
2078                    // No new entries → no-op, nothing to write.
2079                }
2080
2081                let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
2082                enqueue_session_index_snapshot_update(
2083                    &sessions_root,
2084                    &path_clone,
2085                    &self.header,
2086                    message_count,
2087                    session_name,
2088                );
2089            }
2090        }
2091        Ok(())
2092    }
2093
2094    const fn enqueue_autosave_mutation(&mut self, kind: AutosaveMutationKind) {
2095        self.autosave_queue.enqueue_mutation(kind);
2096    }
2097
2098    fn latest_model_change_for_current_path(&self) -> Option<(String, String)> {
2099        for entry in self.entries_for_current_path().iter().rev() {
2100            if let SessionEntry::ModelChange(change) = entry {
2101                return Some((change.provider.clone(), change.model_id.clone()));
2102            }
2103        }
2104        None
2105    }
2106
2107    fn latest_thinking_level_for_current_path(&self) -> Option<String> {
2108        for entry in self.entries_for_current_path().iter().rev() {
2109            if let SessionEntry::ThinkingLevelChange(change) = entry {
2110                return Some(change.thinking_level.clone());
2111            }
2112        }
2113        None
2114    }
2115
2116    pub fn effective_model_for_current_path(&self) -> Option<(String, String)> {
2117        // If there's an explicit model change on the current path, use it
2118        if let Some(model) = self.latest_model_change_for_current_path() {
2119            return Some(model);
2120        }
2121
2122        // If other branches have model changes, we only inherit EXPLICIT fallbacks.
2123        // We do NOT inherit `self.header.provider` because that reflects another branch's tip.
2124        if self.has_any_model_change() {
2125            return self
2126                .header
2127                .fallback_provider
2128                .clone()
2129                .zip(self.header.fallback_model_id.clone());
2130        }
2131
2132        self.header
2133            .provider
2134            .clone()
2135            .zip(self.header.model_id.clone())
2136    }
2137
2138    pub fn effective_thinking_level_for_current_path(&self) -> Option<String> {
2139        // If there's an explicit thinking level change on the current path, use it
2140        if let Some(level) = self.latest_thinking_level_for_current_path() {
2141            return Some(level);
2142        }
2143
2144        // If other branches have thinking level changes, we only inherit EXPLICIT fallbacks.
2145        // We do NOT inherit `self.header.thinking_level` because that reflects another branch's tip.
2146        if self.has_any_thinking_level_change() {
2147            return self.header.fallback_thinking_level.clone();
2148        }
2149
2150        self.header.thinking_level.clone()
2151    }
2152
2153    fn has_any_model_change(&self) -> bool {
2154        self.entries
2155            .iter()
2156            .any(|entry| matches!(entry, SessionEntry::ModelChange(_)))
2157    }
2158
2159    fn has_any_thinking_level_change(&self) -> bool {
2160        self.entries
2161            .iter()
2162            .any(|entry| matches!(entry, SessionEntry::ThinkingLevelChange(_)))
2163    }
2164
2165    fn persisted_leaf_override(&self) -> Option<String> {
2166        if self.entries.is_empty() {
2167            return None;
2168        }
2169
2170        match (
2171            self.leaf_id.as_deref(),
2172            self.entries
2173                .last()
2174                .and_then(SessionEntry::base_id)
2175                .map(String::as_str),
2176        ) {
2177            (None, _) => Some(ROOT_LEAF_OVERRIDE_SENTINEL.to_string()),
2178            (Some(current), Some(natural_tip)) if current == natural_tip => None,
2179            (Some(current), _) => Some(current.to_string()),
2180        }
2181    }
2182
2183    fn sync_navigation_state_to_header(&mut self) {
2184        let mut changed = false;
2185
2186        let desired_leaf_override = self.persisted_leaf_override();
2187        if self.header.current_leaf != desired_leaf_override {
2188            self.header.current_leaf = desired_leaf_override;
2189            changed = true;
2190        }
2191
2192        if let Some((provider, model_id)) = self.effective_model_for_current_path() {
2193            if self.header.provider.as_deref() != Some(provider.as_str())
2194                || self.header.model_id.as_deref() != Some(model_id.as_str())
2195            {
2196                self.header.provider = Some(provider);
2197                self.header.model_id = Some(model_id);
2198                changed = true;
2199            }
2200        } else if self.has_any_model_change()
2201            && (self.header.provider.is_some() || self.header.model_id.is_some())
2202        {
2203            self.header.provider = None;
2204            self.header.model_id = None;
2205            changed = true;
2206        }
2207
2208        if let Some(thinking_level) = self.effective_thinking_level_for_current_path() {
2209            if self.header.thinking_level.as_deref() != Some(thinking_level.as_str()) {
2210                self.header.thinking_level = Some(thinking_level);
2211                changed = true;
2212            }
2213        } else if self.has_any_thinking_level_change() && self.header.thinking_level.is_some() {
2214            self.header.thinking_level = None;
2215            changed = true;
2216        }
2217
2218        if changed {
2219            self.header_dirty = true;
2220            self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2221        }
2222    }
2223
2224    fn clear_persisted_leaf_override_after_append(&mut self) {
2225        let desired_leaf_override = self.persisted_leaf_override();
2226        if self.header.current_leaf != desired_leaf_override {
2227            self.header.current_leaf = desired_leaf_override;
2228            self.header_dirty = true;
2229        }
2230    }
2231
2232    /// Append a session message entry.
2233    pub fn append_message(&mut self, message: SessionMessage) -> String {
2234        let id = self.next_entry_id();
2235        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2236        let entry = SessionEntry::Message(MessageEntry { base, message });
2237        self.leaf_id = Some(id.clone());
2238        self.entries.push(entry);
2239        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2240        self.entry_ids.insert(id.clone());
2241        self.cached_message_count += 1;
2242        self.clear_persisted_leaf_override_after_append();
2243        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2244        id
2245    }
2246
2247    /// Append a message from the model message types.
2248    pub fn append_model_message(&mut self, message: Message) -> String {
2249        self.append_message(SessionMessage::from(message))
2250    }
2251
2252    pub fn append_model_change(&mut self, provider: String, model_id: String) -> String {
2253        let id = self.next_entry_id();
2254        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2255        let entry = SessionEntry::ModelChange(ModelChangeEntry {
2256            base,
2257            provider,
2258            model_id,
2259        });
2260        self.leaf_id = Some(id.clone());
2261        self.entries.push(entry);
2262        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2263        self.entry_ids.insert(id.clone());
2264        self.clear_persisted_leaf_override_after_append();
2265        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2266        id
2267    }
2268
2269    pub fn append_thinking_level_change(&mut self, thinking_level: String) -> String {
2270        let id = self.next_entry_id();
2271        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2272        let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
2273            base,
2274            thinking_level,
2275        });
2276        self.leaf_id = Some(id.clone());
2277        self.entries.push(entry);
2278        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2279        self.entry_ids.insert(id.clone());
2280        self.clear_persisted_leaf_override_after_append();
2281        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2282        id
2283    }
2284
2285    pub fn append_session_info(&mut self, name: Option<String>) -> String {
2286        let id = self.next_entry_id();
2287        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2288        if name.is_some() {
2289            self.cached_name.clone_from(&name);
2290        }
2291        let entry = SessionEntry::SessionInfo(SessionInfoEntry { base, name });
2292        self.leaf_id = Some(id.clone());
2293        self.entries.push(entry);
2294        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2295        self.entry_ids.insert(id.clone());
2296        self.clear_persisted_leaf_override_after_append();
2297        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2298        id
2299    }
2300
2301    /// Append a custom entry (extension state, etc).
2302    pub fn append_custom_entry(
2303        &mut self,
2304        custom_type: String,
2305        data: Option<serde_json::Value>,
2306    ) -> String {
2307        let id = self.next_entry_id();
2308        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2309        let entry = SessionEntry::Custom(CustomEntry {
2310            base,
2311            custom_type,
2312            data,
2313        });
2314        self.leaf_id = Some(id.clone());
2315        self.entries.push(entry);
2316        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2317        self.entry_ids.insert(id.clone());
2318        self.clear_persisted_leaf_override_after_append();
2319        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2320        id
2321    }
2322
2323    pub fn append_bash_execution(
2324        &mut self,
2325        command: String,
2326        output: String,
2327        exit_code: i32,
2328        cancelled: bool,
2329        truncated: bool,
2330        full_output_path: Option<String>,
2331    ) -> String {
2332        let id = self.next_entry_id();
2333        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2334        let entry = SessionEntry::Message(MessageEntry {
2335            base,
2336            message: SessionMessage::BashExecution {
2337                command,
2338                output,
2339                exit_code,
2340                cancelled: Some(cancelled),
2341                truncated: Some(truncated),
2342                full_output_path,
2343                timestamp: Some(chrono::Utc::now().timestamp_millis()),
2344                extra: HashMap::new(),
2345            },
2346        });
2347        self.leaf_id = Some(id.clone());
2348        self.entries.push(entry);
2349        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2350        self.entry_ids.insert(id.clone());
2351        self.cached_message_count += 1;
2352        self.clear_persisted_leaf_override_after_append();
2353        self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2354        id
2355    }
2356
2357    /// Get the current session name from the cached value (Gap C).
2358    pub fn get_name(&self) -> Option<String> {
2359        self.cached_name.clone()
2360    }
2361
2362    /// Set the session name by appending a `SessionInfo` entry.
2363    pub fn set_name(&mut self, name: &str) -> String {
2364        self.append_session_info(Some(name.to_string()))
2365    }
2366
2367    pub fn append_compaction(
2368        &mut self,
2369        summary: String,
2370        first_kept_entry_id: String,
2371        tokens_before: u64,
2372        details: Option<Value>,
2373        from_hook: Option<bool>,
2374    ) -> String {
2375        let id = self.next_entry_id();
2376        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2377        let entry = SessionEntry::Compaction(CompactionEntry {
2378            base,
2379            summary,
2380            first_kept_entry_id,
2381            tokens_before,
2382            details,
2383            from_hook,
2384        });
2385        self.leaf_id = Some(id.clone());
2386        self.entries.push(entry);
2387        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2388        self.entry_ids.insert(id.clone());
2389        self.clear_persisted_leaf_override_after_append();
2390        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2391        id
2392    }
2393
2394    pub fn append_branch_summary(
2395        &mut self,
2396        from_id: String,
2397        summary: String,
2398        details: Option<Value>,
2399        from_hook: Option<bool>,
2400    ) -> String {
2401        let id = self.next_entry_id();
2402        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2403        let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
2404            base,
2405            from_id,
2406            summary,
2407            details,
2408            from_hook,
2409        });
2410        self.leaf_id = Some(id.clone());
2411        self.entries.push(entry);
2412        self.entry_index.insert(id.clone(), self.entries.len() - 1);
2413        self.entry_ids.insert(id.clone());
2414        self.clear_persisted_leaf_override_after_append();
2415        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2416        id
2417    }
2418
2419    pub fn ensure_entry_ids(&mut self) {
2420        // `rebuild_all_caches()` runs `finalize_loaded_entries()`, which already
2421        // fills missing entry IDs and rebuilds all derived caches in one pass.
2422        self.rebuild_all_caches();
2423    }
2424
2425    /// Rebuild all derived caches from `self.entries`.
2426    ///
2427    /// Called after bulk mutations (save round-trip, ensure_entry_ids) where
2428    /// incremental maintenance is impractical.
2429    fn rebuild_all_caches(&mut self) {
2430        let finalized = finalize_loaded_entries(&mut self.entries);
2431        self.entry_ids = finalized.entry_ids;
2432        self.entry_index = finalized.entry_index;
2433        self.cached_message_count = finalized
2434            .message_count
2435            .saturating_add(self.v2_message_count_offset);
2436        self.cached_name = finalized.name;
2437        // is_linear requires BOTH: no branching in the entry tree AND the
2438        // current leaf_id pointing at the last entry.  If the user navigated
2439        // to a mid-chain entry before saving, the leaf differs from the tip
2440        // and the fast path would return wrong results.
2441        self.is_linear = finalized.is_linear && self.leaf_id == finalized.leaf_id;
2442    }
2443
2444    /// Convert session entries to model messages (for provider context).
2445    pub fn to_messages(&self) -> Vec<Message> {
2446        let mut messages = Vec::new();
2447        for entry in &self.entries {
2448            if let SessionEntry::Message(msg_entry) = entry {
2449                if let Some(message) = session_message_to_model(&msg_entry.message) {
2450                    messages.push(message);
2451                }
2452            }
2453        }
2454        messages
2455    }
2456
2457    /// Render the session as a standalone HTML document.
2458    ///
2459    /// Delegates to `render_session_html()` for the actual rendering. For
2460    /// non-blocking export, prefer `export_snapshot().to_html()` which avoids
2461    /// cloning internal caches.
2462    pub fn to_html(&self) -> String {
2463        render_session_html(&self.header, &self.entries)
2464    }
2465
2466    /// Update header model info.
2467    pub fn set_model_header(
2468        &mut self,
2469        provider: Option<String>,
2470        model_id: Option<String>,
2471        thinking_level: Option<String>,
2472    ) {
2473        let changed = provider.is_some() || model_id.is_some() || thinking_level.is_some();
2474        if provider.is_some() {
2475            self.header.provider = provider;
2476        }
2477        if model_id.is_some() {
2478            self.header.model_id = model_id;
2479        }
2480        if thinking_level.is_some() {
2481            self.header.thinking_level = thinking_level;
2482        }
2483        if changed {
2484            self.header_dirty = true;
2485            self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2486        }
2487    }
2488
2489    pub fn set_branched_from(&mut self, path: Option<String>) {
2490        self.header.parent_session = path;
2491        self.header_dirty = true;
2492        self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2493    }
2494
2495    /// Create a lightweight snapshot for non-blocking HTML export.
2496    ///
2497    /// Captures only the fields needed by `to_html()` (header, entries, path),
2498    /// avoiding a full `Session::clone()` which includes caches, autosave queues,
2499    /// persistence state, and other internal bookkeeping.
2500    pub fn export_snapshot(&self) -> ExportSnapshot {
2501        ExportSnapshot {
2502            header: self.header.clone(),
2503            entries: self.entries.clone(),
2504            path: self.path.clone(),
2505        }
2506    }
2507
2508    /// Plan a `/fork` from a user message entry ID.
2509    ///
2510    /// Returns the entries to copy into a new session (path to the parent of the selected
2511    /// user message), the new leaf id, and the selected user message text for editor pre-fill.
2512    pub fn plan_fork_from_user_message(&self, entry_id: &str) -> Result<ForkPlan> {
2513        let entry = self
2514            .get_entry(entry_id)
2515            .ok_or_else(|| Error::session(format!("Fork target not found: {entry_id}")))?;
2516
2517        let SessionEntry::Message(message_entry) = entry else {
2518            return Err(Error::session(format!(
2519                "Fork target is not a message entry: {entry_id}"
2520            )));
2521        };
2522
2523        let SessionMessage::User { content, .. } = &message_entry.message else {
2524            return Err(Error::session(format!(
2525                "Fork target is not a user message: {entry_id}"
2526            )));
2527        };
2528
2529        let selected_text = user_content_to_text(content);
2530        let leaf_id = message_entry.base.parent_id.clone();
2531
2532        let entries = if let Some(ref leaf_id) = leaf_id {
2533            if self.is_linear {
2534                let idx = self.entry_index.get(leaf_id).copied().ok_or_else(|| {
2535                    Error::session(format!("Failed to build fork: missing entry {leaf_id}"))
2536                })?;
2537                self.entries[..=idx].to_vec()
2538            } else {
2539                let path_ids = self.get_path_to_entry(leaf_id);
2540                let mut entries = Vec::new();
2541                for path_id in path_ids {
2542                    let entry = self.get_entry(&path_id).ok_or_else(|| {
2543                        Error::session(format!("Failed to build fork: missing entry {path_id}"))
2544                    })?;
2545                    entries.push(entry.clone());
2546                }
2547                entries
2548            }
2549        } else {
2550            Vec::new()
2551        };
2552
2553        Ok(ForkPlan {
2554            entries,
2555            leaf_id,
2556            selected_text,
2557        })
2558    }
2559
2560    fn next_entry_id(&self) -> String {
2561        let use_entry_id_cache = session_entry_id_cache_enabled();
2562
2563        if use_entry_id_cache {
2564            // Use the cached set for O(1) collision checks.
2565            // generate_entry_id handles generation + collision retry logic.
2566            generate_entry_id(&self.entry_ids)
2567        } else {
2568            // Fallback: scan entries to build the exclusion set on demand.
2569            // This is slower (O(N)) but only used if the cache feature flag is disabled.
2570            let existing = entry_id_set(&self.entries);
2571            generate_entry_id(&existing)
2572        }
2573    }
2574
2575    // ========================================================================
2576    // Tree Navigation
2577    // ========================================================================
2578
2579    /// Build a map from parent ID to children IDs.
2580    fn build_children_map(&self) -> HashMap<Option<String>, Vec<String>> {
2581        let mut children: HashMap<Option<String>, Vec<String>> =
2582            HashMap::with_capacity(self.entries.len());
2583        for entry in &self.entries {
2584            if let Some(id) = entry.base_id() {
2585                children
2586                    .entry(entry.base().parent_id.clone())
2587                    .or_default()
2588                    .push(id.clone());
2589            }
2590        }
2591        children
2592    }
2593
2594    /// Get the path from an entry back to the root (inclusive).
2595    /// Returns entry IDs in order from root to the specified entry.
2596    pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
2597        // Fast path: in linear sessions, every ancestor chain is a prefix of `entries`.
2598        if self.is_linear {
2599            if let Some(&idx) = self.entry_index.get(entry_id) {
2600                let mut path = Vec::with_capacity(idx + 1);
2601                for entry in &self.entries[..=idx] {
2602                    if let Some(id) = entry.base_id() {
2603                        path.push(id.clone());
2604                    }
2605                }
2606                return path;
2607            }
2608        }
2609
2610        let mut path = Vec::new();
2611        let mut visited = std::collections::HashSet::with_capacity(self.entries.len().min(128));
2612        let mut current = Some(entry_id.to_string());
2613
2614        while let Some(id) = current {
2615            if !visited.insert(id.clone()) {
2616                tracing::warn!(
2617                    "Cycle detected in session tree while building ancestor path at entry: {id}"
2618                );
2619                break;
2620            }
2621            path.push(id.clone());
2622            current = self
2623                .get_entry(&id)
2624                .and_then(|entry| entry.base().parent_id.clone());
2625        }
2626
2627        path.reverse();
2628        path
2629    }
2630
2631    /// Get direct children of an entry.
2632    pub fn get_children(&self, entry_id: Option<&str>) -> Vec<String> {
2633        self.entries
2634            .iter()
2635            .filter_map(|entry| {
2636                let id = entry.base_id()?;
2637                if entry.base().parent_id.as_deref() == entry_id {
2638                    Some(id.clone())
2639                } else {
2640                    None
2641                }
2642            })
2643            .collect()
2644    }
2645
2646    /// List all leaf nodes (entries with no children).
2647    pub fn list_leaves(&self) -> Vec<String> {
2648        let mut has_children: HashSet<&str> = HashSet::with_capacity(self.entries.len());
2649        for entry in &self.entries {
2650            if let Some(parent_id) = entry.base().parent_id.as_deref() {
2651                has_children.insert(parent_id);
2652            }
2653        }
2654
2655        self.entries
2656            .iter()
2657            .filter_map(|e| {
2658                let id = e.base_id()?;
2659                if has_children.contains(id.as_str()) {
2660                    None
2661                } else {
2662                    Some(id.clone())
2663                }
2664            })
2665            .collect()
2666    }
2667
2668    /// Navigate to a specific entry, making it the current leaf.
2669    /// Returns true if the entry exists.
2670    pub fn navigate_to(&mut self, entry_id: &str) -> bool {
2671        // Gap B: O(1) existence check via entry_index.
2672        let exists = self.entry_index.contains_key(entry_id);
2673        if exists {
2674            // Gap A: navigating away from the tip breaks linearity.
2675            let is_tip = self
2676                .entries
2677                .last()
2678                .and_then(|e| e.base_id())
2679                .is_some_and(|id| id == entry_id);
2680            if !is_tip {
2681                self.is_linear = false;
2682            }
2683            self.leaf_id = Some(entry_id.to_string());
2684            self.sync_header_to_current_path_metadata();
2685            true
2686        } else {
2687            false
2688        }
2689    }
2690
2691    /// Get the current leaf entry ID.
2692    pub fn leaf_id(&self) -> Option<&str> {
2693        self.leaf_id.as_deref()
2694    }
2695
2696    /// Initialize the session entries and leaf from a `ForkPlan`.
2697    ///
2698    /// This safely applies the new entries and leaf, and rebuilds
2699    /// all internal caches (including the `is_linear` optimization flag).
2700    pub fn init_from_fork_plan(&mut self, plan: ForkPlan) {
2701        self.entries = plan.entries;
2702        self.leaf_id = plan.leaf_id;
2703        self.rebuild_all_caches();
2704        self.sync_navigation_state_to_header();
2705    }
2706
2707    /// Set the leaf ID directly (for tests only).
2708    pub fn _test_set_leaf_id(&mut self, id: Option<String>) {
2709        self.leaf_id = id;
2710        self.rebuild_all_caches();
2711        self.sync_navigation_state_to_header();
2712    }
2713
2714    fn sync_header_to_current_path_metadata(&mut self) {
2715        self.sync_navigation_state_to_header();
2716    }
2717
2718    /// Revert the last user message on the current path, effectively abandoning it.
2719    /// This is used during API retries to prevent duplicating the user prompt in the session history.
2720    pub fn revert_last_user_message(&mut self) -> bool {
2721        let mut current_id = self.leaf_id.clone();
2722        let mut reverted_any = false;
2723
2724        while let Some(id) = current_id {
2725            if let Some(entry) = self.get_entry(&id) {
2726                let parent_id = entry.base().parent_id.clone();
2727                let is_user = if let SessionEntry::Message(msg_entry) = entry {
2728                    matches!(msg_entry.message, SessionMessage::User { .. })
2729                } else {
2730                    false
2731                };
2732
2733                self.leaf_id.clone_from(&parent_id);
2734                self.is_linear = false;
2735                reverted_any = true;
2736
2737                if is_user {
2738                    // We found and reverted the user message, we can stop walking back.
2739                    break;
2740                }
2741
2742                current_id = parent_id;
2743            } else {
2744                break;
2745            }
2746        }
2747        if reverted_any {
2748            self.sync_navigation_state_to_header();
2749        }
2750        reverted_any
2751    }
2752
2753    /// Reset the leaf pointer to root (before any entries).
2754    ///
2755    /// After calling this, the next appended entry will become a new root entry
2756    /// (`parent_id = None`). This is used by interactive `/tree` navigation when
2757    /// re-editing the first user message.
2758    pub fn reset_leaf(&mut self) {
2759        self.leaf_id = None;
2760        self.is_linear = false;
2761        self.sync_navigation_state_to_header();
2762    }
2763
2764    /// Create a new branch starting from a specific entry.
2765    /// Sets the leaf_id to the specified entry so new entries branch from there.
2766    /// Returns true if the entry exists.
2767    pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
2768        self.navigate_to(entry_id)
2769    }
2770
2771    /// Get the entry at a specific ID (Gap B: O(1) via `entry_index`).
2772    pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
2773        self.entry_index
2774            .get(entry_id)
2775            .and_then(|&idx| self.entries.get(idx))
2776    }
2777
2778    /// Get the entry at a specific ID, mutable (Gap B: O(1) via `entry_index`).
2779    pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
2780        self.entry_index
2781            .get(entry_id)
2782            .copied()
2783            .and_then(|idx| self.entries.get_mut(idx))
2784    }
2785
2786    /// Entries along the current leaf path, in chronological order.
2787    ///
2788    /// Gap A: when `is_linear` is true (the 99% case — no branching has
2789    /// occurred), this returns all entries directly without building a
2790    /// parent map or tracing the path.
2791    pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
2792        let Some(leaf_id) = &self.leaf_id else {
2793            return Vec::new();
2794        };
2795
2796        // Fast path: linear session — all entries are on the current path.
2797        if self.is_linear {
2798            return self.entries.iter().collect();
2799        }
2800
2801        let mut path_indices = Vec::with_capacity(16);
2802        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
2803        let mut current = Some(leaf_id.clone());
2804
2805        while let Some(id) = current.as_ref() {
2806            if !visited.insert(id.clone()) {
2807                tracing::warn!(
2808                    "Cycle detected in session tree while collecting current path entries at: {id}"
2809                );
2810                break;
2811            }
2812            let Some(&idx) = self.entry_index.get(id.as_str()) else {
2813                break;
2814            };
2815            let Some(entry) = self.entries.get(idx) else {
2816                break;
2817            };
2818            path_indices.push(idx);
2819            current.clone_from(&entry.base().parent_id);
2820        }
2821
2822        path_indices.reverse();
2823        path_indices
2824            .into_iter()
2825            .filter_map(|idx| self.entries.get(idx))
2826            .collect()
2827    }
2828
2829    /// Convert session entries along the current path to model messages.
2830    /// This follows parent_id links from leaf_id back to root.
2831    pub fn to_messages_for_current_path(&self) -> Vec<Message> {
2832        if self.leaf_id.is_none() {
2833            return Vec::new();
2834        }
2835
2836        if self.is_linear {
2837            return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
2838        }
2839
2840        let path_entries = self.entries_for_current_path();
2841        Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
2842    }
2843
2844    fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
2845        match entry {
2846            SessionEntry::Message(msg_entry) => {
2847                if let Some(message) = session_message_to_model(&msg_entry.message) {
2848                    messages.push(message);
2849                }
2850            }
2851            SessionEntry::BranchSummary(summary) => {
2852                let summary_message = SessionMessage::BranchSummary {
2853                    summary: summary.summary.clone(),
2854                    from_id: summary.from_id.clone(),
2855                };
2856                if let Some(message) = session_message_to_model(&summary_message) {
2857                    messages.push(message);
2858                }
2859            }
2860            _ => {}
2861        }
2862    }
2863
2864    fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
2865    where
2866        F: Fn(usize) -> &'a SessionEntry,
2867    {
2868        let mut last_compaction = None;
2869        for idx in (0..path_len).rev() {
2870            if let SessionEntry::Compaction(compaction) = entry_at(idx) {
2871                last_compaction = Some((idx, compaction));
2872                break;
2873            }
2874        }
2875
2876        if let Some((compaction_idx, compaction)) = last_compaction {
2877            let mut messages = Vec::with_capacity(path_len);
2878            let summary_message = SessionMessage::CompactionSummary {
2879                summary: compaction.summary.clone(),
2880                tokens_before: compaction.tokens_before,
2881            };
2882            if let Some(message) = session_message_to_model(&summary_message) {
2883                messages.push(message);
2884            }
2885
2886            let has_kept_entry = (0..path_len).any(|idx| {
2887                entry_at(idx)
2888                    .base_id()
2889                    .is_some_and(|id| id == &compaction.first_kept_entry_id)
2890            });
2891
2892            let mut keep = false;
2893            let mut past_compaction = false;
2894            for idx in 0..path_len {
2895                let entry = entry_at(idx);
2896                if idx == compaction_idx {
2897                    past_compaction = true;
2898                }
2899                if !keep {
2900                    if has_kept_entry {
2901                        if entry
2902                            .base_id()
2903                            .is_some_and(|id| id == &compaction.first_kept_entry_id)
2904                        {
2905                            keep = true;
2906                        } else {
2907                            continue;
2908                        }
2909                    } else if past_compaction {
2910                        tracing::warn!(
2911                            first_kept_entry_id = %compaction.first_kept_entry_id,
2912                            "Compaction references missing entry; including all post-compaction entries"
2913                        );
2914                        keep = true;
2915                    } else {
2916                        continue;
2917                    }
2918                }
2919                Self::append_model_message_for_entry(&mut messages, entry);
2920            }
2921
2922            return messages;
2923        }
2924
2925        let mut messages = Vec::with_capacity(path_len);
2926        for idx in 0..path_len {
2927            Self::append_model_message_for_entry(&mut messages, entry_at(idx));
2928        }
2929        messages
2930    }
2931
2932    /// Find the nearest ancestor that is a fork point (has multiple children)
2933    /// and return its children (sibling branch roots). Each sibling is represented
2934    /// by its branch-root entry ID plus the leaf ID reachable from that root.
2935    ///
2936    /// Returns `(fork_point_id, sibling_leaves)` where each sibling leaf is
2937    /// a leaf entry ID reachable through the fork point's children. The current
2938    /// leaf is included in the list.
2939    pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
2940        let children_map = self.build_children_map();
2941        let leaf_id = self.leaf_id.as_ref()?;
2942        let path = self.get_path_to_entry(leaf_id);
2943        if path.is_empty() {
2944            return None;
2945        }
2946
2947        // Walk backwards from current leaf's path to find the nearest fork point.
2948        // A fork point is any entry whose parent has >1 children, OR None (root)
2949        // with >1 root entries.
2950        // We check each entry's parent to see if the parent has multiple children.
2951        for (idx, entry_id) in path.iter().enumerate().rev() {
2952            let parent_of_entry = self
2953                .get_entry(entry_id)
2954                .and_then(|e| e.base().parent_id.clone());
2955
2956            let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
2957                continue;
2958            };
2959
2960            if siblings_at_parent.len() > 1 {
2961                // This is a fork point. Collect all leaves reachable from each sibling.
2962                let mut branches = Vec::new();
2963                let current_branch_ids: HashSet<&str> =
2964                    path[idx..].iter().map(String::as_str).collect();
2965                for sibling_root in siblings_at_parent {
2966                    let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
2967                    let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
2968                    let is_current = current_branch_ids.contains(sibling_root.as_str());
2969                    branches.push(SiblingBranch {
2970                        root_id: sibling_root.clone(),
2971                        leaf_id: leaf,
2972                        preview,
2973                        message_count: msg_count,
2974                        is_current,
2975                    });
2976                }
2977                return Some((parent_of_entry, branches));
2978            }
2979        }
2980
2981        None
2982    }
2983
2984    /// Follow the first child chain to reach the deepest leaf from a starting entry.
2985    fn deepest_leaf_from(
2986        children_map: &HashMap<Option<String>, Vec<String>>,
2987        start_id: &str,
2988    ) -> String {
2989        let mut current = start_id.to_string();
2990        let mut visited = HashSet::new();
2991        loop {
2992            if !visited.insert(current.clone()) {
2993                tracing::warn!("Cycle detected in session tree at entry: {current}");
2994                return current;
2995            }
2996            let children = children_map.get(&Some(current.clone()));
2997            match children.and_then(|c| c.first()) {
2998                Some(child) => current.clone_from(child),
2999                None => return current,
3000            }
3001        }
3002    }
3003
3004    /// Compute a short preview (first user message on the path) and the number
3005    /// of message entries for a leaf in a single parent-chain walk.
3006    fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
3007        let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3008        let mut current = Some(leaf_id.to_string());
3009        let mut preview = None;
3010        let mut count = 0usize;
3011
3012        while let Some(id) = current.as_ref() {
3013            if !visited.insert(id.clone()) {
3014                tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
3015                break;
3016            }
3017            let Some(entry) = self.get_entry(id.as_str()) else {
3018                break;
3019            };
3020            if matches!(entry, SessionEntry::Message(_)) {
3021                count = count.saturating_add(1);
3022            }
3023            if let SessionEntry::Message(msg) = entry {
3024                if let SessionMessage::User { content, .. } = &msg.message {
3025                    let text = user_content_to_text(content);
3026                    let trimmed = text.trim();
3027                    if !trimmed.is_empty() {
3028                        preview = Some(if trimmed.chars().count() > 60 {
3029                            let truncated: String = trimmed.chars().take(57).collect();
3030                            format!("{truncated}...")
3031                        } else {
3032                            trimmed.to_string()
3033                        });
3034                    }
3035                }
3036            }
3037            current.clone_from(&entry.base().parent_id);
3038        }
3039
3040        (preview.unwrap_or_else(|| String::from("(empty)")), count)
3041    }
3042
3043    /// Get a summary of branches in this session.
3044    pub fn branch_summary(&self) -> BranchInfo {
3045        let leaves = self.list_leaves();
3046        let children_map = self.build_children_map();
3047
3048        // Find branch points (entries with multiple children)
3049        let branch_points: Vec<String> = self
3050            .entries
3051            .iter()
3052            .filter_map(|e| {
3053                let id = e.base_id()?;
3054                let children = children_map.get(&Some(id.clone()))?;
3055                if children.len() > 1 {
3056                    Some(id.clone())
3057                } else {
3058                    None
3059                }
3060            })
3061            .collect();
3062
3063        BranchInfo {
3064            total_entries: self.entries.len(),
3065            leaf_count: leaves.len(),
3066            branch_point_count: branch_points.len(),
3067            current_leaf: self.leaf_id.clone(),
3068            leaves,
3069            branch_points,
3070        }
3071    }
3072
3073    /// Add a label to an entry.
3074    pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
3075        // Verify target exists
3076        self.get_entry(target_id)?;
3077
3078        let id = self.next_entry_id();
3079        let base = EntryBase::new(self.leaf_id.clone(), id.clone());
3080        let entry = SessionEntry::Label(LabelEntry {
3081            base,
3082            target_id: target_id.to_string(),
3083            label,
3084        });
3085        self.leaf_id = Some(id.clone());
3086        self.entries.push(entry);
3087        self.entry_index.insert(id.clone(), self.entries.len() - 1);
3088        self.entry_ids.insert(id.clone());
3089        self.clear_persisted_leaf_override_after_append();
3090        self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
3091        Some(id)
3092    }
3093}
3094
3095/// Summary of branches in a session.
3096#[derive(Debug, Clone)]
3097pub struct BranchInfo {
3098    pub total_entries: usize,
3099    pub leaf_count: usize,
3100    pub branch_point_count: usize,
3101    pub current_leaf: Option<String>,
3102    pub leaves: Vec<String>,
3103    pub branch_points: Vec<String>,
3104}
3105
3106/// A sibling branch at a fork point.
3107#[derive(Debug, Clone)]
3108pub struct SiblingBranch {
3109    /// Entry ID of the branch root (child of the fork point).
3110    pub root_id: String,
3111    /// Leaf entry ID reachable from this branch root.
3112    pub leaf_id: String,
3113    /// Short preview of the first user message on this branch.
3114    pub preview: String,
3115    /// Number of message entries along the path.
3116    pub message_count: usize,
3117    /// Whether the current session leaf is on this branch.
3118    pub is_current: bool,
3119}
3120
3121#[derive(Debug, Clone)]
3122struct SessionPickEntry {
3123    path: PathBuf,
3124    id: String,
3125    cwd: String,
3126    timestamp: String,
3127    message_count: u64,
3128    name: Option<String>,
3129    last_modified_ms: i64,
3130    size_bytes: u64,
3131}
3132
3133impl SessionPickEntry {
3134    fn from_meta(meta: crate::session_index::SessionMeta) -> Self {
3135        Self {
3136            path: PathBuf::from(meta.path),
3137            id: meta.id,
3138            cwd: meta.cwd,
3139            timestamp: meta.timestamp,
3140            message_count: meta.message_count,
3141            name: meta.name,
3142            last_modified_ms: meta.last_modified_ms,
3143            size_bytes: meta.size_bytes,
3144        }
3145    }
3146
3147    fn to_meta(&self) -> crate::session_index::SessionMeta {
3148        crate::session_index::SessionMeta {
3149            path: self.path.display().to_string(),
3150            id: self.id.clone(),
3151            cwd: self.cwd.clone(),
3152            timestamp: self.timestamp.clone(),
3153            message_count: self.message_count,
3154            last_modified_ms: self.last_modified_ms,
3155            size_bytes: self.size_bytes,
3156            name: self.name.clone(),
3157        }
3158    }
3159}
3160
3161fn indexed_session_path_is_missing(path: &Path) -> bool {
3162    match path.try_exists() {
3163        Ok(exists) => !exists,
3164        Err(err) => {
3165            tracing::warn!(
3166                path = %path.display(),
3167                error = %err,
3168                "Failed to determine whether indexed session path exists; deferring prune"
3169            );
3170            false
3171        }
3172    }
3173}
3174
3175fn split_indexed_session_entries(
3176    metas: Vec<crate::session_index::SessionMeta>,
3177) -> (Vec<SessionPickEntry>, Vec<PathBuf>) {
3178    let mut entries = Vec::new();
3179    let mut missing_paths = Vec::new();
3180
3181    for meta in metas {
3182        let path = PathBuf::from(&meta.path);
3183        if indexed_session_path_is_missing(&path) {
3184            missing_paths.push(path);
3185            continue;
3186        }
3187
3188        entries.push(SessionPickEntry::from_meta(meta));
3189    }
3190
3191    (entries, missing_paths)
3192}
3193
3194fn prune_session_index_path(index: &SessionIndex, path: &Path, reason: &'static str) {
3195    if let Err(err) = index.delete_session_path(path) {
3196        tracing::warn!(
3197            path = %path.display(),
3198            error = %err,
3199            reason,
3200            "Failed to prune session from index"
3201        );
3202    }
3203}
3204
3205const fn can_reuse_known_entry(
3206    known_entry: &SessionPickEntry,
3207    disk_ms: i64,
3208    disk_size: u64,
3209) -> bool {
3210    known_entry.last_modified_ms == disk_ms && known_entry.size_bytes == disk_size
3211}
3212
3213struct ScanSessionsResult {
3214    entries: Vec<SessionPickEntry>,
3215    refreshed_entries: Vec<SessionPickEntry>,
3216    failed_paths: Vec<PathBuf>,
3217}
3218
3219fn refresh_session_index_entries(
3220    index: &SessionIndex,
3221    entries: &[SessionPickEntry],
3222    reason: &'static str,
3223) {
3224    for entry in entries {
3225        if let Err(err) = index.upsert_session_meta(entry.to_meta()) {
3226            tracing::warn!(
3227                path = %entry.path.display(),
3228                error = %err,
3229                reason,
3230                "Failed to refresh session metadata in index"
3231            );
3232        }
3233    }
3234}
3235
3236fn merge_scanned_session_entries(
3237    by_path: &mut HashMap<PathBuf, SessionPickEntry>,
3238    entries: Vec<SessionPickEntry>,
3239) {
3240    for entry in entries {
3241        // Disk is the source of truth for session metadata. The scan either
3242        // reparsed the file or confirmed the cached snapshot still matches, so
3243        // it should always win over the earlier index view for that path.
3244        by_path.insert(entry.path.clone(), entry);
3245    }
3246}
3247
3248async fn scan_sessions_on_disk(
3249    project_session_dir: &Path,
3250    known: Vec<SessionPickEntry>,
3251) -> Result<ScanSessionsResult> {
3252    let path_buf = project_session_dir.to_path_buf();
3253    let (tx, mut rx) = oneshot::channel();
3254
3255    let handle = thread::Builder::new()
3256        .name("session-scan".to_string())
3257        .spawn(move || {
3258            let res = (|| -> Result<ScanSessionsResult> {
3259                let mut entries = Vec::new();
3260                let mut refreshed_entries = Vec::new();
3261                let mut failed_paths = Vec::new();
3262                let dir_entries = std::fs::read_dir(&path_buf)
3263                    .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
3264
3265                let known_map: HashMap<PathBuf, SessionPickEntry> =
3266                    known.into_iter().map(|e| (e.path.clone(), e)).collect();
3267
3268                for entry in dir_entries {
3269                    let entry =
3270                        entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
3271                    let path = entry.path();
3272                    if is_session_file_path(&path) {
3273                        // Optimization: if we already have this file indexed and both mtime and
3274                        // size match, reuse indexed metadata to avoid a full parse.
3275                        if let Ok((disk_ms, disk_size)) = session_file_stats(&path) {
3276                            if let Some(known_entry) = known_map.get(&path) {
3277                                if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
3278                                    entries.push(known_entry.clone());
3279                                    continue;
3280                                }
3281                            }
3282                        }
3283
3284                        match load_session_meta(&path) {
3285                            Ok(meta) => {
3286                                refreshed_entries.push(meta.clone());
3287                                entries.push(meta);
3288                            }
3289                            Err(_) => failed_paths.push(path),
3290                        }
3291                    }
3292                }
3293                Ok(ScanSessionsResult {
3294                    entries,
3295                    refreshed_entries,
3296                    failed_paths,
3297                })
3298            })();
3299            let cx = AgentCx::for_request();
3300            let _ = tx.send(cx.cx(), res);
3301        })
3302        .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
3303
3304    let cx = AgentCx::for_request();
3305    let recv_result = rx.recv(cx.cx()).await;
3306    finish_worker_result(handle, recv_result, "Scan task cancelled")
3307}
3308
3309fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
3310    match path.extension().and_then(|ext| ext.to_str()) {
3311        Some("jsonl") => load_session_meta_jsonl(path),
3312        #[cfg(feature = "sqlite-sessions")]
3313        Some("sqlite") => load_session_meta_sqlite(path),
3314        _ => Err(Error::session(format!(
3315            "Unsupported session file extension: {}",
3316            path.display()
3317        ))),
3318    }
3319}
3320
3321#[derive(Deserialize)]
3322struct PartialEntry {
3323    #[serde(default)]
3324    r#type: String,
3325    #[serde(default)]
3326    name: Option<String>,
3327}
3328
3329fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
3330    let file = std::fs::File::open(path)
3331        .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
3332    let mut reader = BufReader::new(file);
3333
3334    let Some(header_line) = read_capped_utf8_line(&mut reader)
3335        .map_err(|e| Error::session(format!("Failed to read header: {e}")))?
3336    else {
3337        return Err(Error::session("Empty session file"));
3338    };
3339
3340    let header: SessionHeader =
3341        serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
3342    header
3343        .validate()
3344        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3345
3346    let mut message_count = 0u64;
3347    let mut name = None;
3348    loop {
3349        let Some(line_content) = read_capped_utf8_line(&mut reader)
3350            .map_err(|e| Error::session(format!("Failed to read session entry: {e}")))?
3351        else {
3352            break;
3353        };
3354        if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
3355            match entry.r#type.as_str() {
3356                "message" => message_count += 1,
3357                "session_info" if entry.name.is_some() => {
3358                    name = entry.name;
3359                }
3360                _ => {}
3361            }
3362        }
3363    }
3364
3365    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3366
3367    Ok(SessionPickEntry {
3368        path: path.to_path_buf(),
3369        id: header.id,
3370        cwd: header.cwd,
3371        timestamp: header.timestamp,
3372        message_count,
3373        name,
3374        last_modified_ms,
3375        size_bytes,
3376    })
3377}
3378
3379#[cfg(feature = "sqlite-sessions")]
3380fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
3381    let meta = futures::executor::block_on(async {
3382        crate::session_sqlite::load_session_meta(path).await
3383    })?;
3384    let header = meta.header;
3385    header
3386        .validate()
3387        .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3388
3389    let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3390
3391    Ok(SessionPickEntry {
3392        path: path.to_path_buf(),
3393        id: header.id,
3394        cwd: header.cwd,
3395        timestamp: header.timestamp,
3396        message_count: meta.message_count,
3397        name: meta.name,
3398        last_modified_ms,
3399        size_bytes,
3400    })
3401}
3402
3403// ============================================================================
3404// Session Header
3405// ============================================================================
3406
3407/// Session file header.
3408#[derive(Debug, Clone, Serialize, Deserialize)]
3409#[serde(rename_all = "camelCase")]
3410pub struct SessionHeader {
3411    pub r#type: String,
3412    #[serde(skip_serializing_if = "Option::is_none")]
3413    pub version: Option<u8>,
3414    pub id: String,
3415    pub timestamp: String,
3416    pub cwd: String,
3417    #[serde(skip_serializing_if = "Option::is_none")]
3418    pub provider: Option<String>,
3419    #[serde(skip_serializing_if = "Option::is_none")]
3420    pub model_id: Option<String>,
3421    #[serde(skip_serializing_if = "Option::is_none")]
3422    pub thinking_level: Option<String>,
3423    #[serde(skip_serializing_if = "Option::is_none")]
3424    pub fallback_provider: Option<String>,
3425    #[serde(skip_serializing_if = "Option::is_none")]
3426    pub fallback_model_id: Option<String>,
3427    #[serde(skip_serializing_if = "Option::is_none")]
3428    pub fallback_thinking_level: Option<String>,
3429    #[serde(skip_serializing_if = "Option::is_none", rename = "leafId")]
3430    pub current_leaf: Option<String>,
3431    #[serde(
3432        skip_serializing_if = "Option::is_none",
3433        rename = "branchedFrom",
3434        alias = "parentSession"
3435    )]
3436    pub parent_session: Option<String>,
3437}
3438
3439impl SessionHeader {
3440    pub fn new() -> Self {
3441        let now = chrono::Utc::now();
3442        Self {
3443            r#type: "session".to_string(),
3444            version: Some(SESSION_VERSION),
3445            id: uuid::Uuid::new_v4().to_string(),
3446            timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3447            cwd: std::env::current_dir()
3448                .map(|p| p.display().to_string())
3449                .unwrap_or_default(),
3450            provider: None,
3451            model_id: None,
3452            thinking_level: None,
3453            fallback_provider: None,
3454            fallback_model_id: None,
3455            fallback_thinking_level: None,
3456            current_leaf: None,
3457            parent_session: None,
3458        }
3459    }
3460
3461    fn branch_fallback_model_fields(&self) -> (Option<String>, Option<String>) {
3462        (
3463            self.fallback_provider
3464                .clone()
3465                .or_else(|| self.provider.clone()),
3466            self.fallback_model_id
3467                .clone()
3468                .or_else(|| self.model_id.clone()),
3469        )
3470    }
3471
3472    fn branch_fallback_model_pair(&self) -> Option<(String, String)> {
3473        let (provider, model_id) = self.branch_fallback_model_fields();
3474        provider.zip(model_id)
3475    }
3476
3477    fn branch_fallback_thinking_level(&self) -> Option<String> {
3478        self.fallback_thinking_level
3479            .clone()
3480            .or_else(|| self.thinking_level.clone())
3481    }
3482
3483    fn materialize_branch_fallbacks(&mut self) -> bool {
3484        // Track mutations as booleans, then materialize them.
3485        // This pattern avoids clippy::useless_let_if_seq while
3486        // remaining readable for multiple independent conditions.
3487        let set_provider = self.fallback_provider.is_none() && self.provider.is_some();
3488        let set_model_id = self.fallback_model_id.is_none() && self.model_id.is_some();
3489        let set_thinking = self.fallback_thinking_level.is_none() && self.thinking_level.is_some();
3490
3491        if set_provider {
3492            self.fallback_provider = self.provider.clone();
3493        }
3494        if set_model_id {
3495            self.fallback_model_id = self.model_id.clone();
3496        }
3497        if set_thinking {
3498            self.fallback_thinking_level = self.thinking_level.clone();
3499        }
3500
3501        set_provider || set_model_id || set_thinking
3502    }
3503
3504    pub fn validate(&self) -> std::result::Result<(), String> {
3505        if self.r#type != "session" {
3506            return Err(format!("type must be `session`, got `{}`", self.r#type));
3507        }
3508        if self.version != Some(SESSION_VERSION) {
3509            return Err(format!(
3510                "version must be {SESSION_VERSION}, got {}",
3511                self.version
3512                    .map_or_else(|| "none".to_string(), |value| value.to_string())
3513            ));
3514        }
3515        if self.id.trim().is_empty() {
3516            return Err("id must be non-empty".to_string());
3517        }
3518        if self.timestamp.trim().is_empty() {
3519            return Err("timestamp must be non-empty".to_string());
3520        }
3521        if self.cwd.trim().is_empty() {
3522            return Err("cwd must be non-empty".to_string());
3523        }
3524        Ok(())
3525    }
3526
3527    pub fn is_valid(&self) -> bool {
3528        self.validate().is_ok()
3529    }
3530}
3531
3532impl Default for SessionHeader {
3533    fn default() -> Self {
3534        Self::new()
3535    }
3536}
3537
3538// ============================================================================
3539// Session Entries
3540// ============================================================================
3541
3542/// A session entry.
3543#[derive(Debug, Clone, Serialize, Deserialize)]
3544#[serde(tag = "type", rename_all = "snake_case")]
3545pub enum SessionEntry {
3546    Message(MessageEntry),
3547    ModelChange(ModelChangeEntry),
3548    ThinkingLevelChange(ThinkingLevelChangeEntry),
3549    Compaction(CompactionEntry),
3550    BranchSummary(BranchSummaryEntry),
3551    Label(LabelEntry),
3552    SessionInfo(SessionInfoEntry),
3553    Custom(CustomEntry),
3554}
3555
3556impl SessionEntry {
3557    pub const fn base(&self) -> &EntryBase {
3558        match self {
3559            Self::Message(e) => &e.base,
3560            Self::ModelChange(e) => &e.base,
3561            Self::ThinkingLevelChange(e) => &e.base,
3562            Self::Compaction(e) => &e.base,
3563            Self::BranchSummary(e) => &e.base,
3564            Self::Label(e) => &e.base,
3565            Self::SessionInfo(e) => &e.base,
3566            Self::Custom(e) => &e.base,
3567        }
3568    }
3569
3570    pub const fn base_mut(&mut self) -> &mut EntryBase {
3571        match self {
3572            Self::Message(e) => &mut e.base,
3573            Self::ModelChange(e) => &mut e.base,
3574            Self::ThinkingLevelChange(e) => &mut e.base,
3575            Self::Compaction(e) => &mut e.base,
3576            Self::BranchSummary(e) => &mut e.base,
3577            Self::Label(e) => &mut e.base,
3578            Self::SessionInfo(e) => &mut e.base,
3579            Self::Custom(e) => &mut e.base,
3580        }
3581    }
3582
3583    pub const fn base_id(&self) -> Option<&String> {
3584        self.base().id.as_ref()
3585    }
3586}
3587
3588/// Base entry fields.
3589#[derive(Debug, Clone, Serialize, Deserialize)]
3590#[serde(rename_all = "camelCase")]
3591pub struct EntryBase {
3592    #[serde(skip_serializing_if = "Option::is_none")]
3593    pub id: Option<String>,
3594    #[serde(skip_serializing_if = "Option::is_none")]
3595    pub parent_id: Option<String>,
3596    pub timestamp: String,
3597}
3598
3599impl EntryBase {
3600    pub fn new(parent_id: Option<String>, id: String) -> Self {
3601        Self {
3602            id: Some(id),
3603            parent_id,
3604            timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3605        }
3606    }
3607}
3608
3609/// Message entry.
3610#[derive(Debug, Clone, Serialize, Deserialize)]
3611#[serde(rename_all = "camelCase")]
3612pub struct MessageEntry {
3613    #[serde(flatten)]
3614    pub base: EntryBase,
3615    pub message: SessionMessage,
3616}
3617
3618/// Session message payload.
3619#[derive(Debug, Clone, Serialize, Deserialize)]
3620#[serde(
3621    tag = "role",
3622    rename_all = "camelCase",
3623    rename_all_fields = "camelCase"
3624)]
3625pub enum SessionMessage {
3626    User {
3627        content: UserContent,
3628        #[serde(skip_serializing_if = "Option::is_none")]
3629        timestamp: Option<i64>,
3630    },
3631    Assistant {
3632        #[serde(flatten)]
3633        message: AssistantMessage,
3634    },
3635    ToolResult {
3636        tool_call_id: String,
3637        tool_name: String,
3638        content: Vec<ContentBlock>,
3639        #[serde(skip_serializing_if = "Option::is_none")]
3640        details: Option<Value>,
3641        #[serde(default)]
3642        is_error: bool,
3643        #[serde(skip_serializing_if = "Option::is_none")]
3644        timestamp: Option<i64>,
3645    },
3646    Custom {
3647        custom_type: String,
3648        content: String,
3649        #[serde(default)]
3650        display: bool,
3651        #[serde(skip_serializing_if = "Option::is_none")]
3652        details: Option<Value>,
3653        #[serde(skip_serializing_if = "Option::is_none")]
3654        timestamp: Option<i64>,
3655    },
3656    BashExecution {
3657        command: String,
3658        output: String,
3659        exit_code: i32,
3660        #[serde(skip_serializing_if = "Option::is_none")]
3661        cancelled: Option<bool>,
3662        #[serde(skip_serializing_if = "Option::is_none")]
3663        truncated: Option<bool>,
3664        #[serde(skip_serializing_if = "Option::is_none")]
3665        full_output_path: Option<String>,
3666        #[serde(skip_serializing_if = "Option::is_none")]
3667        timestamp: Option<i64>,
3668        #[serde(flatten)]
3669        extra: HashMap<String, Value>,
3670    },
3671    BranchSummary {
3672        summary: String,
3673        from_id: String,
3674    },
3675    CompactionSummary {
3676        summary: String,
3677        tokens_before: u64,
3678    },
3679}
3680
3681impl From<Message> for SessionMessage {
3682    fn from(message: Message) -> Self {
3683        match message {
3684            Message::User(user) => Self::User {
3685                content: user.content,
3686                timestamp: Some(user.timestamp),
3687            },
3688            Message::Assistant(assistant) => Self::Assistant {
3689                message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
3690            },
3691            Message::ToolResult(result) => {
3692                let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
3693                Self::ToolResult {
3694                    tool_call_id: result.tool_call_id,
3695                    tool_name: result.tool_name,
3696                    content: result.content,
3697                    details: result.details,
3698                    is_error: result.is_error,
3699                    timestamp: Some(result.timestamp),
3700                }
3701            }
3702            Message::Custom(custom) => Self::Custom {
3703                custom_type: custom.custom_type,
3704                content: custom.content,
3705                display: custom.display,
3706                details: custom.details,
3707                timestamp: Some(custom.timestamp),
3708            },
3709        }
3710    }
3711}
3712
3713/// Model change entry.
3714#[derive(Debug, Clone, Serialize, Deserialize)]
3715#[serde(rename_all = "camelCase")]
3716pub struct ModelChangeEntry {
3717    #[serde(flatten)]
3718    pub base: EntryBase,
3719    pub provider: String,
3720    pub model_id: String,
3721}
3722
3723/// Thinking level change entry.
3724#[derive(Debug, Clone, Serialize, Deserialize)]
3725#[serde(rename_all = "camelCase")]
3726pub struct ThinkingLevelChangeEntry {
3727    #[serde(flatten)]
3728    pub base: EntryBase,
3729    pub thinking_level: String,
3730}
3731
3732/// Compaction entry.
3733#[derive(Debug, Clone, Serialize, Deserialize)]
3734#[serde(rename_all = "camelCase")]
3735pub struct CompactionEntry {
3736    #[serde(flatten)]
3737    pub base: EntryBase,
3738    pub summary: String,
3739    pub first_kept_entry_id: String,
3740    pub tokens_before: u64,
3741    #[serde(skip_serializing_if = "Option::is_none")]
3742    pub details: Option<serde_json::Value>,
3743    #[serde(skip_serializing_if = "Option::is_none")]
3744    pub from_hook: Option<bool>,
3745}
3746
3747/// Branch summary entry.
3748#[derive(Debug, Clone, Serialize, Deserialize)]
3749#[serde(rename_all = "camelCase")]
3750pub struct BranchSummaryEntry {
3751    #[serde(flatten)]
3752    pub base: EntryBase,
3753    pub from_id: String,
3754    pub summary: String,
3755    #[serde(skip_serializing_if = "Option::is_none")]
3756    pub details: Option<serde_json::Value>,
3757    #[serde(skip_serializing_if = "Option::is_none")]
3758    pub from_hook: Option<bool>,
3759}
3760
3761/// Label entry.
3762#[derive(Debug, Clone, Serialize, Deserialize)]
3763#[serde(rename_all = "camelCase")]
3764pub struct LabelEntry {
3765    #[serde(flatten)]
3766    pub base: EntryBase,
3767    pub target_id: String,
3768    #[serde(skip_serializing_if = "Option::is_none")]
3769    pub label: Option<String>,
3770}
3771
3772/// Session info entry.
3773#[derive(Debug, Clone, Serialize, Deserialize)]
3774#[serde(rename_all = "camelCase")]
3775pub struct SessionInfoEntry {
3776    #[serde(flatten)]
3777    pub base: EntryBase,
3778    #[serde(skip_serializing_if = "Option::is_none")]
3779    pub name: Option<String>,
3780}
3781
3782/// Custom entry.
3783#[derive(Debug, Clone, Serialize, Deserialize)]
3784#[serde(rename_all = "camelCase")]
3785pub struct CustomEntry {
3786    #[serde(flatten)]
3787    pub base: EntryBase,
3788    pub custom_type: String,
3789    #[serde(skip_serializing_if = "Option::is_none")]
3790    pub data: Option<serde_json::Value>,
3791}
3792
3793// ============================================================================
3794// Utilities
3795// ============================================================================
3796
3797/// Encode a working directory path for use in session directory names.
3798pub fn encode_cwd(path: &std::path::Path) -> String {
3799    let s = path.display().to_string();
3800    let s = s.trim_start_matches(['/', '\\']);
3801    let s = s.replace(['/', '\\', ':'], "-");
3802    format!("--{s}--")
3803}
3804
3805fn infer_session_root_from_path(path: &Path) -> Option<PathBuf> {
3806    let parent = path.parent()?.to_path_buf();
3807    if parent
3808        .file_name()
3809        .and_then(|name| name.to_str())
3810        .is_some_and(|name| name.starts_with("--") && name.ends_with("--") && name.len() > 4)
3811    {
3812        return parent.parent().map(PathBuf::from).or(Some(parent));
3813    }
3814    Some(parent)
3815}
3816
3817pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
3818    match message {
3819        SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
3820            content: content.clone(),
3821            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3822        })),
3823        SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
3824        SessionMessage::ToolResult {
3825            tool_call_id,
3826            tool_name,
3827            content,
3828            details,
3829            is_error,
3830            timestamp,
3831        } => Some(Message::tool_result(ToolResultMessage {
3832            tool_call_id: tool_call_id.clone(),
3833            tool_name: tool_name.clone(),
3834            content: content.clone(),
3835            details: details.clone(),
3836            is_error: *is_error,
3837            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3838        })),
3839        SessionMessage::Custom {
3840            custom_type,
3841            content,
3842            display,
3843            details,
3844            timestamp,
3845        } => Some(Message::Custom(crate::model::CustomMessage {
3846            content: content.clone(),
3847            custom_type: custom_type.clone(),
3848            display: *display,
3849            details: details.clone(),
3850            timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3851        })),
3852        SessionMessage::BashExecution {
3853            command,
3854            output,
3855            exit_code,
3856            cancelled,
3857            truncated,
3858            full_output_path,
3859            timestamp,
3860            extra,
3861        } => {
3862            if extra
3863                .get("excludeFromContext")
3864                .and_then(Value::as_bool)
3865                .is_some_and(|v| v)
3866            {
3867                return None;
3868            }
3869            let text = bash_execution_to_text(
3870                command,
3871                output,
3872                *exit_code,
3873                cancelled.unwrap_or(false),
3874                truncated.unwrap_or(false),
3875                full_output_path.as_deref(),
3876            );
3877            Some(Message::User(UserMessage {
3878                content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
3879                timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3880            }))
3881        }
3882        SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
3883            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3884                "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
3885            )))]),
3886            timestamp: chrono::Utc::now().timestamp_millis(),
3887        })),
3888        SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
3889            content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3890                "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
3891            )))]),
3892            timestamp: chrono::Utc::now().timestamp_millis(),
3893        })),
3894    }
3895}
3896
3897const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
3898const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
3899
3900const BRANCH_SUMMARY_PREFIX: &str =
3901    "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
3902const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
3903
3904pub(crate) fn bash_execution_to_text(
3905    command: &str,
3906    output: &str,
3907    exit_code: i32,
3908    cancelled: bool,
3909    truncated: bool,
3910    full_output_path: Option<&str>,
3911) -> String {
3912    let mut text = format!("Ran `{command}`\n");
3913    if output.is_empty() {
3914        text.push_str("(no output)");
3915    } else {
3916        text.push_str("```\n");
3917        text.push_str(output);
3918        if !output.ends_with('\n') {
3919            text.push('\n');
3920        }
3921        text.push_str("```");
3922    }
3923
3924    if cancelled {
3925        text.push_str("\n\n(command cancelled)");
3926    } else if exit_code != 0 {
3927        let _ = write!(text, "\n\nCommand exited with code {exit_code}");
3928    }
3929
3930    if truncated {
3931        if let Some(path) = full_output_path {
3932            let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
3933        } else {
3934            text.push_str("\n\n[Output truncated]");
3935        }
3936    }
3937
3938    text
3939}
3940
3941/// Render session header and entries as a standalone HTML document.
3942///
3943/// Shared implementation used by both `Session::to_html()` and
3944/// `ExportSnapshot::to_html()`.
3945#[allow(clippy::too_many_lines)]
3946fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
3947    let mut html = String::new();
3948    html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
3949    html.push_str("<title>Pi Session</title>");
3950    html.push_str("<style>");
3951    html.push_str(
3952        "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
3953            h1{margin:0 0 8px 0;}
3954            .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
3955            .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
3956            .msg.user{border-left:4px solid #4fc3f7;}
3957            .msg.assistant{border-left:4px solid #81c784;}
3958            .msg.tool{border-left:4px solid #ffb74d;}
3959            .msg.system{border-left:4px solid #ef9a9a;}
3960            .role{font-weight:600;margin-bottom:8px;}
3961            pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
3962            .thinking summary{cursor:pointer;}
3963            img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
3964            .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
3965            ",
3966    );
3967    html.push_str("</style></head><body>");
3968
3969    let _ = write!(
3970        html,
3971        "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
3972        escape_html(&header.id),
3973        escape_html(&header.timestamp),
3974        escape_html(&header.cwd)
3975    );
3976
3977    for entry in entries {
3978        match entry {
3979            SessionEntry::Message(message) => {
3980                html.push_str(&render_session_message(&message.message));
3981            }
3982            SessionEntry::ModelChange(change) => {
3983                let _ = write!(
3984                    html,
3985                    "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
3986                    escape_html(&change.provider),
3987                    escape_html(&change.model_id)
3988                );
3989            }
3990            SessionEntry::ThinkingLevelChange(change) => {
3991                let _ = write!(
3992                    html,
3993                    "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
3994                    escape_html(&change.thinking_level)
3995                );
3996            }
3997            SessionEntry::Compaction(compaction) => {
3998                let _ = write!(
3999                    html,
4000                    "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4001                    escape_html(&compaction.summary)
4002                );
4003            }
4004            SessionEntry::BranchSummary(summary) => {
4005                let _ = write!(
4006                    html,
4007                    "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4008                    escape_html(&summary.summary)
4009                );
4010            }
4011            SessionEntry::SessionInfo(info) => {
4012                if let Some(name) = &info.name {
4013                    let _ = write!(
4014                        html,
4015                        "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
4016                        escape_html(name)
4017                    );
4018                }
4019            }
4020            SessionEntry::Custom(custom) => {
4021                let _ = write!(
4022                    html,
4023                    "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
4024                    escape_html(&custom.custom_type)
4025                );
4026            }
4027            SessionEntry::Label(_) => {}
4028        }
4029    }
4030
4031    html.push_str("</body></html>");
4032    html
4033}
4034
4035fn render_session_message(message: &SessionMessage) -> String {
4036    match message {
4037        SessionMessage::User { content, .. } => {
4038            let mut html = String::new();
4039            html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
4040            html.push_str(&render_user_content(content));
4041            html.push_str("</div>");
4042            html
4043        }
4044        SessionMessage::Assistant { message } => {
4045            let mut html = String::new();
4046            html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
4047            html.push_str(&render_blocks(&message.content));
4048            html.push_str("</div>");
4049            html
4050        }
4051        SessionMessage::ToolResult {
4052            tool_name,
4053            content,
4054            is_error,
4055            details,
4056            ..
4057        } => {
4058            let mut html = String::new();
4059            let role = if *is_error { "Tool Error" } else { "Tool" };
4060            let _ = write!(
4061                html,
4062                "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
4063                role,
4064                escape_html(tool_name)
4065            );
4066            html.push_str(&render_blocks(content));
4067            if let Some(details) = details {
4068                let details_str =
4069                    serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
4070                let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
4071            }
4072            html.push_str("</div>");
4073            html
4074        }
4075        SessionMessage::Custom {
4076            custom_type,
4077            content,
4078            ..
4079        } => {
4080            let mut html = String::new();
4081            let _ = write!(
4082                html,
4083                "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
4084                escape_html(custom_type),
4085                escape_html(content)
4086            );
4087            html
4088        }
4089        SessionMessage::BashExecution {
4090            command,
4091            output,
4092            exit_code,
4093            ..
4094        } => {
4095            let mut html = String::new();
4096            let _ = write!(
4097                html,
4098                "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
4099                escape_html(command),
4100                escape_html(output)
4101            );
4102            html
4103        }
4104        SessionMessage::BranchSummary { summary, .. } => {
4105            format!(
4106                "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4107                escape_html(summary)
4108            )
4109        }
4110        SessionMessage::CompactionSummary { summary, .. } => {
4111            format!(
4112                "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4113                escape_html(summary)
4114            )
4115        }
4116    }
4117}
4118
4119fn render_user_content(content: &UserContent) -> String {
4120    match content {
4121        UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
4122        UserContent::Blocks(blocks) => render_blocks(blocks),
4123    }
4124}
4125
4126fn render_blocks(blocks: &[ContentBlock]) -> String {
4127    let mut html = String::new();
4128    for block in blocks {
4129        match block {
4130            ContentBlock::Text(text) => {
4131                let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
4132            }
4133            ContentBlock::Thinking(thinking) => {
4134                let _ = write!(
4135                    html,
4136                    "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
4137                    escape_html(&thinking.thinking)
4138                );
4139            }
4140            ContentBlock::Image(image) => {
4141                let _ = write!(
4142                    html,
4143                    "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
4144                    escape_html(&image.mime_type),
4145                    escape_html(&image.data)
4146                );
4147            }
4148            ContentBlock::ToolCall(tool_call) => {
4149                let args = serde_json::to_string_pretty(&tool_call.arguments)
4150                    .unwrap_or_else(|_| tool_call.arguments.to_string());
4151                let _ = write!(
4152                    html,
4153                    "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
4154                    escape_html(&tool_call.name),
4155                    escape_html(&args)
4156                );
4157            }
4158        }
4159    }
4160    html
4161}
4162
4163fn escape_html(input: &str) -> String {
4164    let mut escaped = String::with_capacity(input.len());
4165    for ch in input.chars() {
4166        match ch {
4167            '&' => escaped.push_str("&amp;"),
4168            '<' => escaped.push_str("&lt;"),
4169            '>' => escaped.push_str("&gt;"),
4170            '"' => escaped.push_str("&quot;"),
4171            '\'' => escaped.push_str("&#39;"),
4172            _ => escaped.push(ch),
4173        }
4174    }
4175    escaped
4176}
4177
4178fn user_content_to_text(content: &UserContent) -> String {
4179    match content {
4180        UserContent::Text(text) => text.clone(),
4181        UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
4182    }
4183}
4184
4185fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
4186    let mut output = String::new();
4187    for block in blocks {
4188        match block {
4189            ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
4190            ContentBlock::Image(image) => {
4191                push_line(&mut output, &format!("[image: {}]", image.mime_type));
4192            }
4193            ContentBlock::Thinking(thinking_block) => {
4194                push_line(&mut output, &thinking_block.thinking);
4195            }
4196            ContentBlock::ToolCall(call) => {
4197                push_line(&mut output, &format!("[tool call: {}]", call.name));
4198            }
4199        }
4200    }
4201    output
4202}
4203
4204fn push_line(out: &mut String, line: &str) {
4205    if !out.is_empty() {
4206        out.push('\n');
4207    }
4208    out.push_str(line);
4209}
4210
4211fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
4212    entries
4213        .iter()
4214        .filter_map(|e| e.base_id().cloned())
4215        .collect()
4216}
4217
4218fn session_entry_stats(entries: &[SessionEntry]) -> (u64, Option<String>) {
4219    let mut message_count = 0u64;
4220    let mut name = None;
4221    for entry in entries {
4222        match entry {
4223            SessionEntry::Message(_) => message_count += 1,
4224            SessionEntry::SessionInfo(info) if info.name.is_some() => {
4225                name.clone_from(&info.name);
4226            }
4227            _ => {}
4228        }
4229    }
4230    (message_count, name)
4231}
4232
4233/// Minimum entry count to activate parallel deserialization (Gap E).
4234const PARALLEL_THRESHOLD: usize = 512;
4235/// Number of JSONL lines deserialized per batch in the blocking open path.
4236const JSONL_PARSE_BATCH_SIZE: usize = 8192;
4237
4238/// Parse a JSONL session file on the current (blocking) thread.
4239///
4240/// Combines Gap E (parallel deserialization) and Gap F (single-pass
4241/// finalization) for the fastest possible open path.
4242#[allow(clippy::too_many_lines)]
4243fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4244    let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
4245    let mut reader = std::io::BufReader::new(file);
4246
4247    let Some(header_line) =
4248        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4249    else {
4250        return Err(crate::Error::session("Empty session file"));
4251    };
4252    if header_line.trim().is_empty() {
4253        return Err(crate::Error::session("Empty session file"));
4254    }
4255
4256    // Parse header (first line)
4257    let header: SessionHeader = serde_json::from_str(&header_line)
4258        .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
4259    header
4260        .validate()
4261        .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
4262    let (header, normalized_header_dirty) = normalize_loaded_header(header);
4263
4264    let mut entries = Vec::new();
4265    let mut diagnostics = SessionOpenDiagnostics::default();
4266
4267    // Gap E: parallel deserialization for large sessions.
4268    // Batch processing to bound memory usage while allowing parallelism.
4269    let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
4270
4271    let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
4272    let mut current_line_num = 2; // Header is line 1
4273
4274    loop {
4275        line_batch.clear();
4276        let mut batch_eof = false;
4277
4278        for _ in 0..JSONL_PARSE_BATCH_SIZE {
4279            match read_capped_utf8_line(&mut reader) {
4280                Ok(None) => {
4281                    batch_eof = true;
4282                    break;
4283                }
4284                Ok(Some(line)) => {
4285                    if !line.trim().is_empty() {
4286                        line_batch.push((current_line_num, line));
4287                    }
4288                }
4289                Err(e) => {
4290                    diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4291                        line_number: current_line_num,
4292                        error: format!("IO error reading line: {e}"),
4293                    });
4294                }
4295            }
4296            current_line_num += 1;
4297        }
4298
4299        if line_batch.is_empty() {
4300            if batch_eof {
4301                break;
4302            }
4303            continue;
4304        }
4305
4306        if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
4307            let chunk_size = (line_batch.len() / num_threads).max(64);
4308
4309            let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
4310                std::thread::scope(|s| {
4311                    line_batch
4312                        .chunks(chunk_size)
4313                        .map(|chunk| {
4314                            s.spawn(move || {
4315                                let mut ok = Vec::with_capacity(chunk.len());
4316                                let mut skip = Vec::new();
4317                                for (line_num, line) in chunk {
4318                                    match serde_json::from_str::<SessionEntry>(line) {
4319                                        Ok(entry) => ok.push(entry),
4320                                        Err(e) => {
4321                                            skip.push(SessionOpenSkippedEntry {
4322                                                line_number: *line_num,
4323                                                error: e.to_string(),
4324                                            });
4325                                        }
4326                                    }
4327                                }
4328                                (ok, skip)
4329                            })
4330                        })
4331                        .collect::<Vec<_>>()
4332                        .into_iter()
4333                        .map(|h| {
4334                            h.join().map_err(|panic_payload| {
4335                                let panic_message =
4336                                    panic_payload.downcast_ref::<String>().map_or_else(
4337                                        || {
4338                                            panic_payload.downcast_ref::<&str>().map_or_else(
4339                                                || "unknown panic payload".to_string(),
4340                                                |message| (*message).to_string(),
4341                                            )
4342                                        },
4343                                        std::clone::Clone::clone,
4344                                    );
4345                                Error::session(format!(
4346                                    "parallel session parse worker panicked: {panic_message}"
4347                                ))
4348                            })
4349                        })
4350                        .collect()
4351                });
4352            let chunk_results = chunk_results?;
4353
4354            for (chunk_entries, chunk_skipped) in chunk_results {
4355                entries.extend(chunk_entries);
4356                diagnostics.skipped_entries.extend(chunk_skipped);
4357            }
4358        } else {
4359            // Sequential path
4360            for (line_num, line) in &line_batch {
4361                match serde_json::from_str::<SessionEntry>(line) {
4362                    Ok(entry) => entries.push(entry),
4363                    Err(e) => {
4364                        diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4365                            line_number: *line_num,
4366                            error: e.to_string(),
4367                        });
4368                    }
4369                }
4370            }
4371        }
4372
4373        if batch_eof {
4374            break;
4375        }
4376    }
4377
4378    // --- Single-pass load finalization (Gap F) ---
4379    let finalized = finalize_loaded_entries(&mut entries);
4380    for orphan in &finalized.orphans {
4381        diagnostics
4382            .orphaned_parent_links
4383            .push(SessionOpenOrphanedParentLink {
4384                entry_id: orphan.0.clone(),
4385                missing_parent_id: orphan.1.clone(),
4386            });
4387    }
4388
4389    let entry_count = entries.len();
4390    let natural_leaf_id = finalized.leaf_id.clone();
4391    let leaf_id = resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
4392
4393    Ok((
4394        Session {
4395            header,
4396            entries,
4397            path: Some(path_buf),
4398            leaf_id: leaf_id.clone(),
4399            session_dir: None,
4400            store_kind: SessionStoreKind::Jsonl,
4401            entry_ids: finalized.entry_ids,
4402            is_linear: finalized.is_linear && leaf_id == natural_leaf_id,
4403            entry_index: finalized.entry_index,
4404            cached_message_count: finalized.message_count,
4405            cached_name: finalized.name,
4406            autosave_queue: AutosaveQueue::new(),
4407            autosave_durability: AutosaveDurabilityMode::from_env(),
4408            persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
4409            header_dirty: normalized_header_dirty,
4410            appends_since_checkpoint: 0,
4411            v2_sidecar_root: None,
4412            v2_partial_hydration: false,
4413            v2_resume_mode: None,
4414            v2_sidecar_stale: false,
4415            v2_message_count_offset: 0,
4416        },
4417        diagnostics,
4418    ))
4419}
4420
4421/// Open a session from its V2 sidecar store.
4422///
4423/// Reads the JSONL header (first line) for `SessionHeader`, then loads
4424/// entries from the V2 segment store via its offset index — O(index + tail)
4425/// instead of the O(n) full-file parse that `open_jsonl_blocking` performs.
4426#[allow(clippy::too_many_lines)]
4427fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4428    // 1. Read JSONL header (first line only).
4429    let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4430    let mut reader = BufReader::new(file);
4431    let Some(header_line) =
4432        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4433    else {
4434        return Err(crate::Error::session("Empty JSONL session file"));
4435    };
4436    let header: SessionHeader = serde_json::from_str(header_line.trim())
4437        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4438    header.validate().map_err(|reason| {
4439        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4440    })?;
4441
4442    // 2. Open V2 sidecar store.
4443    let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
4444    let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
4445
4446    // 3. Choose an explicit hydration strategy for resume:
4447    // - env override (PI_SESSION_V2_OPEN_MODE)
4448    // - auto lazy mode for large sessions
4449    let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
4450    let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
4451    if let Some(raw) = mode_override_raw.as_deref() {
4452        if parse_v2_open_mode(raw).is_none() {
4453            tracing::warn!(
4454                value = %raw,
4455                "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
4456            );
4457        }
4458    }
4459    if let Some(raw) = threshold_override_raw.as_deref() {
4460        if raw.trim().parse::<u64>().is_err() {
4461            tracing::warn!(
4462                value = %raw,
4463                "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
4464            );
4465        }
4466    }
4467
4468    let entry_count = store.entry_count();
4469    let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
4470        entry_count,
4471        mode_override_raw.as_deref(),
4472        threshold_override_raw.as_deref(),
4473    );
4474    let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
4475        && entry_count > 0
4476        && store.head().is_none()
4477    {
4478        tracing::warn!(
4479            entry_count,
4480            "active-path hydration selected but store has no head; falling back to full hydration"
4481        );
4482        V2OpenMode::Full
4483    } else {
4484        selected_mode
4485    };
4486    tracing::debug!(
4487        entry_count,
4488        lazy_threshold,
4489        selection_reason,
4490        ?mode,
4491        "selected V2 resume hydration mode"
4492    );
4493
4494    // 4. Load entries using the selected mode.
4495    let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
4496    session.path = Some(jsonl_path);
4497    session.v2_sidecar_root = Some(v2_root);
4498    session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
4499    session.v2_resume_mode = Some(mode);
4500    Ok((session, diagnostics))
4501}
4502
4503/// Create a V2 sidecar store from an existing JSONL session file.
4504///
4505/// This is the migration path: parse the full JSONL once and write each entry
4506/// into the V2 segmented store with offset index. Subsequent opens can then
4507/// use `open_from_v2_store_blocking` for O(index+tail) resume.
4508pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
4509    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4510    if !v2_root.exists() {
4511        return build_v2_sidecar_from_jsonl_into(jsonl_path, &v2_root);
4512    }
4513
4514    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
4515    let _staged_store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
4516        Ok(store) => store,
4517        Err(err) => {
4518            let _ = cleanup_sidecar_root(&staging_root);
4519            return Err(err);
4520        }
4521    };
4522
4523    let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
4524    if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
4525        let _ = cleanup_sidecar_root(&staging_root);
4526        return Err(crate::Error::Io(Box::new(err)));
4527    }
4528
4529    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
4530        let _ = std::fs::rename(&backup_root, &v2_root);
4531        let _ = cleanup_sidecar_root(&staging_root);
4532        return Err(crate::Error::Io(Box::new(err)));
4533    }
4534
4535    if let Err(err) = cleanup_sidecar_root(&backup_root) {
4536        tracing::warn!(
4537            path = %backup_root.display(),
4538            error = %err,
4539            "create_v2_sidecar_from_jsonl left backup sidecar after successful swap"
4540        );
4541    }
4542
4543    SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)
4544}
4545
4546fn build_v2_sidecar_from_jsonl_into(jsonl_path: &Path, v2_root: &Path) -> Result<SessionStoreV2> {
4547    let build_result = (|| -> Result<SessionStoreV2> {
4548        let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4549        let mut reader = std::io::BufReader::new(file);
4550
4551        let header_line = read_capped_utf8_line(&mut reader)
4552            .map_err(|e| crate::Error::Io(Box::new(e)))?
4553            .filter(|l| !l.trim().is_empty())
4554            .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
4555
4556        let header: SessionHeader = serde_json::from_str(header_line.trim())
4557            .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4558        header.validate().map_err(|reason| {
4559            crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4560        })?;
4561
4562        if v2_root.exists() {
4563            std::fs::remove_dir_all(v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4564        }
4565        let mut store = SessionStoreV2::create(v2_root, 64 * 1024 * 1024)?;
4566
4567        loop {
4568            let Some(line) =
4569                read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4570            else {
4571                break;
4572            };
4573            if line.trim().is_empty() {
4574                continue;
4575            }
4576            let entry: SessionEntry = serde_json::from_str(&line)
4577                .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
4578            let (entry_id, parent_entry_id, entry_type, payload) =
4579                session_store_v2::session_entry_to_frame_args(&entry)?;
4580            store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
4581        }
4582
4583        store.write_manifest(header.id, "jsonl")?;
4584
4585        Ok(store)
4586    })();
4587
4588    if build_result.is_err() && v2_root.exists() {
4589        let _ = std::fs::remove_dir_all(v2_root);
4590    }
4591
4592    build_result
4593}
4594
4595fn unique_sidecar_aux_path(v2_root: &Path, suffix: &str) -> PathBuf {
4596    let file_name = v2_root
4597        .file_name()
4598        .and_then(|name| name.to_str())
4599        .unwrap_or("session.v2");
4600    v2_root.with_file_name(format!(
4601        "{file_name}.{suffix}.{}",
4602        uuid::Uuid::new_v4().simple()
4603    ))
4604}
4605
4606fn cleanup_sidecar_root(path: &Path) -> Result<()> {
4607    if path.exists() {
4608        std::fs::remove_dir_all(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4609    }
4610    Ok(())
4611}
4612
4613/// Migrate a JSONL session to V2 with full verification and event logging.
4614///
4615/// Returns the `MigrationEvent` that was recorded in the V2 store's migration
4616/// ledger. The migration is atomic: if verification fails, the sidecar is
4617/// removed and an error is returned.
4618pub fn migrate_jsonl_to_v2(
4619    jsonl_path: &Path,
4620    correlation_id: &str,
4621) -> Result<session_store_v2::MigrationEvent> {
4622    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4623    let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
4624    let store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
4625        Ok(store) => store,
4626        Err(err) => {
4627            let _ = cleanup_sidecar_root(&staging_root);
4628            return Err(err);
4629        }
4630    };
4631
4632    // Verify fidelity.
4633    let verification = match verify_v2_against_jsonl(jsonl_path, &store) {
4634        Ok(verification) => verification,
4635        Err(err) => {
4636            let _ = cleanup_sidecar_root(&staging_root);
4637            return Err(err);
4638        }
4639    };
4640
4641    if !(verification.entry_count_match
4642        && verification.hash_chain_match
4643        && verification.index_consistent)
4644    {
4645        // Verification failed — remove the sidecar.
4646        cleanup_sidecar_root(&staging_root)?;
4647        return Err(crate::Error::session(format!(
4648            "V2 migration verification failed: count={} hash={} index={}",
4649            verification.entry_count_match,
4650            verification.hash_chain_match,
4651            verification.index_consistent,
4652        )));
4653    }
4654
4655    let event = session_store_v2::MigrationEvent {
4656        schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
4657        migration_id: uuid::Uuid::new_v4().to_string(),
4658        phase: "forward".to_string(),
4659        at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4660        source_path: jsonl_path.display().to_string(),
4661        target_path: session_store_v2::v2_sidecar_path(jsonl_path)
4662            .display()
4663            .to_string(),
4664        source_format: "jsonl_v3".to_string(),
4665        target_format: "native_v2".to_string(),
4666        verification,
4667        outcome: "ok".to_string(),
4668        error_class: None,
4669        correlation_id: correlation_id.to_string(),
4670    };
4671    if let Err(err) = store.append_migration_event(event.clone()) {
4672        let _ = cleanup_sidecar_root(&staging_root);
4673        return Err(err);
4674    }
4675
4676    let backup_root = if v2_root.exists() {
4677        let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
4678        if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
4679            let _ = cleanup_sidecar_root(&staging_root);
4680            return Err(crate::Error::Io(Box::new(err)));
4681        }
4682        Some(backup_root)
4683    } else {
4684        None
4685    };
4686
4687    if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
4688        if let Some(backup_root) = backup_root.as_ref() {
4689            let _ = std::fs::rename(backup_root, &v2_root);
4690        }
4691        let _ = cleanup_sidecar_root(&staging_root);
4692        return Err(crate::Error::Io(Box::new(err)));
4693    }
4694
4695    if let Some(backup_root) = backup_root {
4696        if let Err(err) = cleanup_sidecar_root(&backup_root) {
4697            tracing::warn!(
4698                path = %backup_root.display(),
4699                error = %err,
4700                "V2 migration left backup sidecar after successful swap"
4701            );
4702        }
4703    }
4704
4705    Ok(event)
4706}
4707
4708/// Verify a V2 sidecar against its source JSONL for fidelity.
4709///
4710/// Compares entry count, entry IDs in order, and validates the V2 store's
4711/// internal integrity (checksums + hash chain).
4712pub fn verify_v2_against_jsonl(
4713    jsonl_path: &Path,
4714    store: &SessionStoreV2,
4715) -> Result<session_store_v2::MigrationVerification> {
4716    // Parse all JSONL entries (skip header).
4717    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4718    let mut reader = std::io::BufReader::new(file);
4719
4720    let Some(header_line) =
4721        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4722    else {
4723        return Err(crate::Error::session("Empty JSONL session file"));
4724    };
4725    if header_line.trim().is_empty() {
4726        return Err(crate::Error::session("Empty JSONL session file"));
4727    }
4728
4729    let header: SessionHeader = serde_json::from_str(header_line.trim())
4730        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4731    header.validate().map_err(|reason| {
4732        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4733    })?;
4734
4735    let mut jsonl_ids: Vec<String> = Vec::new();
4736    let mut jsonl_chain_hash = V2_CHAIN_HASH_GENESIS.to_string();
4737
4738    loop {
4739        let Some(line) =
4740            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4741        else {
4742            break;
4743        };
4744        if line.trim().is_empty() {
4745            continue;
4746        }
4747        let entry: SessionEntry = serde_json::from_str(&line)
4748            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
4749        let id = entry
4750            .base_id()
4751            .cloned()
4752            .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
4753        jsonl_ids.push(id);
4754        jsonl_chain_hash = session_entry_chain_hash_step(&jsonl_chain_hash, &entry)?;
4755    }
4756
4757    // Read V2 store entries.
4758    let frames = store.read_all_entries()?;
4759    let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
4760
4761    let entry_count_match = jsonl_ids.len() == v2_ids.len() && jsonl_ids == v2_ids;
4762
4763    // Check hash chain via validate_integrity (which also verifies checksums).
4764    let index_consistent = store.validate_integrity().is_ok();
4765
4766    let hash_chain_match = jsonl_chain_hash == store.chain_hash();
4767
4768    Ok(session_store_v2::MigrationVerification {
4769        entry_count_match,
4770        hash_chain_match,
4771        index_consistent,
4772    })
4773}
4774
4775fn is_v2_sidecar_stale(jsonl_path: &Path, v2_root: &Path) -> bool {
4776    let Some(jsonl_meta) = std::fs::metadata(jsonl_path).ok() else {
4777        return true;
4778    };
4779
4780    let v2_index = v2_root.join("index").join("offsets.jsonl");
4781    let v2_manifest = v2_root.join("manifest.json");
4782    let Some(v2_meta) = std::fs::metadata(&v2_index)
4783        .or_else(|_| std::fs::metadata(&v2_manifest))
4784        .ok()
4785    else {
4786        return true;
4787    };
4788
4789    let Some(jsonl_mtime) = jsonl_meta.modified().ok() else {
4790        return true;
4791    };
4792    let Some(v2_mtime) = v2_meta.modified().ok() else {
4793        return true;
4794    };
4795
4796    jsonl_mtime > v2_mtime
4797}
4798
4799fn session_entry_chain_hash_step(prev_chain: &str, entry: &SessionEntry) -> Result<String> {
4800    let (_, _, _, payload) = session_store_v2::session_entry_to_frame_args(entry)?;
4801    let payload_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&payload)?));
4802    let mut hasher = Sha256::new();
4803    hasher.update(prev_chain.as_bytes());
4804    hasher.update(payload_sha256.as_bytes());
4805    Ok(format!("{:x}", hasher.finalize()))
4806}
4807
4808/// Remove a V2 sidecar, reverting to JSONL-only storage.
4809///
4810/// Logs a rollback event in the migration ledger before removing the sidecar.
4811/// Returns `Ok(())` if the sidecar was removed (or didn't exist).
4812pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
4813    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4814    if !v2_root.exists() {
4815        return Ok(());
4816    }
4817
4818    // Try to log the rollback event before deleting.
4819    if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
4820        let event = session_store_v2::MigrationEvent {
4821            schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
4822            migration_id: uuid::Uuid::new_v4().to_string(),
4823            phase: "rollback_to_jsonl".to_string(),
4824            at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4825            source_path: v2_root.display().to_string(),
4826            target_path: jsonl_path.display().to_string(),
4827            source_format: "native_v2".to_string(),
4828            target_format: "jsonl_v3".to_string(),
4829            verification: session_store_v2::MigrationVerification {
4830                entry_count_match: true,
4831                hash_chain_match: true,
4832                index_consistent: true,
4833            },
4834            outcome: "ok".to_string(),
4835            error_class: None,
4836            correlation_id: correlation_id.to_string(),
4837        };
4838        let _ = store.append_migration_event(event);
4839    }
4840
4841    std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4842    Ok(())
4843}
4844
4845/// Current migration state of a JSONL session.
4846#[derive(Debug, Clone, PartialEq, Eq)]
4847pub enum MigrationState {
4848    /// No V2 sidecar exists — pure JSONL.
4849    Unmigrated,
4850    /// V2 sidecar exists and passes integrity validation.
4851    Migrated,
4852    /// V2 sidecar exists but fails integrity validation.
4853    Corrupt { error: String },
4854    /// V2 sidecar directory exists but is missing critical files (partial write).
4855    Partial,
4856}
4857
4858/// Query the migration state of a JSONL session file.
4859pub fn migration_status(jsonl_path: &Path) -> MigrationState {
4860    let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4861    if !v2_root.exists() {
4862        return MigrationState::Unmigrated;
4863    }
4864
4865    let segments_dir = v2_root.join("segments");
4866    if !segments_dir.exists() {
4867        return MigrationState::Partial;
4868    }
4869
4870    let index_path = v2_root.join("index").join("offsets.jsonl");
4871    if !index_path.exists() {
4872        match jsonl_has_entry_lines(jsonl_path) {
4873            Ok(true) => return MigrationState::Partial,
4874            Ok(false) => {}
4875            Err(e) => {
4876                return MigrationState::Corrupt {
4877                    error: e.to_string(),
4878                };
4879            }
4880        }
4881    }
4882
4883    let inspector = match SessionStoreV2::open_for_inspection(&v2_root, 64 * 1024 * 1024) {
4884        Ok(store) => store,
4885        Err(e) => {
4886            return MigrationState::Corrupt {
4887                error: e.to_string(),
4888            };
4889        }
4890    };
4891
4892    match inspector.read_index() {
4893        Ok(_) => match inspector.validate_integrity() {
4894            Ok(()) => MigrationState::Migrated,
4895            Err(e) => MigrationState::Corrupt {
4896                error: e.to_string(),
4897            },
4898        },
4899        Err(e) if migration_status_can_rebuild_index(&e) => {
4900            match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
4901                Ok(store) => match verify_v2_against_jsonl(jsonl_path, &store) {
4902                    Ok(verification)
4903                        if verification.entry_count_match
4904                            && verification.hash_chain_match
4905                            && verification.index_consistent =>
4906                    {
4907                        MigrationState::Migrated
4908                    }
4909                    Ok(verification) => MigrationState::Corrupt {
4910                        error: format!(
4911                            "migration verification failed after index rebuild: count={} hash={} index={}",
4912                            verification.entry_count_match,
4913                            verification.hash_chain_match,
4914                            verification.index_consistent,
4915                        ),
4916                    },
4917                    Err(err) => MigrationState::Corrupt {
4918                        error: err.to_string(),
4919                    },
4920                },
4921                Err(err) => MigrationState::Corrupt {
4922                    error: err.to_string(),
4923                },
4924            }
4925        }
4926        Err(e) => MigrationState::Corrupt {
4927            error: e.to_string(),
4928        },
4929    }
4930}
4931
4932fn migration_status_can_rebuild_index(error: &Error) -> bool {
4933    match error {
4934        Error::Json(_) => true,
4935        Error::Io(err) => matches!(
4936            err.kind(),
4937            std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::InvalidData
4938        ),
4939        _ => false,
4940    }
4941}
4942
4943/// Dry-run a JSONL → V2 migration without persisting the sidecar.
4944///
4945/// Creates the V2 store in a temporary directory, runs verification, then
4946/// cleans up. Returns the verification result so callers can inspect
4947/// entry counts and integrity before committing.
4948pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
4949    let tmp_dir =
4950        tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
4951    let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
4952
4953    // Parse JSONL and populate a temporary V2 store.
4954    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4955    let mut reader = std::io::BufReader::new(file);
4956
4957    let Some(header_line) =
4958        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4959    else {
4960        return Err(crate::Error::session("Empty JSONL session file"));
4961    };
4962
4963    let header: SessionHeader = serde_json::from_str(header_line.trim_end())
4964        .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4965    header.validate().map_err(|reason| {
4966        crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4967    })?;
4968
4969    let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
4970
4971    loop {
4972        let Some(line) =
4973            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4974        else {
4975            break;
4976        };
4977        if line.trim().is_empty() {
4978            continue;
4979        }
4980        let entry: SessionEntry = serde_json::from_str(line.trim_end())
4981            .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
4982        let (entry_id, parent_entry_id, entry_type, payload) =
4983            session_store_v2::session_entry_to_frame_args(&entry)?;
4984        store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
4985    }
4986
4987    // Verify against source JSONL (but using the temp store).
4988    verify_v2_against_jsonl(jsonl_path, &store)
4989    // tmp_dir drops here → auto-cleanup
4990}
4991
4992/// Recover from a partial or corrupted V2 migration.
4993///
4994/// If the sidecar is in a partial/corrupt state, removes it and optionally
4995/// re-runs the migration. Returns the final migration state.
4996pub fn recover_partial_migration(
4997    jsonl_path: &Path,
4998    correlation_id: &str,
4999    re_migrate: bool,
5000) -> Result<MigrationState> {
5001    let status = migration_status(jsonl_path);
5002    match &status {
5003        MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
5004        MigrationState::Partial | MigrationState::Corrupt { .. } => {
5005            // Remove the broken sidecar.
5006            let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5007            if v2_root.exists() {
5008                std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5009            }
5010
5011            if re_migrate {
5012                migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
5013                Ok(MigrationState::Migrated)
5014            } else {
5015                Ok(MigrationState::Unmigrated)
5016            }
5017        }
5018    }
5019}
5020
5021fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
5022    let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5023    let mut reader = std::io::BufReader::new(file);
5024
5025    let Some(_line) =
5026        read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5027    else {
5028        return Err(crate::Error::session("Empty JSONL session file"));
5029    };
5030
5031    loop {
5032        let Some(line) =
5033            read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5034        else {
5035            return Ok(false);
5036        };
5037        if !line.trim().is_empty() {
5038            return Ok(true);
5039        }
5040    }
5041}
5042
5043/// Result of single-pass load finalization (Gap F).
5044///
5045/// Replaces the previous multi-pass approach (`ensure_entry_ids` +
5046/// `entry_id_set` + orphan detection + stats) with a single O(n) scan
5047/// that produces all required caches at once.
5048struct LoadFinalization {
5049    leaf_id: Option<String>,
5050    entry_ids: HashSet<String>,
5051    entry_index: HashMap<String, usize>,
5052    message_count: u64,
5053    name: Option<String>,
5054    is_linear: bool,
5055    orphans: Vec<(String, String)>,
5056}
5057
5058/// Single-pass finalization of loaded entries.
5059///
5060/// 1. Assigns IDs to entries missing them (`ensure_entry_ids` work).
5061/// 2. Builds `entry_ids` set and `entry_index` map.
5062/// 3. Detects orphaned parent links.
5063/// 4. Computes `session_entry_stats` (message count + name).
5064/// 5. Determines `is_linear` (no branching, leaf == last entry).
5065fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
5066    // First pass: assign missing IDs (same logic as `ensure_entry_ids`).
5067    let mut entry_ids: HashSet<String> = entries
5068        .iter()
5069        .filter_map(|e| e.base_id().cloned())
5070        .collect();
5071    for entry in entries.iter_mut() {
5072        if entry.base().id.is_none() {
5073            let id = generate_entry_id(&entry_ids);
5074            entry.base_mut().id = Some(id.clone());
5075            entry_ids.insert(id);
5076        }
5077    }
5078
5079    // Second (main) pass: build all caches in one scan.
5080    let mut entry_index = HashMap::with_capacity(entries.len());
5081    let mut message_count = 0u64;
5082    let mut name: Option<String> = None;
5083    let mut leaf_id: Option<String> = None;
5084    let mut orphans = Vec::new();
5085    // Track parent_ids seen as children's parent to detect branching.
5086    let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
5087    let mut has_branching = false;
5088    let mut root_count = 0u32;
5089
5090    for (idx, entry) in entries.iter().enumerate() {
5091        let Some(id) = entry.base_id() else {
5092            continue;
5093        };
5094        entry_index.insert(id.clone(), idx);
5095        leaf_id = Some(id.clone());
5096
5097        // Orphan detection.
5098        if let Some(parent_id) = entry.base().parent_id.as_ref() {
5099            if !entry_ids.contains(parent_id) {
5100                orphans.push((id.clone(), parent_id.clone()));
5101            }
5102        } else {
5103            root_count += 1;
5104        }
5105
5106        // Branch detection: if any parent_id has >1 child, it's branched.
5107        if !has_branching {
5108            let parent_key = entry.base().parent_id.as_deref();
5109            let count = parent_id_child_count.entry(parent_key).or_insert(0);
5110            *count += 1;
5111            if *count > 1 {
5112                has_branching = true;
5113            }
5114        }
5115
5116        // Stats.
5117        match entry {
5118            SessionEntry::Message(_) => message_count += 1,
5119            SessionEntry::SessionInfo(info) if info.name.is_some() => {
5120                name.clone_from(&info.name);
5121            }
5122            _ => {}
5123        }
5124    }
5125
5126    // is_linear: no branching detected in the entry set, exactly one root, and no orphans.
5127    // Note: callers (e.g. rebuild_all_caches) add the additional check that
5128    // self.leaf_id == finalized.leaf_id to confirm we're at the tip.
5129    let is_linear = !has_branching && root_count <= 1 && orphans.is_empty();
5130
5131    LoadFinalization {
5132        leaf_id,
5133        entry_ids,
5134        entry_index,
5135        message_count,
5136        name,
5137        is_linear,
5138        orphans,
5139    }
5140}
5141
5142fn parse_env_bool(value: &str) -> bool {
5143    matches!(
5144        value.trim().to_ascii_lowercase().as_str(),
5145        "1" | "true" | "yes" | "on"
5146    )
5147}
5148
5149fn session_entry_id_cache_enabled() -> bool {
5150    static ENABLED: OnceLock<bool> = OnceLock::new();
5151    *ENABLED.get_or_init(|| {
5152        std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
5153    })
5154}
5155
5156fn ensure_entry_ids(entries: &mut [SessionEntry]) {
5157    let mut existing = entry_id_set(entries);
5158    for entry in entries.iter_mut() {
5159        if entry.base().id.is_none() {
5160            let id = generate_entry_id(&existing);
5161            entry.base_mut().id = Some(id.clone());
5162            existing.insert(id);
5163        }
5164    }
5165}
5166
5167/// Generate a unique entry ID (8 hex characters), falling back to UUID on collision.
5168fn generate_entry_id(existing: &HashSet<String>) -> String {
5169    for _ in 0..100 {
5170        let uuid = uuid::Uuid::new_v4();
5171        let id = uuid.simple().to_string()[..8].to_string();
5172        if !existing.contains(&id) {
5173            return id;
5174        }
5175    }
5176    uuid::Uuid::new_v4().to_string()
5177}
5178
5179#[cfg(test)]
5180fn set_name_deadline_probe()
5181-> &'static std::sync::Mutex<Option<std::sync::mpsc::Sender<Option<asupersync::Time>>>> {
5182    static PROBE: std::sync::OnceLock<
5183        std::sync::Mutex<Option<std::sync::mpsc::Sender<Option<asupersync::Time>>>>,
5184    > = std::sync::OnceLock::new();
5185    PROBE.get_or_init(|| std::sync::Mutex::new(None))
5186}
5187
5188#[cfg(test)]
5189fn emit_set_name_deadline_probe(deadline: Option<asupersync::Time>) {
5190    let probe = set_name_deadline_probe();
5191    let guard = probe.lock().expect("lock set_name deadline probe");
5192    if let Some(tx) = guard.as_ref() {
5193        let _ = tx.send(deadline);
5194    }
5195}
5196
5197#[cfg(test)]
5198mod tests {
5199    use super::*;
5200    use crate::model::{Cost, StopReason, Usage};
5201    use asupersync::runtime::RuntimeBuilder;
5202    use asupersync::sync::Mutex as AsyncMutex;
5203    use clap::Parser;
5204    use std::env;
5205    use std::future::Future;
5206    use std::path::{Path, PathBuf};
5207    use std::sync::{Mutex as StdMutex, OnceLock};
5208    use std::time::Duration;
5209
5210    fn make_test_message(text: &str) -> SessionMessage {
5211        SessionMessage::User {
5212            content: UserContent::Text(text.to_string()),
5213            timestamp: Some(0),
5214        }
5215    }
5216
5217    fn run_async<T>(future: impl Future<Output = T>) -> T {
5218        let runtime = RuntimeBuilder::current_thread()
5219            .build()
5220            .expect("build runtime");
5221        runtime.block_on(future)
5222    }
5223
5224    fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
5225        static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
5226        LOCK.get_or_init(|| StdMutex::new(())).lock().expect("lock")
5227    }
5228
5229    struct CurrentDirGuard {
5230        previous: PathBuf,
5231    }
5232
5233    impl CurrentDirGuard {
5234        fn new(path: &Path) -> Self {
5235            let previous = env::current_dir().expect("current dir");
5236            env::set_current_dir(path).expect("set current dir");
5237            Self { previous }
5238        }
5239    }
5240
5241    impl Drop for CurrentDirGuard {
5242        fn drop(&mut self) {
5243            let _ = env::set_current_dir(&self.previous);
5244        }
5245    }
5246
5247    #[test]
5248    fn v2_open_mode_parser_supports_expected_values() {
5249        assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
5250        assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
5251        assert_eq!(
5252            parse_v2_open_mode("active_path"),
5253            Some(V2OpenMode::ActivePath)
5254        );
5255        assert_eq!(
5256            parse_v2_open_mode("active-path"),
5257            Some(V2OpenMode::ActivePath)
5258        );
5259        assert_eq!(
5260            parse_v2_open_mode("tail"),
5261            Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
5262        );
5263        assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
5264        assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
5265        assert_eq!(parse_v2_open_mode("bad-mode"), None);
5266        assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
5267    }
5268
5269    #[test]
5270    fn v2_open_mode_selection_prefers_env_override_then_threshold() {
5271        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
5272        assert_eq!(mode, V2OpenMode::Full);
5273        assert_eq!(reason, "env_override");
5274        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5275
5276        let (mode, reason, threshold) =
5277            select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
5278        assert_eq!(
5279            mode,
5280            V2OpenMode::ActivePath,
5281            "invalid threshold falls back to default threshold"
5282        );
5283        assert_eq!(reason, "entry_count_above_lazy_threshold");
5284        assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5285
5286        let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
5287        assert_eq!(mode, V2OpenMode::ActivePath);
5288        assert_eq!(reason, "entry_count_above_lazy_threshold");
5289        assert_eq!(threshold, 500);
5290
5291        let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
5292        assert_eq!(mode, V2OpenMode::Full);
5293        assert_eq!(reason, "default_full");
5294        assert_eq!(threshold, 500);
5295    }
5296
5297    #[test]
5298    fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
5299        let temp_dir = tempfile::tempdir().unwrap();
5300        let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
5301
5302        // Build a branching session:
5303        // root -> a -> b
5304        //           \-> c (active leaf)
5305        let mut seed = Session::create();
5306        seed.path = Some(path.clone());
5307        let _id_root = seed.append_message(make_test_message("root"));
5308        let id_a = seed.append_message(make_test_message("a"));
5309        let id_b = seed.append_message(make_test_message("main-branch"));
5310        assert!(seed.create_branch_from(&id_a));
5311        let id_c = seed.append_message(make_test_message("side-branch"));
5312        run_async(async { seed.save().await }).unwrap();
5313
5314        // Build sidecar and reopen in ActivePath mode.
5315        create_v2_sidecar_from_jsonl(&path).unwrap();
5316        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5317        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5318        let (mut loaded, _) =
5319            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5320        loaded.path = Some(path.clone());
5321        loaded.v2_sidecar_root = Some(v2_root);
5322        loaded.v2_partial_hydration = true;
5323        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5324
5325        let active_ids: Vec<String> = loaded
5326            .entries
5327            .iter()
5328            .filter_map(|entry| entry.base().id.clone())
5329            .collect();
5330        assert!(
5331            !active_ids.contains(&id_b),
5332            "active path intentionally excludes non-leaf sibling branch"
5333        );
5334        assert!(active_ids.contains(&id_c));
5335        assert_eq!(
5336            loaded.cached_message_count, seed.cached_message_count,
5337            "active-path resume should retain total message count metadata"
5338        );
5339        assert!(
5340            loaded.v2_message_count_offset > 0,
5341            "active-path resume should track hidden messages outside the active path"
5342        );
5343
5344        // Force full rewrite path (header dirty). Save must rehydrate first so b survives.
5345        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
5346        run_async(async { loaded.save().await }).unwrap();
5347
5348        let (reopened, _) =
5349            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
5350        let reopened_ids: Vec<String> = reopened
5351            .entries
5352            .iter()
5353            .filter_map(|entry| entry.base().id.clone())
5354            .collect();
5355        assert!(
5356            reopened_ids.contains(&id_b),
5357            "non-active branch entry must survive full rewrite after lazy hydration"
5358        );
5359        assert!(reopened_ids.contains(&id_c));
5360        assert_eq!(reopened_ids.len(), 4);
5361    }
5362
5363    #[test]
5364    fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
5365        let temp_dir = tempfile::tempdir().unwrap();
5366        let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
5367
5368        let mut seed = Session::create();
5369        seed.path = Some(path.clone());
5370        let _id_root = seed.append_message(make_test_message("root"));
5371        let id_a = seed.append_message(make_test_message("a"));
5372        let id_b = seed.append_message(make_test_message("main-branch"));
5373        assert!(seed.create_branch_from(&id_a));
5374        let _id_c = seed.append_message(make_test_message("side-branch"));
5375        run_async(async { seed.save().await }).unwrap();
5376
5377        create_v2_sidecar_from_jsonl(&path).unwrap();
5378        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5379        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5380        let (mut loaded, _) =
5381            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5382        loaded.path = Some(path.clone());
5383        loaded.v2_sidecar_root = Some(v2_root);
5384        loaded.v2_partial_hydration = true;
5385        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5386
5387        let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
5388        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
5389        run_async(async { loaded.save().await }).unwrap();
5390
5391        let (reopened, _) =
5392            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
5393        let reopened_ids: Vec<String> = reopened
5394            .entries
5395            .iter()
5396            .filter_map(|entry| entry.base().id.clone())
5397            .collect();
5398        assert!(
5399            reopened_ids.contains(&id_b),
5400            "non-active branch entry must survive rehydration+save"
5401        );
5402        assert!(
5403            reopened_ids.contains(&new_id),
5404            "pending entry appended on partial session must be preserved"
5405        );
5406        assert_eq!(reopened_ids.len(), 5);
5407    }
5408
5409    #[test]
5410    fn v2_partial_hydration_full_rewrite_uses_newer_jsonl_when_sidecar_is_stale() {
5411        let temp_dir = tempfile::tempdir().unwrap();
5412        let path = temp_dir.path().join("lazy_hydration_stale_sidecar.jsonl");
5413
5414        let mut seed = Session::create();
5415        seed.path = Some(path.clone());
5416        let _id_root = seed.append_message(make_test_message("root"));
5417        let id_a = seed.append_message(make_test_message("a"));
5418        let id_b = seed.append_message(make_test_message("main-branch"));
5419        assert!(seed.create_branch_from(&id_a));
5420        let _id_c = seed.append_message(make_test_message("side-branch"));
5421        run_async(async { seed.save().await }).unwrap();
5422
5423        create_v2_sidecar_from_jsonl(&path).unwrap();
5424        let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5425        let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5426        let (mut loaded, _) =
5427            Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5428        loaded.path = Some(path.clone());
5429        loaded.v2_sidecar_root = Some(v2_root.clone());
5430        loaded.v2_partial_hydration = true;
5431        loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5432
5433        std::thread::sleep(std::time::Duration::from_secs(1));
5434        let new_id = loaded.append_message(make_test_message("saved-before-full-rewrite"));
5435        run_async(async { loaded.save().await }).unwrap();
5436        assert!(
5437            is_v2_sidecar_stale(&path, &v2_root),
5438            "incremental JSONL save should make sidecar stale"
5439        );
5440
5441        loaded.set_model_header(Some("provider-updated".to_string()), None, None);
5442        run_async(async { loaded.save().await }).unwrap();
5443
5444        let (reopened, _) =
5445            run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
5446        let reopened_ids: Vec<String> = reopened
5447            .entries
5448            .iter()
5449            .filter_map(|entry| entry.base().id.clone())
5450            .collect();
5451        assert!(
5452            reopened_ids.contains(&id_b),
5453            "non-active branch entry must survive full rewrite after stale sidecar"
5454        );
5455        assert!(
5456            reopened_ids.contains(&new_id),
5457            "entry already saved to JSONL must not be dropped during rehydrate"
5458        );
5459        assert_eq!(reopened_ids.len(), 5);
5460    }
5461
5462    #[test]
5463    fn verify_v2_against_jsonl_detects_payload_mismatch_with_matching_ids() {
5464        let temp_dir = tempfile::tempdir().unwrap();
5465        let path = temp_dir.path().join("verify_v2_payload_mismatch.jsonl");
5466
5467        let mut session = Session::create();
5468        session.path = Some(path.clone());
5469        session.append_message(make_test_message("alpha"));
5470        session.append_message(make_test_message("beta"));
5471        run_async(async { session.save().await }).unwrap();
5472
5473        let contents = std::fs::read_to_string(&path).unwrap();
5474        let mut lines = contents.lines();
5475        let _header_line = lines.next().expect("header");
5476        let mut tampered_entries: Vec<SessionEntry> = lines
5477            .filter(|line| !line.trim().is_empty())
5478            .map(|line| serde_json::from_str(line).expect("parse session entry"))
5479            .collect();
5480
5481        let SessionEntry::Message(message_entry) = tampered_entries
5482            .first_mut()
5483            .expect("first tampered entry should exist")
5484        else {
5485            panic!("expected message entry");
5486        };
5487        let SessionMessage::User {
5488            content: UserContent::Text(text),
5489            ..
5490        } = &mut message_entry.message
5491        else {
5492            panic!("expected user text message");
5493        };
5494        *text = "alpha-tampered".to_string();
5495
5496        let tampered_root = temp_dir.path().join("verify_v2_payload_mismatch.v2");
5497        let mut tampered_store = SessionStoreV2::create(&tampered_root, 64 * 1024 * 1024).unwrap();
5498        for entry in &tampered_entries {
5499            let (entry_id, parent_entry_id, entry_type, payload) =
5500                session_store_v2::session_entry_to_frame_args(entry).unwrap();
5501            tampered_store
5502                .append_entry(entry_id, parent_entry_id, entry_type, payload)
5503                .unwrap();
5504        }
5505
5506        let verification = verify_v2_against_jsonl(&path, &tampered_store).unwrap();
5507        assert!(verification.entry_count_match);
5508        assert!(verification.index_consistent);
5509        assert!(
5510            !verification.hash_chain_match,
5511            "payload divergence must fail migration verification even when entry ids match"
5512        );
5513    }
5514
5515    #[test]
5516    fn test_session_handle_mutations_defer_persistence_side_effects() {
5517        let temp_dir = tempfile::tempdir().expect("temp dir");
5518        let mut session = Session::create();
5519        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
5520        // Point at a directory path so an eager save would fail with an IO error.
5521        session.path = Some(temp_dir.path().to_path_buf());
5522        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
5523
5524        run_async(async { handle.set_name("deferred-save".to_string()).await })
5525            .expect("set_name should not trigger immediate save");
5526        run_async(async { handle.append_message(make_test_message("hello")).await })
5527            .expect("append_message should not trigger immediate save");
5528        run_async(async {
5529            handle
5530                .append_custom_entry(
5531                    "marker".to_string(),
5532                    Some(serde_json::json!({ "value": 42 })),
5533                )
5534                .await
5535        })
5536        .expect("append_custom_entry should not trigger immediate save");
5537        run_async(async {
5538            handle
5539                .set_model("prov".to_string(), "model".to_string())
5540                .await
5541        })
5542        .expect("set_model should not trigger immediate save");
5543        run_async(async { handle.set_thinking_level("high".to_string()).await })
5544            .expect("set_thinking_level should not trigger immediate save");
5545
5546        let branch = run_async(async { handle.get_branch().await });
5547        let message_id = branch
5548            .iter()
5549            .find_map(|entry| {
5550                if entry.get("type").and_then(Value::as_str) == Some("message") {
5551                    entry
5552                        .get("id")
5553                        .and_then(Value::as_str)
5554                        .map(ToString::to_string)
5555                } else {
5556                    None
5557                }
5558            })
5559            .expect("message entry id in branch");
5560        run_async(async {
5561            handle
5562                .set_label(message_id, Some("hot-path".to_string()))
5563                .await
5564        })
5565        .expect("set_label should not trigger immediate save");
5566
5567        let state = run_async(async { handle.get_state().await });
5568        assert_eq!(
5569            state.get("sessionName").and_then(Value::as_str),
5570            Some("deferred-save")
5571        );
5572        assert_eq!(
5573            state.get("thinkingLevel").and_then(Value::as_str),
5574            Some("high")
5575        );
5576        assert_eq!(
5577            state.get("durabilityMode").and_then(Value::as_str),
5578            Some("throughput")
5579        );
5580        assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
5581        assert_eq!(
5582            state
5583                .get("model")
5584                .and_then(|model| model.get("provider"))
5585                .and_then(Value::as_str),
5586            Some("prov")
5587        );
5588        assert_eq!(
5589            state
5590                .get("model")
5591                .and_then(|model| model.get("id"))
5592                .and_then(Value::as_str),
5593            Some("model")
5594        );
5595
5596        let (provider, model_id) = run_async(async { handle.get_model().await });
5597        assert_eq!(provider.as_deref(), Some("prov"));
5598        assert_eq!(model_id.as_deref(), Some("model"));
5599    }
5600
5601    #[test]
5602    fn session_handle_set_name_inherits_cancelled_context_when_lock_is_held() {
5603        let runtime = RuntimeBuilder::current_thread()
5604            .build()
5605            .expect("build runtime");
5606
5607        runtime.block_on(async {
5608            let session = Arc::new(AsyncMutex::new(Session::in_memory()));
5609            let handle = SessionHandle(Arc::clone(&session));
5610
5611            let hold_cx = AgentCx::for_request();
5612            let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
5613
5614            let ambient_cx = asupersync::Cx::for_testing();
5615            ambient_cx.set_cancel_requested(true);
5616            let _current = asupersync::Cx::set_current(Some(ambient_cx));
5617            let inner = asupersync::time::timeout(
5618                asupersync::time::wall_now(),
5619                Duration::from_millis(100),
5620                handle.set_name("cancelled-name".to_string()),
5621            )
5622            .await;
5623            let outcome = inner.expect("cancelled helper should finish before timeout");
5624            let err = outcome.expect_err("lock acquisition should honor inherited cancellation");
5625            assert!(
5626                err.to_string().contains("Failed to lock session"),
5627                "unexpected error: {err}"
5628            );
5629
5630            drop(held_guard);
5631
5632            let state = SessionHandle(Arc::clone(&session)).get_state().await;
5633            assert!(
5634                state.get("sessionName").is_none_or(Value::is_null),
5635                "cancelled mutation should not update the session name: {state:?}"
5636            );
5637        });
5638    }
5639
5640    #[test]
5641    fn session_handle_set_name_inherits_deadline() {
5642        let runtime = RuntimeBuilder::current_thread()
5643            .build()
5644            .expect("build runtime");
5645
5646        runtime.block_on(async {
5647            struct ProbeReset;
5648            impl Drop for ProbeReset {
5649                fn drop(&mut self) {
5650                    let mut probe = set_name_deadline_probe()
5651                        .lock()
5652                        .expect("lock set_name deadline probe");
5653                    *probe = None;
5654                }
5655            }
5656
5657            let session = Arc::new(AsyncMutex::new(Session::in_memory()));
5658            let handle = SessionHandle(Arc::clone(&session));
5659
5660            let (probe_tx, probe_rx) = std::sync::mpsc::channel();
5661            {
5662                let mut probe = set_name_deadline_probe()
5663                    .lock()
5664                    .expect("lock set_name deadline probe");
5665                assert!(probe.is_none(), "set_name deadline probe already installed");
5666                *probe = Some(probe_tx);
5667            }
5668            let _probe_reset = ProbeReset;
5669
5670            let expected_deadline = asupersync::time::wall_now() + Duration::from_secs(30);
5671            let ambient_cx = AgentCx::for_request_with_budget(asupersync::Budget {
5672                deadline: Some(expected_deadline),
5673                ..asupersync::Budget::INFINITE
5674            });
5675            let _current = asupersync::Cx::set_current(Some(ambient_cx.cx().clone()));
5676            handle
5677                .set_name("deadline-name".to_string())
5678                .await
5679                .expect("set_name should succeed with inherited deadline");
5680
5681            let recorded = probe_rx
5682                .recv_timeout(Duration::from_secs(1))
5683                .expect("set_name deadline probe");
5684            assert_eq!(recorded, Some(expected_deadline));
5685
5686            let state = SessionHandle(Arc::clone(&session)).get_state().await;
5687            assert_eq!(
5688                state.get("sessionName").and_then(Value::as_str),
5689                Some("deadline-name")
5690            );
5691        });
5692    }
5693
5694    #[test]
5695    fn test_session_handle_set_model_and_thinking_level_dedupe_history() {
5696        let handle = SessionHandle(Arc::new(AsyncMutex::new(Session::in_memory())));
5697
5698        run_async(async {
5699            handle
5700                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
5701                .await
5702        })
5703        .expect("set model");
5704        run_async(async {
5705            handle
5706                .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
5707                .await
5708        })
5709        .expect("repeat model");
5710        run_async(async { handle.set_thinking_level("high".to_string()).await })
5711            .expect("set thinking");
5712        run_async(async { handle.set_thinking_level("high".to_string()).await })
5713            .expect("repeat thinking");
5714
5715        let branch = run_async(async { handle.get_branch().await });
5716        let model_changes = branch
5717            .iter()
5718            .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("model_change"))
5719            .count();
5720        let thinking_changes = branch
5721            .iter()
5722            .filter(|entry| {
5723                entry.get("type").and_then(Value::as_str) == Some("thinking_level_change")
5724            })
5725            .count();
5726        assert_eq!(model_changes, 1);
5727        assert_eq!(thinking_changes, 1);
5728    }
5729
5730    #[test]
5731    fn test_session_handle_preserves_alias_equivalent_model_state() {
5732        let mut session = Session::in_memory();
5733        session.append_model_change("google".to_string(), "gemini-2.5-pro".to_string());
5734        session.set_model_header(
5735            Some("google".to_string()),
5736            Some("gemini-2.5-pro".to_string()),
5737            None,
5738        );
5739        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
5740
5741        run_async(async {
5742            handle
5743                .set_model("gemini".to_string(), "GEMINI-2.5-PRO".to_string())
5744                .await
5745        })
5746        .expect("alias-equivalent model should dedupe");
5747
5748        let branch = run_async(async { handle.get_branch().await });
5749        let model_changes: Vec<_> = branch
5750            .iter()
5751            .filter_map(|entry| {
5752                if entry.get("type").and_then(Value::as_str) == Some("model_change") {
5753                    Some((
5754                        entry.get("provider").and_then(Value::as_str),
5755                        entry.get("modelId").and_then(Value::as_str),
5756                    ))
5757                } else {
5758                    None
5759                }
5760            })
5761            .collect();
5762        assert_eq!(
5763            model_changes,
5764            vec![(Some("google"), Some("gemini-2.5-pro"))],
5765            "alias-equivalent set_model should not append duplicate history"
5766        );
5767
5768        let (provider, model_id) = run_async(async { handle.get_model().await });
5769        assert_eq!(provider.as_deref(), Some("google"));
5770        assert_eq!(model_id.as_deref(), Some("gemini-2.5-pro"));
5771
5772        let state = run_async(async { handle.get_state().await });
5773        assert_eq!(state["model"]["provider"], "google");
5774        assert_eq!(state["model"]["id"], "gemini-2.5-pro");
5775    }
5776
5777    #[test]
5778    fn session_handle_reports_branch_local_model_and_thinking_state() {
5779        let mut session = Session::in_memory();
5780        let root_id = session.append_message(make_test_message("root"));
5781
5782        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
5783        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
5784        session.set_model_header(
5785            Some("openai".to_string()),
5786            Some("gpt-4o".to_string()),
5787            Some("low".to_string()),
5788        );
5789
5790        assert!(session.create_branch_from(&root_id));
5791        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
5792        session.append_thinking_level_change("high".to_string());
5793        session.set_model_header(
5794            Some("anthropic".to_string()),
5795            Some("claude-sonnet-4-5".to_string()),
5796            Some("high".to_string()),
5797        );
5798
5799        assert!(session.navigate_to(&branch_a_thinking));
5800
5801        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
5802        let state = run_async(async { handle.get_state().await });
5803        let (provider, model_id) = run_async(async { handle.get_model().await });
5804        let thinking_level = run_async(async { handle.get_thinking_level().await });
5805
5806        assert_eq!(provider.as_deref(), Some("openai"));
5807        assert_eq!(model_id.as_deref(), Some("gpt-4o"));
5808        assert_eq!(thinking_level.as_deref(), Some("low"));
5809        assert_eq!(
5810            state
5811                .get("model")
5812                .and_then(|model| model.get("provider"))
5813                .and_then(Value::as_str),
5814            Some("openai")
5815        );
5816        assert_eq!(
5817            state
5818                .get("model")
5819                .and_then(|model| model.get("id"))
5820                .and_then(Value::as_str),
5821            Some("gpt-4o")
5822        );
5823        assert_eq!(
5824            state.get("thinkingLevel").and_then(Value::as_str),
5825            Some("low")
5826        );
5827    }
5828
5829    #[test]
5830    fn session_handle_set_model_and_thinking_level_dedupe_on_switched_branch() {
5831        let mut session = Session::in_memory();
5832        let root_id = session.append_message(make_test_message("root"));
5833
5834        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
5835        let branch_a_thinking = session.append_thinking_level_change("low".to_string());
5836        session.set_model_header(
5837            Some("openai".to_string()),
5838            Some("gpt-4o".to_string()),
5839            Some("low".to_string()),
5840        );
5841
5842        assert!(session.create_branch_from(&root_id));
5843        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
5844        session.append_thinking_level_change("high".to_string());
5845        session.set_model_header(
5846            Some("anthropic".to_string()),
5847            Some("claude-sonnet-4-5".to_string()),
5848            Some("high".to_string()),
5849        );
5850
5851        assert!(session.navigate_to(&branch_a_thinking));
5852
5853        let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
5854
5855        run_async(async {
5856            handle
5857                .set_model("openai".to_string(), "gpt-4o".to_string())
5858                .await
5859        })
5860        .expect("same-branch model should dedupe");
5861        run_async(async { handle.set_thinking_level("low".to_string()).await })
5862            .expect("same-branch thinking should dedupe");
5863
5864        let branch = run_async(async { handle.get_branch().await });
5865        let model_changes = branch
5866            .iter()
5867            .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("model_change"))
5868            .count();
5869        let thinking_changes = branch
5870            .iter()
5871            .filter(|entry| {
5872                entry.get("type").and_then(Value::as_str) == Some("thinking_level_change")
5873            })
5874            .count();
5875
5876        assert_eq!(model_changes, 1, "expected one branch-local model_change");
5877        assert_eq!(
5878            thinking_changes, 1,
5879            "expected one branch-local thinking_level_change"
5880        );
5881    }
5882
5883    #[test]
5884    fn test_autosave_queue_coalesces_mutations_per_flush() {
5885        let temp_dir = tempfile::tempdir().expect("temp dir");
5886        let mut session = Session::create();
5887        session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
5888
5889        session.append_message(make_test_message("one"));
5890        session.append_custom_entry("marker".to_string(), None);
5891        session.append_message(make_test_message("two"));
5892
5893        let before = session.autosave_metrics();
5894        assert_eq!(before.pending_mutations, 3);
5895        assert!(before.coalesced_mutations >= 2);
5896        assert_eq!(before.flush_succeeded, 0);
5897
5898        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
5899            .expect("periodic flush");
5900
5901        let after = session.autosave_metrics();
5902        assert_eq!(after.pending_mutations, 0);
5903        assert_eq!(after.flush_started, 1);
5904        assert_eq!(after.flush_succeeded, 1);
5905        assert_eq!(after.last_flush_batch_size, 3);
5906        assert_eq!(
5907            after.last_flush_trigger,
5908            Some(AutosaveFlushTrigger::Periodic)
5909        );
5910    }
5911
5912    #[test]
5913    fn test_autosave_queue_backpressure_is_bounded() {
5914        let mut session = Session::create();
5915        session.set_autosave_queue_limit_for_test(2);
5916
5917        for i in 0..5 {
5918            session.append_message(make_test_message(&format!("message-{i}")));
5919        }
5920
5921        let metrics = session.autosave_metrics();
5922        assert_eq!(metrics.max_pending_mutations, 2);
5923        assert_eq!(metrics.pending_mutations, 2);
5924        assert_eq!(metrics.backpressure_events, 3);
5925        assert!(metrics.coalesced_mutations >= 4);
5926    }
5927
5928    #[test]
5929    fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
5930        let temp_dir = tempfile::tempdir().expect("temp dir");
5931
5932        let mut strict = Session::create();
5933        // Point at a directory path so strict shutdown flush attempts fail.
5934        strict.path = Some(temp_dir.path().to_path_buf());
5935        strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
5936        strict.append_message(make_test_message("strict"));
5937
5938        run_async(async { strict.flush_autosave_on_shutdown().await })
5939            .expect_err("strict mode should propagate shutdown flush failure");
5940        let strict_metrics = strict.autosave_metrics();
5941        assert_eq!(strict_metrics.flush_failed, 1);
5942        assert!(strict_metrics.pending_mutations > 0);
5943
5944        let mut throughput = Session::create();
5945        throughput.path = Some(temp_dir.path().to_path_buf());
5946        throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
5947        throughput.append_message(make_test_message("throughput"));
5948
5949        run_async(async { throughput.flush_autosave_on_shutdown().await })
5950            .expect("throughput mode skips shutdown flush");
5951        let throughput_metrics = throughput.autosave_metrics();
5952        assert_eq!(throughput_metrics.flush_started, 0);
5953        assert_eq!(throughput_metrics.pending_mutations, 1);
5954    }
5955
5956    #[test]
5957    fn test_session_new_prefers_cli_durability_mode_over_config() {
5958        let cli =
5959            crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
5960        let config: Config =
5961            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
5962        let session =
5963            run_async(async { Session::new(&cli, &config).await }).expect("create session");
5964        assert_eq!(
5965            session.autosave_durability_mode(),
5966            AutosaveDurabilityMode::Strict
5967        );
5968    }
5969
5970    #[test]
5971    fn test_session_new_uses_config_durability_mode_when_cli_unset() {
5972        let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
5973        let config: Config =
5974            serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
5975        let session =
5976            run_async(async { Session::new(&cli, &config).await }).expect("create session");
5977        assert_eq!(
5978            session.autosave_durability_mode(),
5979            AutosaveDurabilityMode::Throughput
5980        );
5981    }
5982
5983    #[test]
5984    fn test_resolve_autosave_durability_mode_precedence() {
5985        assert_eq!(
5986            resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
5987            AutosaveDurabilityMode::Strict
5988        );
5989        assert_eq!(
5990            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
5991            AutosaveDurabilityMode::Throughput
5992        );
5993        assert_eq!(
5994            resolve_autosave_durability_mode(None, None, Some("strict")),
5995            AutosaveDurabilityMode::Strict
5996        );
5997        assert_eq!(
5998            resolve_autosave_durability_mode(None, None, None),
5999            AutosaveDurabilityMode::Balanced
6000        );
6001    }
6002
6003    #[test]
6004    fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
6005        assert_eq!(
6006            resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
6007            AutosaveDurabilityMode::Throughput
6008        );
6009        assert_eq!(
6010            resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
6011            AutosaveDurabilityMode::Strict
6012        );
6013        assert_eq!(
6014            resolve_autosave_durability_mode(None, None, Some("bad")),
6015            AutosaveDurabilityMode::Balanced
6016        );
6017    }
6018
6019    #[test]
6020    fn test_get_share_viewer_url_matches_legacy() {
6021        assert_eq!(
6022            build_share_viewer_url(None, "gist-123"),
6023            "https://buildwithpi.ai/session/#gist-123"
6024        );
6025        assert_eq!(
6026            build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
6027            "https://example.com/session/#gist-123"
6028        );
6029        assert_eq!(
6030            build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
6031            "https://example.com/session#gist-123"
6032        );
6033        // Legacy JS uses `process.env.PI_SHARE_VIEWER_URL || DEFAULT`, so empty-string should
6034        // fall back to default.
6035        assert_eq!(
6036            build_share_viewer_url(Some(""), "gist-123"),
6037            "https://buildwithpi.ai/session/#gist-123"
6038        );
6039    }
6040
6041    #[test]
6042    fn test_session_linear_history() {
6043        let mut session = Session::in_memory();
6044
6045        let id1 = session.append_message(make_test_message("Hello"));
6046        let id2 = session.append_message(make_test_message("World"));
6047        let id3 = session.append_message(make_test_message("Test"));
6048
6049        // Check leaf is the last entry
6050        assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
6051
6052        // Check path from last entry
6053        let path = session.get_path_to_entry(&id3);
6054        assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
6055
6056        // Check only one leaf
6057        let leaves = session.list_leaves();
6058        assert_eq!(leaves.len(), 1);
6059        assert_eq!(leaves[0], id3);
6060    }
6061
6062    #[test]
6063    fn test_session_branching() {
6064        let mut session = Session::in_memory();
6065
6066        // Create linear history: A -> B -> C
6067        let id_a = session.append_message(make_test_message("A"));
6068        let id_b = session.append_message(make_test_message("B"));
6069        let id_c = session.append_message(make_test_message("C"));
6070
6071        // Now branch from B: A -> B -> D
6072        assert!(session.create_branch_from(&id_b));
6073        let id_d = session.append_message(make_test_message("D"));
6074
6075        // Should have 2 leaves: C and D
6076        let leaves = session.list_leaves();
6077        assert_eq!(leaves.len(), 2);
6078        assert!(leaves.contains(&id_c));
6079        assert!(leaves.contains(&id_d));
6080
6081        // Path to D should be A -> B -> D
6082        let path_to_d = session.get_path_to_entry(&id_d);
6083        assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
6084
6085        // Path to C should be A -> B -> C
6086        let path_to_c = session.get_path_to_entry(&id_c);
6087        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6088    }
6089
6090    #[test]
6091    fn test_session_navigation() {
6092        let mut session = Session::in_memory();
6093
6094        let id1 = session.append_message(make_test_message("First"));
6095        let id2 = session.append_message(make_test_message("Second"));
6096
6097        // Navigate to first entry
6098        assert!(session.navigate_to(&id1));
6099        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6100
6101        // Navigate to non-existent entry
6102        assert!(!session.navigate_to("nonexistent"));
6103        // leaf_id unchanged
6104        assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6105
6106        // Navigate back to second
6107        assert!(session.navigate_to(&id2));
6108        assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
6109    }
6110
6111    #[test]
6112    fn test_navigation_syncs_header_to_current_branch_metadata() {
6113        let mut session = Session::in_memory();
6114
6115        let root_id = session.append_message(make_test_message("root"));
6116        let openai_id = session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
6117        let high_id = session.append_thinking_level_change("high".to_string());
6118        let _tip_a = session.append_message(make_test_message("branch-a"));
6119
6120        assert!(session.create_branch_from(&root_id));
6121        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6122        let minimal_id = session.append_thinking_level_change("minimal".to_string());
6123        let _tip_b = session.append_message(make_test_message("branch-b"));
6124
6125        assert!(session.navigate_to(&high_id));
6126        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6127        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6128        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6129
6130        assert!(session.navigate_to(&minimal_id));
6131        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6132        assert_eq!(session.header.model_id.as_deref(), Some("claude-sonnet-4"));
6133        assert_eq!(session.header.thinking_level.as_deref(), Some("minimal"));
6134
6135        assert!(session.navigate_to(&openai_id));
6136        assert_eq!(session.header.provider.as_deref(), Some("openai"));
6137        assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6138    }
6139
6140    #[test]
6141    fn test_navigation_clears_stale_header_metadata_when_target_branch_has_no_override() {
6142        let mut session = Session::in_memory();
6143
6144        let root_id = session.append_message(make_test_message("root"));
6145        let branch_a_tip = session.append_message(make_test_message("branch-a"));
6146
6147        assert!(session.create_branch_from(&root_id));
6148        session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6149        session.append_thinking_level_change("high".to_string());
6150        session.set_model_header(
6151            Some("anthropic".to_string()),
6152            Some("claude-sonnet-4".to_string()),
6153            Some("high".to_string()),
6154        );
6155
6156        assert!(session.navigate_to(&branch_a_tip));
6157        assert!(session.header.provider.is_none());
6158        assert!(session.header.model_id.is_none());
6159        assert!(session.header.thinking_level.is_none());
6160    }
6161
6162    #[test]
6163    fn test_open_materializes_header_fallback_for_historyless_branch_navigation() {
6164        let temp = tempfile::tempdir().expect("temp dir");
6165        let path = temp.path().join("legacy-historyless-branch.jsonl");
6166
6167        let mut legacy = Session::in_memory();
6168        legacy.header.provider = Some("openai".to_string());
6169        legacy.header.model_id = Some("gpt-5.4".to_string());
6170        legacy.header.thinking_level = Some("low".to_string());
6171
6172        let root_id = legacy.append_message(make_test_message("root"));
6173        let branch_b_tip = legacy.append_message(make_test_message("branch-b"));
6174
6175        assert!(legacy.create_branch_from(&root_id));
6176        legacy.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6177        legacy.append_thinking_level_change("high".to_string());
6178        let branch_a_tip = legacy.append_message(make_test_message("branch-a"));
6179
6180        legacy.header.current_leaf = Some(branch_b_tip.clone());
6181
6182        let mut jsonl = serde_json::to_string(&legacy.header).expect("serialize legacy header");
6183        jsonl.push('\n');
6184        for entry in &legacy.entries {
6185            jsonl.push_str(&serde_json::to_string(entry).expect("serialize session entry"));
6186            jsonl.push('\n');
6187        }
6188        std::fs::write(&path, jsonl).expect("write legacy session");
6189
6190        let mut loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6191            .expect("open legacy session");
6192
6193        assert_eq!(loaded.leaf_id.as_deref(), Some(branch_b_tip.as_str()));
6194        assert_eq!(loaded.header.fallback_provider.as_deref(), Some("openai"));
6195        assert_eq!(loaded.header.fallback_model_id.as_deref(), Some("gpt-5.4"));
6196        assert_eq!(
6197            loaded.header.fallback_thinking_level.as_deref(),
6198            Some("low")
6199        );
6200
6201        assert!(loaded.navigate_to(&branch_a_tip));
6202        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
6203        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-sonnet-4"));
6204        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
6205
6206        assert!(loaded.navigate_to(&branch_b_tip));
6207        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
6208        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
6209        assert_eq!(loaded.header.thinking_level.as_deref(), Some("low"));
6210    }
6211
6212    #[test]
6213    fn test_session_get_children() {
6214        let mut session = Session::in_memory();
6215
6216        // A -> B -> C
6217        //   -> D
6218        let id_a = session.append_message(make_test_message("A"));
6219        let id_b = session.append_message(make_test_message("B"));
6220        let _id_c = session.append_message(make_test_message("C"));
6221
6222        // Branch from A
6223        session.create_branch_from(&id_a);
6224        let id_d = session.append_message(make_test_message("D"));
6225
6226        // A should have 2 children: B and D
6227        let children_a = session.get_children(Some(&id_a));
6228        assert_eq!(children_a.len(), 2);
6229        assert!(children_a.contains(&id_b));
6230        assert!(children_a.contains(&id_d));
6231
6232        // Root (None) should have 1 child: A
6233        let root_children = session.get_children(None);
6234        assert_eq!(root_children.len(), 1);
6235        assert_eq!(root_children[0], id_a);
6236    }
6237
6238    #[test]
6239    fn test_branch_summary() {
6240        let mut session = Session::in_memory();
6241
6242        // Linear: A -> B
6243        let id_a = session.append_message(make_test_message("A"));
6244        let id_b = session.append_message(make_test_message("B"));
6245
6246        let info = session.branch_summary();
6247        assert_eq!(info.total_entries, 2);
6248        assert_eq!(info.leaf_count, 1);
6249        assert_eq!(info.branch_point_count, 0);
6250
6251        // Create branch: A -> B, A -> C
6252        session.create_branch_from(&id_a);
6253        let _id_c = session.append_message(make_test_message("C"));
6254
6255        let info = session.branch_summary();
6256        assert_eq!(info.total_entries, 3);
6257        assert_eq!(info.leaf_count, 2);
6258        assert_eq!(info.branch_point_count, 1);
6259        assert!(info.branch_points.contains(&id_a));
6260        assert!(info.leaves.contains(&id_b));
6261    }
6262
6263    #[test]
6264    fn test_session_jsonl_serialization() {
6265        let temp = tempfile::tempdir().unwrap();
6266        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6267        session.header.provider = Some("anthropic".to_string());
6268        session.header.model_id = Some("claude-test".to_string());
6269        session.header.thinking_level = Some("medium".to_string());
6270
6271        let user_id = session.append_message(make_test_message("Hello"));
6272        let assistant = AssistantMessage {
6273            content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
6274            api: "anthropic".to_string(),
6275            provider: "anthropic".to_string(),
6276            model: "claude-test".to_string(),
6277            usage: Usage::default(),
6278            stop_reason: StopReason::Stop,
6279            error_message: None,
6280            timestamp: 0,
6281        };
6282        session.append_message(SessionMessage::Assistant { message: assistant });
6283        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
6284        session.append_thinking_level_change("high".to_string());
6285        session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
6286        session.append_branch_summary(user_id, "branch".to_string(), None, None);
6287        session.append_session_info(Some("my-session".to_string()));
6288
6289        run_async(async { session.save().await }).unwrap();
6290
6291        let path = session.path.clone().unwrap();
6292        let contents = std::fs::read_to_string(path).unwrap();
6293        let mut lines = contents.lines();
6294
6295        let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
6296        assert_eq!(header["type"], "session");
6297        assert_eq!(header["version"], SESSION_VERSION);
6298
6299        let mut types = Vec::new();
6300        for line in lines {
6301            let value: serde_json::Value = serde_json::from_str(line).unwrap();
6302            let entry_type = value["type"].as_str().unwrap_or_default().to_string();
6303            types.push(entry_type);
6304        }
6305
6306        assert!(types.contains(&"message".to_string()));
6307        assert!(types.contains(&"model_change".to_string()));
6308        assert!(types.contains(&"thinking_level_change".to_string()));
6309        assert!(types.contains(&"compaction".to_string()));
6310        assert!(types.contains(&"branch_summary".to_string()));
6311        assert!(types.contains(&"session_info".to_string()));
6312    }
6313
6314    #[test]
6315    fn test_save_handles_short_or_empty_session_id() {
6316        let temp = tempfile::tempdir().unwrap();
6317
6318        let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6319        short_id_session.header.id = "x".to_string();
6320        run_async(async { short_id_session.save().await }).expect("save with short id");
6321        let short_name = short_id_session
6322            .path
6323            .as_ref()
6324            .and_then(|p| p.file_name())
6325            .and_then(|n| n.to_str())
6326            .expect("short id filename");
6327        assert!(short_name.contains("_x."));
6328
6329        let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6330        empty_id_session.header.id.clear();
6331        run_async(async { empty_id_session.save().await }).expect("save with empty id");
6332        let empty_name = empty_id_session
6333            .path
6334            .as_ref()
6335            .and_then(|p| p.file_name())
6336            .and_then(|n| n.to_str())
6337            .expect("empty id filename");
6338        assert!(empty_name.contains("_session."));
6339
6340        let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6341        unsafe_id_session.header.id = "../etc/passwd".to_string();
6342        run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
6343        let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
6344        let unsafe_name = unsafe_path
6345            .file_name()
6346            .and_then(|n| n.to_str())
6347            .expect("unsafe id filename");
6348        assert!(unsafe_name.contains("____etc_p."));
6349        let expected_dir = temp
6350            .path()
6351            .join(encode_cwd(&std::env::current_dir().unwrap()));
6352        assert_eq!(
6353            unsafe_path.parent().expect("unsafe id parent"),
6354            expected_dir.as_path()
6355        );
6356    }
6357
6358    #[test]
6359    fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
6360        let temp = tempfile::tempdir().unwrap();
6361        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6362
6363        let first_id = session.append_message(make_test_message("Hello"));
6364        let second_id = session.append_message(make_test_message("World"));
6365        assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
6366
6367        run_async(async { session.save().await }).unwrap();
6368        let path = session.path.clone().expect("session path set");
6369
6370        let mut lines = std::fs::read_to_string(&path)
6371            .expect("read session")
6372            .lines()
6373            .map(str::to_string)
6374            .collect::<Vec<_>>();
6375        assert!(lines.len() >= 3, "expected header + 2 entries");
6376
6377        let corrupted_line_number = lines.len(); // 1-based
6378        let last_index = lines.len() - 1;
6379        lines[last_index] = "{ this is not json }".to_string();
6380
6381        let corrupted_path = temp.path().join("corrupted.jsonl");
6382        std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
6383            .expect("write corrupted session");
6384
6385        let (loaded, diagnostics) = run_async(async {
6386            Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
6387        })
6388        .expect("open corrupted session");
6389
6390        assert_eq!(diagnostics.skipped_entries.len(), 1);
6391        assert_eq!(
6392            diagnostics.skipped_entries[0].line_number,
6393            corrupted_line_number
6394        );
6395
6396        let warnings = diagnostics.warning_lines();
6397        assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
6398        assert!(
6399            warnings[0].starts_with(&format!(
6400                "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
6401            )),
6402            "unexpected warning: {}",
6403            warnings[0]
6404        );
6405        assert_eq!(
6406            warnings[1],
6407            "Warning: Skipped 1 corrupted entries while loading session"
6408        );
6409
6410        assert_eq!(
6411            loaded.entries.len(),
6412            session.entries.len() - 1,
6413            "expected last entry to be dropped"
6414        );
6415        assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
6416    }
6417
6418    #[test]
6419    fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
6420        let temp = tempfile::tempdir().unwrap();
6421        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6422
6423        let root_id = session.append_message(make_test_message("Hello"));
6424        session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
6425        session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
6426
6427        run_async(async { session.save().await }).unwrap();
6428        let path = session.path.clone().expect("session path set");
6429
6430        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6431            .expect("reopen session");
6432
6433        assert!(loaded.entries.iter().any(|entry| {
6434            matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary == "compacted" && compaction.tokens_before == 123)
6435        }));
6436        assert!(loaded.entries.iter().any(|entry| {
6437            matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary == "branch summary")
6438        }));
6439
6440        let html = loaded.to_html();
6441        assert!(html.contains("compacted"));
6442        assert!(html.contains("branch summary"));
6443    }
6444
6445    #[test]
6446    fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
6447        let temp = tempfile::tempdir().unwrap();
6448        let base_dir = temp.path().join("sessions");
6449
6450        let mut session = Session::create_with_dir(Some(base_dir));
6451        session.append_message(make_test_message("Hello"));
6452
6453        run_async(async { session.save().await }).expect("initial save");
6454        let path = session.path.clone().expect("session path set");
6455
6456        let path1 = path.clone();
6457        let path2 = path.clone();
6458
6459        let t1 = std::thread::spawn(move || {
6460            let runtime = RuntimeBuilder::current_thread()
6461                .build()
6462                .expect("build runtime");
6463            runtime.block_on(async move {
6464                let mut s = Session::open(path1.to_string_lossy().as_ref())
6465                    .await
6466                    .expect("open session");
6467                s.append_message(make_test_message("From thread 1"));
6468                s.save().await
6469            })
6470        });
6471
6472        let t2 = std::thread::spawn(move || {
6473            let runtime = RuntimeBuilder::current_thread()
6474                .build()
6475                .expect("build runtime");
6476            runtime.block_on(async move {
6477                let mut s = Session::open(path2.to_string_lossy().as_ref())
6478                    .await
6479                    .expect("open session");
6480                s.append_message(make_test_message("From thread 2"));
6481                s.save().await
6482            })
6483        });
6484
6485        let r1 = t1.join().expect("thread 1 join");
6486        let r2 = t2.join().expect("thread 2 join");
6487        assert!(
6488            r1.is_ok() || r2.is_ok(),
6489            "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
6490        );
6491
6492        let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6493            .expect("open after concurrent saves");
6494        assert!(!loaded.entries.is_empty());
6495    }
6496
6497    #[test]
6498    fn test_to_messages_for_current_path() {
6499        let mut session = Session::in_memory();
6500
6501        // Tree structure:
6502        // A -> B -> C
6503        //       \-> D  (D branches from B)
6504        let _id_a = session.append_message(make_test_message("A"));
6505        let id_b = session.append_message(make_test_message("B"));
6506        let _id_c = session.append_message(make_test_message("C"));
6507
6508        // Navigate to B and add D
6509        session.create_branch_from(&id_b);
6510        let id_d = session.append_message(make_test_message("D"));
6511
6512        // Current path should be A -> B -> D
6513        session.navigate_to(&id_d);
6514        let messages = session.to_messages_for_current_path();
6515        assert_eq!(messages.len(), 3);
6516
6517        // Verify content
6518        if let Message::User(user) = &messages[0] {
6519            if let UserContent::Text(text) = &user.content {
6520                assert_eq!(text, "A");
6521            }
6522        }
6523        if let Message::User(user) = &messages[2] {
6524            if let UserContent::Text(text) = &user.content {
6525                assert_eq!(text, "D");
6526            }
6527        }
6528    }
6529
6530    #[test]
6531    fn test_reset_leaf_produces_empty_current_path() {
6532        let mut session = Session::in_memory();
6533
6534        let _id_a = session.append_message(make_test_message("A"));
6535        let _id_b = session.append_message(make_test_message("B"));
6536
6537        session.reset_leaf();
6538        assert!(session.entries_for_current_path().is_empty());
6539        assert!(session.to_messages_for_current_path().is_empty());
6540
6541        // After reset, the next entry becomes a new root.
6542        let id_root = session.append_message(make_test_message("Root"));
6543        let entry = session.get_entry(&id_root).expect("entry");
6544        assert!(entry.base().parent_id.is_none());
6545    }
6546
6547    #[test]
6548    fn test_encode_cwd() {
6549        let path = std::path::Path::new("/home/user/project");
6550        let encoded = encode_cwd(path);
6551        assert!(encoded.starts_with("--"));
6552        assert!(encoded.ends_with("--"));
6553        assert!(encoded.contains("home-user-project"));
6554    }
6555
6556    // ======================================================================
6557    // Session creation and header validation
6558    // ======================================================================
6559
6560    #[test]
6561    fn test_session_header_defaults() {
6562        let header = SessionHeader::new();
6563        assert_eq!(header.r#type, "session");
6564        assert_eq!(header.version, Some(SESSION_VERSION));
6565        assert!(!header.id.is_empty());
6566        assert!(!header.timestamp.is_empty());
6567        assert!(header.provider.is_none());
6568        assert!(header.model_id.is_none());
6569        assert!(header.thinking_level.is_none());
6570        assert!(header.parent_session.is_none());
6571    }
6572
6573    #[test]
6574    fn test_session_create_produces_unique_ids() {
6575        let s1 = Session::create();
6576        let s2 = Session::create();
6577        assert_ne!(s1.header.id, s2.header.id);
6578    }
6579
6580    #[test]
6581    fn test_in_memory_session_has_no_path() {
6582        let session = Session::in_memory();
6583        assert!(session.path.is_none());
6584        assert!(session.leaf_id.is_none());
6585        assert!(session.entries.is_empty());
6586    }
6587
6588    #[test]
6589    fn test_create_with_dir_stores_session_dir() {
6590        let temp = tempfile::tempdir().unwrap();
6591        let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6592        assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
6593    }
6594
6595    // ======================================================================
6596    // Message types: tool result, bash execution, custom
6597    // ======================================================================
6598
6599    #[test]
6600    fn test_append_tool_result_message() {
6601        let mut session = Session::in_memory();
6602        let user_id = session.append_message(make_test_message("Hello"));
6603
6604        let tool_msg = SessionMessage::ToolResult {
6605            tool_call_id: "call_123".to_string(),
6606            tool_name: "read".to_string(),
6607            content: vec![ContentBlock::Text(TextContent::new("file contents"))],
6608            details: None,
6609            is_error: false,
6610            timestamp: Some(1000),
6611        };
6612        let tool_id = session.append_message(tool_msg);
6613
6614        // Verify parent linking
6615        let entry = session.get_entry(&tool_id).unwrap();
6616        assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
6617
6618        // Verify it converts to model message
6619        let messages = session.to_messages();
6620        assert_eq!(messages.len(), 2);
6621        assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id == "call_123"));
6622    }
6623
6624    #[test]
6625    fn test_append_tool_result_error() {
6626        let mut session = Session::in_memory();
6627        session.append_message(make_test_message("Hello"));
6628
6629        let tool_msg = SessionMessage::ToolResult {
6630            tool_call_id: "call_err".to_string(),
6631            tool_name: "bash".to_string(),
6632            content: vec![ContentBlock::Text(TextContent::new("command not found"))],
6633            details: None,
6634            is_error: true,
6635            timestamp: Some(2000),
6636        };
6637        let tool_id = session.append_message(tool_msg);
6638
6639        let entry = session.get_entry(&tool_id).expect("should find tool entry");
6640        if let SessionEntry::Message(msg) = entry {
6641            if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
6642                assert!(is_error);
6643            } else {
6644                panic!("Expected SessionMessage::ToolResult, got {:?}", msg.message);
6645            }
6646        } else {
6647            panic!("Expected SessionEntry::Message");
6648        }
6649    }
6650
6651    #[test]
6652    fn test_append_bash_execution() {
6653        let mut session = Session::in_memory();
6654        session.append_message(make_test_message("run something"));
6655
6656        let bash_id = session.append_bash_execution(
6657            "echo hello".to_string(),
6658            "hello\n".to_string(),
6659            0,
6660            false,
6661            false,
6662            None,
6663        );
6664
6665        let entry = session.get_entry(&bash_id).expect("should find bash entry");
6666        if let SessionEntry::Message(msg) = entry {
6667            if let SessionMessage::BashExecution {
6668                command, exit_code, ..
6669            } = &msg.message
6670            {
6671                assert_eq!(command, "echo hello");
6672                assert_eq!(*exit_code, 0);
6673            } else {
6674                panic!(
6675                    "Expected SessionMessage::BashExecution, got {:?}",
6676                    msg.message
6677                );
6678            }
6679        } else {
6680            panic!("Expected SessionEntry::Message");
6681        }
6682
6683        // BashExecution converts to User message for model context
6684        let messages = session.to_messages();
6685        assert_eq!(messages.len(), 2);
6686        assert!(matches!(&messages[1], Message::User(_)));
6687    }
6688
6689    #[test]
6690    fn test_bash_execution_exclude_from_context() {
6691        let mut session = Session::in_memory();
6692        session.append_message(make_test_message("run something"));
6693
6694        let id = session.next_entry_id();
6695        let base = EntryBase::new(session.leaf_id.clone(), id.clone());
6696        let mut extra = HashMap::new();
6697        extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
6698        let entry = SessionEntry::Message(MessageEntry {
6699            base,
6700            message: SessionMessage::BashExecution {
6701                command: "secret".to_string(),
6702                output: "hidden".to_string(),
6703                exit_code: 0,
6704                cancelled: None,
6705                truncated: None,
6706                full_output_path: None,
6707                timestamp: Some(0),
6708                extra,
6709            },
6710        });
6711        session.leaf_id = Some(id);
6712        session.entries.push(entry);
6713        session.entry_ids = entry_id_set(&session.entries);
6714
6715        // The excluded bash execution should not appear in model messages
6716        let messages = session.to_messages();
6717        assert_eq!(messages.len(), 1); // only the user message
6718    }
6719
6720    #[test]
6721    fn test_append_custom_message() {
6722        let mut session = Session::in_memory();
6723        session.append_message(make_test_message("Hello"));
6724
6725        let custom_msg = SessionMessage::Custom {
6726            custom_type: "extension_state".to_string(),
6727            content: "some state".to_string(),
6728            display: false,
6729            details: Some(serde_json::json!({"key": "value"})),
6730            timestamp: Some(0),
6731        };
6732        let custom_id = session.append_message(custom_msg);
6733
6734        let entry = session
6735            .get_entry(&custom_id)
6736            .expect("should find custom entry");
6737        if let SessionEntry::Message(msg) = entry {
6738            if let SessionMessage::Custom {
6739                custom_type,
6740                display,
6741                ..
6742            } = &msg.message
6743            {
6744                assert_eq!(custom_type, "extension_state");
6745                assert!(!display);
6746            } else {
6747                panic!("Expected SessionMessage::Custom, got {:?}", msg.message);
6748            }
6749        } else {
6750            panic!("Expected SessionEntry::Message");
6751        }
6752    }
6753
6754    #[test]
6755    fn test_append_custom_entry() {
6756        let mut session = Session::in_memory();
6757        let root_id = session.append_message(make_test_message("Hello"));
6758
6759        let custom_id =
6760            session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
6761
6762        let entry = session
6763            .get_entry(&custom_id)
6764            .expect("should find custom entry");
6765        if let SessionEntry::Custom(custom) = entry {
6766            assert_eq!(custom.custom_type, "my_type");
6767            assert_eq!(custom.data, Some(serde_json::json!(42)));
6768            assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
6769        } else {
6770            panic!("Expected SessionEntry::Custom, got {:?}", entry);
6771        }
6772    }
6773
6774    // ======================================================================
6775    // Parent linking / tree structure
6776    // ======================================================================
6777
6778    #[test]
6779    fn test_parent_linking_chain() {
6780        let mut session = Session::in_memory();
6781
6782        let id1 = session.append_message(make_test_message("A"));
6783        let id2 = session.append_message(make_test_message("B"));
6784        let id3 = session.append_message(make_test_message("C"));
6785
6786        // First entry has no parent
6787        let e1 = session.get_entry(&id1).unwrap();
6788        assert!(e1.base().parent_id.is_none());
6789
6790        // Second entry's parent is first
6791        let e2 = session.get_entry(&id2).unwrap();
6792        assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
6793
6794        // Third entry's parent is second
6795        let e3 = session.get_entry(&id3).unwrap();
6796        assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
6797    }
6798
6799    #[test]
6800    fn test_model_change_updates_leaf() {
6801        let mut session = Session::in_memory();
6802
6803        let msg_id = session.append_message(make_test_message("Hello"));
6804        let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
6805
6806        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
6807
6808        let entry = session
6809            .get_entry(&change_id)
6810            .expect("should find change entry");
6811        assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
6812
6813        if let SessionEntry::ModelChange(mc) = entry {
6814            assert_eq!(mc.provider, "openai");
6815            assert_eq!(mc.model_id, "gpt-4");
6816        } else {
6817            panic!("Expected SessionEntry::ModelChange, got {:?}", entry);
6818        }
6819    }
6820
6821    #[test]
6822    fn test_thinking_level_change_updates_leaf() {
6823        let mut session = Session::in_memory();
6824        session.append_message(make_test_message("Hello"));
6825
6826        let change_id = session.append_thinking_level_change("high".to_string());
6827        assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
6828
6829        let entry = session
6830            .get_entry(&change_id)
6831            .expect("should find change entry");
6832        if let SessionEntry::ThinkingLevelChange(tlc) = entry {
6833            assert_eq!(tlc.thinking_level, "high");
6834        } else {
6835            panic!(
6836                "Expected SessionEntry::ThinkingLevelChange, got {:?}",
6837                entry
6838            );
6839        }
6840    }
6841
6842    // ======================================================================
6843    // Session name get/set
6844    // ======================================================================
6845
6846    #[test]
6847    fn test_get_name_returns_latest() {
6848        let mut session = Session::in_memory();
6849
6850        assert!(session.get_name().is_none());
6851
6852        session.set_name("first");
6853        assert_eq!(session.get_name().as_deref(), Some("first"));
6854
6855        session.set_name("second");
6856        assert_eq!(session.get_name().as_deref(), Some("second"));
6857    }
6858
6859    #[test]
6860    fn test_set_name_returns_entry_id() {
6861        let mut session = Session::in_memory();
6862        let id = session.set_name("test-name");
6863        assert!(!id.is_empty());
6864        let entry = session.get_entry(&id).unwrap();
6865        assert!(matches!(entry, SessionEntry::SessionInfo(_)));
6866    }
6867
6868    // ======================================================================
6869    // Label
6870    // ======================================================================
6871
6872    #[test]
6873    fn test_add_label_to_existing_entry() {
6874        let mut session = Session::in_memory();
6875        let msg_id = session.append_message(make_test_message("Hello"));
6876
6877        let label_id = session.add_label(&msg_id, Some("important".to_string()));
6878        assert!(label_id.is_some());
6879
6880        let entry = session
6881            .get_entry(&label_id.unwrap())
6882            .expect("should find label entry");
6883        if let SessionEntry::Label(label) = entry {
6884            assert_eq!(label.target_id, msg_id);
6885            assert_eq!(label.label.as_deref(), Some("important"));
6886        } else {
6887            panic!("Expected SessionEntry::Label, got {:?}", entry);
6888        }
6889    }
6890
6891    #[test]
6892    fn test_add_label_to_nonexistent_entry_returns_none() {
6893        let mut session = Session::in_memory();
6894        let result = session.add_label("nonexistent", Some("label".to_string()));
6895        assert!(result.is_none());
6896    }
6897
6898    // ======================================================================
6899    // JSONL round-trip (save + reload)
6900    // ======================================================================
6901
6902    #[test]
6903    fn test_round_trip_preserves_all_message_types() {
6904        let temp = tempfile::tempdir().unwrap();
6905        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6906
6907        // Append diverse message types
6908        session.append_message(make_test_message("user text"));
6909
6910        let assistant = AssistantMessage {
6911            content: vec![ContentBlock::Text(TextContent::new("response"))],
6912            api: "anthropic".to_string(),
6913            provider: "anthropic".to_string(),
6914            model: "claude-test".to_string(),
6915            usage: Usage::default(),
6916            stop_reason: StopReason::Stop,
6917            error_message: None,
6918            timestamp: 0,
6919        };
6920        session.append_message(SessionMessage::Assistant { message: assistant });
6921
6922        session.append_message(SessionMessage::ToolResult {
6923            tool_call_id: "call_1".to_string(),
6924            tool_name: "read".to_string(),
6925            content: vec![ContentBlock::Text(TextContent::new("result"))],
6926            details: None,
6927            is_error: false,
6928            timestamp: Some(100),
6929        });
6930
6931        session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
6932
6933        session.append_custom_entry(
6934            "ext_data".to_string(),
6935            Some(serde_json::json!({"foo": "bar"})),
6936        );
6937
6938        run_async(async { session.save().await }).unwrap();
6939        let path = session.path.clone().unwrap();
6940
6941        let loaded =
6942            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6943
6944        assert_eq!(loaded.entries.len(), session.entries.len());
6945        assert_eq!(loaded.header.id, session.header.id);
6946        assert_eq!(loaded.header.version, Some(SESSION_VERSION));
6947
6948        // Verify specific entry types survived the round-trip
6949        let has_tool_result = loaded.entries.iter().any(|e| {
6950            matches!(
6951                e,
6952                SessionEntry::Message(m) if matches!(
6953                    &m.message,
6954                    SessionMessage::ToolResult { tool_name, .. } if tool_name == "read"
6955                )
6956            )
6957        });
6958        assert!(has_tool_result, "tool result should survive round-trip");
6959
6960        let has_bash = loaded.entries.iter().any(|e| {
6961            matches!(
6962                e,
6963                SessionEntry::Message(m) if matches!(
6964                    &m.message,
6965                    SessionMessage::BashExecution { command, .. } if command == "ls"
6966                )
6967            )
6968        });
6969        assert!(has_bash, "bash execution should survive round-trip");
6970
6971        let has_custom = loaded.entries.iter().any(|e| {
6972            matches!(
6973                e,
6974                SessionEntry::Custom(c) if c.custom_type == "ext_data"
6975            )
6976        });
6977        assert!(has_custom, "custom entry should survive round-trip");
6978    }
6979
6980    #[test]
6981    fn test_round_trip_preserves_leaf_id() {
6982        let temp = tempfile::tempdir().unwrap();
6983        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6984
6985        let _id1 = session.append_message(make_test_message("A"));
6986        let id2 = session.append_message(make_test_message("B"));
6987
6988        run_async(async { session.save().await }).unwrap();
6989        let path = session.path.clone().unwrap();
6990
6991        let loaded =
6992            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6993
6994        assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
6995    }
6996
6997    #[test]
6998    fn test_round_trip_preserves_selected_branch_leaf_and_header_state() {
6999        let temp = tempfile::tempdir().unwrap();
7000        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7001
7002        let root_id = session.append_message(make_test_message("root"));
7003        let _openai_model =
7004            session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
7005        session.set_model_header(
7006            Some("openai".to_string()),
7007            Some("gpt-5.4".to_string()),
7008            None,
7009        );
7010        let high_id = session.append_thinking_level_change("high".to_string());
7011        session.set_model_header(None, None, Some("high".to_string()));
7012
7013        assert!(session.create_branch_from(&root_id));
7014        let _anthropic_model =
7015            session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
7016        session.set_model_header(
7017            Some("anthropic".to_string()),
7018            Some("claude-sonnet-4".to_string()),
7019            None,
7020        );
7021        session.append_thinking_level_change("medium".to_string());
7022        session.set_model_header(None, None, Some("medium".to_string()));
7023
7024        assert!(session.navigate_to(&high_id));
7025
7026        run_async(async { session.save().await }).unwrap();
7027        let path = session.path.clone().unwrap();
7028
7029        let loaded =
7030            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7031
7032        assert_eq!(loaded.leaf_id.as_deref(), Some(high_id.as_str()));
7033        assert_eq!(
7034            loaded.header.current_leaf.as_deref(),
7035            Some(high_id.as_str())
7036        );
7037        assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
7038        assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
7039        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
7040    }
7041
7042    #[test]
7043    fn test_append_after_branch_navigation_clears_persisted_leaf_override() {
7044        let temp = tempfile::tempdir().unwrap();
7045        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7046
7047        let id_a = session.append_message(make_test_message("A"));
7048        let id_b = session.append_message(make_test_message("B"));
7049        session.append_message(make_test_message("C"));
7050
7051        assert!(session.create_branch_from(&id_a));
7052        session.append_message(make_test_message("D"));
7053
7054        assert!(session.navigate_to(&id_b));
7055        let id_e = session.append_message(make_test_message("E"));
7056
7057        run_async(async { session.save().await }).unwrap();
7058        let path = session.path.clone().unwrap();
7059
7060        let loaded =
7061            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7062
7063        assert_eq!(loaded.leaf_id.as_deref(), Some(id_e.as_str()));
7064        assert!(loaded.header.current_leaf.is_none());
7065    }
7066
7067    #[test]
7068    fn test_round_trip_preserves_header_fields() {
7069        let temp = tempfile::tempdir().unwrap();
7070        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7071        session.header.provider = Some("anthropic".to_string());
7072        session.header.model_id = Some("claude-opus".to_string());
7073        session.header.thinking_level = Some("high".to_string());
7074        session.header.parent_session = Some("/old/session.jsonl".to_string());
7075
7076        session.append_message(make_test_message("Hello"));
7077        run_async(async { session.save().await }).unwrap();
7078        let path = session.path.clone().unwrap();
7079
7080        let loaded =
7081            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7082
7083        assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
7084        assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
7085        assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
7086        assert_eq!(
7087            loaded.header.parent_session.as_deref(),
7088            Some("/old/session.jsonl")
7089        );
7090    }
7091
7092    #[test]
7093    fn test_empty_session_save_and_reload() {
7094        let temp = tempfile::tempdir().unwrap();
7095        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7096
7097        run_async(async { session.save().await }).unwrap();
7098        let path = session.path.clone().unwrap();
7099
7100        let loaded =
7101            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7102
7103        assert!(loaded.entries.is_empty());
7104        assert!(loaded.leaf_id.is_none());
7105        assert_eq!(loaded.header.id, session.header.id);
7106    }
7107
7108    // ======================================================================
7109    // Corrupted JSONL recovery
7110    // ======================================================================
7111
7112    #[test]
7113    fn test_corrupted_middle_entry_preserves_surrounding_entries() {
7114        let temp = tempfile::tempdir().unwrap();
7115        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7116
7117        let id1 = session.append_message(make_test_message("First"));
7118        let id2 = session.append_message(make_test_message("Second"));
7119        let id3 = session.append_message(make_test_message("Third"));
7120
7121        run_async(async { session.save().await }).unwrap();
7122        let path = session.path.clone().unwrap();
7123
7124        // Corrupt the middle entry (line 3, 1-indexed: header=1, first=2, second=3)
7125        let mut lines: Vec<String> = std::fs::read_to_string(&path)
7126            .unwrap()
7127            .lines()
7128            .map(str::to_string)
7129            .collect();
7130        assert!(lines.len() >= 4);
7131        lines[2] = "GARBAGE JSON".to_string();
7132        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7133
7134        let (loaded, diagnostics) = run_async(async {
7135            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7136        })
7137        .unwrap();
7138
7139        let diag = serde_json::json!({
7140            "fixture_id": "session-corrupted-middle-entry-replay-integrity",
7141            "path": path.display().to_string(),
7142            "seed": "deterministic-static",
7143            "env": {
7144                "os": std::env::consts::OS,
7145                "arch": std::env::consts::ARCH,
7146            },
7147            "expected": {
7148                "skipped_entries": 1,
7149                "orphaned_parent_links": 1,
7150            },
7151            "actual": {
7152                "skipped_entries": diagnostics.skipped_entries.len(),
7153                "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
7154                "leaf_id": loaded.leaf_id,
7155            },
7156        })
7157        .to_string();
7158
7159        assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
7160        assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
7161        assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
7162        assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
7163        assert_eq!(
7164            diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
7165            "{diag}"
7166        );
7167        assert!(
7168            diagnostics.warning_lines().iter().any(|line| {
7169                line.contains("references missing parent")
7170                    && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
7171            }),
7172            "{diag}"
7173        );
7174
7175        // First and third entries should survive
7176        assert_eq!(loaded.entries.len(), 2, "{diag}");
7177        assert!(loaded.get_entry(&id1).is_some(), "{diag}");
7178        assert!(loaded.get_entry(&id3).is_some(), "{diag}");
7179    }
7180
7181    #[test]
7182    fn test_multiple_corrupted_entries_recovery() {
7183        let temp = tempfile::tempdir().unwrap();
7184        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7185
7186        session.append_message(make_test_message("A"));
7187        session.append_message(make_test_message("B"));
7188        session.append_message(make_test_message("C"));
7189        session.append_message(make_test_message("D"));
7190
7191        run_async(async { session.save().await }).unwrap();
7192        let path = session.path.clone().unwrap();
7193
7194        let mut lines: Vec<String> = std::fs::read_to_string(&path)
7195            .unwrap()
7196            .lines()
7197            .map(str::to_string)
7198            .collect();
7199        // Corrupt entries B (line 3) and D (line 5)
7200        lines[2] = "BAD".to_string();
7201        lines[4] = "ALSO BAD".to_string();
7202        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7203
7204        let (loaded, diagnostics) = run_async(async {
7205            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7206        })
7207        .unwrap();
7208
7209        assert_eq!(diagnostics.skipped_entries.len(), 2);
7210        assert_eq!(loaded.entries.len(), 2); // A and C survive
7211    }
7212
7213    #[test]
7214    fn test_corrupted_header_fails_to_open() {
7215        let temp = tempfile::tempdir().unwrap();
7216        let path = temp.path().join("bad_header.jsonl");
7217        std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
7218
7219        let result = run_async(async {
7220            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7221        });
7222        assert!(
7223            result.is_err(),
7224            "corrupted header should cause open failure"
7225        );
7226    }
7227
7228    // ======================================================================
7229    // Branching and navigation
7230    // ======================================================================
7231
7232    #[test]
7233    fn test_create_branch_from_nonexistent_returns_false() {
7234        let mut session = Session::in_memory();
7235        session.append_message(make_test_message("A"));
7236        assert!(!session.create_branch_from("nonexistent"));
7237    }
7238
7239    #[test]
7240    fn test_deep_branching() {
7241        let mut session = Session::in_memory();
7242
7243        // Create A -> B -> C
7244        let id_a = session.append_message(make_test_message("A"));
7245        let id_b = session.append_message(make_test_message("B"));
7246        let _id_c = session.append_message(make_test_message("C"));
7247
7248        // Branch from A: A -> D
7249        session.create_branch_from(&id_a);
7250        let _id_d = session.append_message(make_test_message("D"));
7251
7252        // Branch from B: A -> B -> E
7253        session.create_branch_from(&id_b);
7254        let id_e = session.append_message(make_test_message("E"));
7255
7256        // Should have 3 leaves: C, D, E
7257        let leaves = session.list_leaves();
7258        assert_eq!(leaves.len(), 3);
7259
7260        // Path to E is A -> B -> E
7261        let path = session.get_path_to_entry(&id_e);
7262        assert_eq!(path.len(), 3);
7263        assert_eq!(path[0], id_a);
7264        assert_eq!(path[1], id_b);
7265        assert_eq!(path[2], id_e);
7266    }
7267
7268    #[test]
7269    fn test_sibling_branches_at_fork() {
7270        let mut session = Session::in_memory();
7271
7272        // Create A -> B -> C
7273        let id_a = session.append_message(make_test_message("A"));
7274        let _id_b = session.append_message(make_test_message("B"));
7275        let _id_c = session.append_message(make_test_message("C"));
7276
7277        // Branch from A: A -> D
7278        session.create_branch_from(&id_a);
7279        let id_d = session.append_message(make_test_message("D"));
7280
7281        // Navigate to D to make it current
7282        session.navigate_to(&id_d);
7283
7284        let siblings = session.sibling_branches();
7285        assert!(siblings.is_some());
7286        let (fork_point, branches) = siblings.unwrap();
7287        assert!(fork_point.is_none() || fork_point.as_deref() == Some(id_a.as_str()));
7288        assert_eq!(branches.len(), 2);
7289
7290        // One should be current, one not
7291        let current_count = branches.iter().filter(|b| b.is_current).count();
7292        assert_eq!(current_count, 1);
7293    }
7294
7295    #[test]
7296    fn test_sibling_branches_no_fork() {
7297        let mut session = Session::in_memory();
7298        session.append_message(make_test_message("A"));
7299        session.append_message(make_test_message("B"));
7300
7301        // No fork points, so sibling_branches returns None
7302        assert!(session.sibling_branches().is_none());
7303    }
7304
7305    // ======================================================================
7306    // Plan fork
7307    // ======================================================================
7308
7309    #[test]
7310    fn test_plan_fork_from_user_message() {
7311        let mut session = Session::in_memory();
7312
7313        let _id_a = session.append_message(make_test_message("First question"));
7314        let assistant = AssistantMessage {
7315            content: vec![ContentBlock::Text(TextContent::new("Answer"))],
7316            api: "anthropic".to_string(),
7317            provider: "anthropic".to_string(),
7318            model: "test".to_string(),
7319            usage: Usage::default(),
7320            stop_reason: StopReason::Stop,
7321            error_message: None,
7322            timestamp: 0,
7323        };
7324        let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
7325        let id_c = session.append_message(make_test_message("Second question"));
7326
7327        // Fork from the second user message
7328        let plan = session.plan_fork_from_user_message(&id_c).unwrap();
7329        assert_eq!(plan.selected_text, "Second question");
7330        // Entries should be the path up to (but not including) the forked message
7331        assert_eq!(plan.entries.len(), 2); // A and B
7332    }
7333
7334    #[test]
7335    fn test_plan_fork_from_root_message() {
7336        let mut session = Session::in_memory();
7337        let id_a = session.append_message(make_test_message("Root question"));
7338
7339        let plan = session.plan_fork_from_user_message(&id_a).unwrap();
7340        assert_eq!(plan.selected_text, "Root question");
7341        assert!(plan.entries.is_empty()); // No entries before root
7342        assert!(plan.leaf_id.is_none());
7343    }
7344
7345    #[test]
7346    fn test_plan_fork_from_nonexistent_fails() {
7347        let session = Session::in_memory();
7348        assert!(session.plan_fork_from_user_message("nonexistent").is_err());
7349    }
7350
7351    #[test]
7352    fn test_plan_fork_from_assistant_message_fails() {
7353        let mut session = Session::in_memory();
7354        session.append_message(make_test_message("Q"));
7355        let assistant = AssistantMessage {
7356            content: vec![ContentBlock::Text(TextContent::new("A"))],
7357            api: "anthropic".to_string(),
7358            provider: "anthropic".to_string(),
7359            model: "test".to_string(),
7360            usage: Usage::default(),
7361            stop_reason: StopReason::Stop,
7362            error_message: None,
7363            timestamp: 0,
7364        };
7365        let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
7366
7367        assert!(session.plan_fork_from_user_message(&asst_id).is_err());
7368    }
7369
7370    // ======================================================================
7371    // Compaction in message context
7372    // ======================================================================
7373
7374    #[test]
7375    fn test_compaction_truncates_model_context() {
7376        let mut session = Session::in_memory();
7377
7378        let _id_a = session.append_message(make_test_message("old message A"));
7379        let _id_b = session.append_message(make_test_message("old message B"));
7380        let id_c = session.append_message(make_test_message("kept message C"));
7381
7382        // Compact: keep from id_c onwards
7383        session.append_compaction(
7384            "Summary of old messages".to_string(),
7385            id_c,
7386            5000,
7387            None,
7388            None,
7389        );
7390
7391        let id_d = session.append_message(make_test_message("new message D"));
7392
7393        // Ensure we're at the right leaf
7394        session.navigate_to(&id_d);
7395
7396        let messages = session.to_messages_for_current_path();
7397        // Should have: compaction summary + kept message C + new message D
7398        // (old messages A and B should be omitted)
7399        assert!(messages.len() <= 4); // compaction summary + C + compaction entry + D
7400
7401        // Verify old messages are not in context
7402        let all_text: String = messages
7403            .iter()
7404            .filter_map(|m| match m {
7405                Message::User(u) => match &u.content {
7406                    UserContent::Text(t) => Some(t.clone()),
7407                    UserContent::Blocks(blocks) => {
7408                        let texts: Vec<String> = blocks
7409                            .iter()
7410                            .filter_map(|b| {
7411                                if let ContentBlock::Text(t) = b {
7412                                    Some(t.text.clone())
7413                                } else {
7414                                    None
7415                                }
7416                            })
7417                            .collect();
7418                        Some(texts.join(" "))
7419                    }
7420                },
7421                _ => None,
7422            })
7423            .collect::<Vec<_>>()
7424            .join(" ");
7425
7426        assert!(
7427            !all_text.contains("old message A"),
7428            "compacted message A should not appear in context"
7429        );
7430        assert!(
7431            !all_text.contains("old message B"),
7432            "compacted message B should not appear in context"
7433        );
7434        assert!(
7435            all_text.contains("kept message C") || all_text.contains("new message D"),
7436            "kept messages should appear in context"
7437        );
7438    }
7439
7440    // ======================================================================
7441    // Large session handling
7442    // ======================================================================
7443
7444    #[test]
7445    fn test_large_session_append_and_path() {
7446        let mut session = Session::in_memory();
7447
7448        let mut last_id = String::new();
7449        for i in 0..500 {
7450            last_id = session.append_message(make_test_message(&format!("msg-{i}")));
7451        }
7452
7453        assert_eq!(session.entries.len(), 500);
7454        assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
7455
7456        // Path from root to leaf should include all 500 entries
7457        let path = session.get_path_to_entry(&last_id);
7458        assert_eq!(path.len(), 500);
7459
7460        // Entries for current path should also be 500
7461        let current = session.entries_for_current_path();
7462        assert_eq!(current.len(), 500);
7463    }
7464
7465    #[test]
7466    fn test_large_session_save_and_reload() {
7467        let temp = tempfile::tempdir().unwrap();
7468        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7469
7470        for i in 0..200 {
7471            session.append_message(make_test_message(&format!("message {i}")));
7472        }
7473
7474        run_async(async { session.save().await }).unwrap();
7475        let path = session.path.clone().unwrap();
7476
7477        let loaded =
7478            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7479
7480        assert_eq!(loaded.entries.len(), 200);
7481        assert_eq!(loaded.header.id, session.header.id);
7482    }
7483
7484    // ======================================================================
7485    // Entry ID generation
7486    // ======================================================================
7487
7488    #[test]
7489    fn test_ensure_entry_ids_fills_missing() {
7490        let mut entries = vec![
7491            SessionEntry::Message(MessageEntry {
7492                base: EntryBase {
7493                    id: None,
7494                    parent_id: None,
7495                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
7496                },
7497                message: SessionMessage::User {
7498                    content: UserContent::Text("test".to_string()),
7499                    timestamp: Some(0),
7500                },
7501            }),
7502            SessionEntry::Message(MessageEntry {
7503                base: EntryBase {
7504                    id: Some("existing".to_string()),
7505                    parent_id: None,
7506                    timestamp: "2025-01-01T00:00:00.000Z".to_string(),
7507                },
7508                message: SessionMessage::User {
7509                    content: UserContent::Text("test2".to_string()),
7510                    timestamp: Some(0),
7511                },
7512            }),
7513        ];
7514
7515        ensure_entry_ids(&mut entries);
7516
7517        // First entry should now have an ID
7518        assert!(entries[0].base().id.is_some());
7519        // Second entry should keep its existing ID
7520        assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
7521        // IDs should be unique
7522        assert_ne!(entries[0].base().id, entries[1].base().id);
7523    }
7524
7525    #[test]
7526    fn test_generate_entry_id_produces_8_char_hex() {
7527        let existing = HashSet::new();
7528        let id = generate_entry_id(&existing);
7529        assert_eq!(id.len(), 8);
7530        assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
7531    }
7532
7533    // ======================================================================
7534    // set_model_header / set_branched_from
7535    // ======================================================================
7536
7537    #[test]
7538    fn test_set_model_header() {
7539        let mut session = Session::in_memory();
7540        session.set_model_header(
7541            Some("anthropic".to_string()),
7542            Some("claude-opus".to_string()),
7543            Some("high".to_string()),
7544        );
7545        assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
7546        assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
7547        assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
7548    }
7549
7550    #[test]
7551    fn test_effective_model_and_thinking_use_current_header_without_change_entries() {
7552        let mut session = Session::in_memory();
7553        session.set_model_header(
7554            Some("openai".to_string()),
7555            Some("gpt-5.4".to_string()),
7556            Some("medium".to_string()),
7557        );
7558
7559        assert_eq!(
7560            session.effective_model_for_current_path(),
7561            Some(("openai".to_string(), "gpt-5.4".to_string()))
7562        );
7563        assert_eq!(
7564            session
7565                .effective_thinking_level_for_current_path()
7566                .as_deref(),
7567            Some("medium")
7568        );
7569    }
7570
7571    #[test]
7572    fn test_set_branched_from() {
7573        let mut session = Session::in_memory();
7574        assert!(session.header.parent_session.is_none());
7575
7576        session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
7577        assert_eq!(
7578            session.header.parent_session.as_deref(),
7579            Some("/path/to/parent.jsonl")
7580        );
7581    }
7582
7583    // ======================================================================
7584    // to_html rendering
7585    // ======================================================================
7586
7587    #[test]
7588    fn test_to_html_contains_all_message_types() {
7589        let mut session = Session::in_memory();
7590
7591        session.append_message(make_test_message("user question"));
7592
7593        let assistant = AssistantMessage {
7594            content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
7595            api: "anthropic".to_string(),
7596            provider: "anthropic".to_string(),
7597            model: "test".to_string(),
7598            usage: Usage::default(),
7599            stop_reason: StopReason::Stop,
7600            error_message: None,
7601            timestamp: 0,
7602        };
7603        session.append_message(SessionMessage::Assistant { message: assistant });
7604        session.append_model_change("anthropic".to_string(), "claude-test".to_string());
7605        session.set_name("test-session-html");
7606
7607        let html = session.to_html();
7608        assert!(html.contains("<!doctype html>"));
7609        assert!(html.contains("user question"));
7610        assert!(html.contains("assistant answer"));
7611        assert!(html.contains("anthropic"));
7612        assert!(html.contains("test-session-html"));
7613    }
7614
7615    // ======================================================================
7616    // to_messages conversion
7617    // ======================================================================
7618
7619    #[test]
7620    fn test_to_messages_includes_all_message_entries() {
7621        let mut session = Session::in_memory();
7622
7623        session.append_message(make_test_message("Q1"));
7624        let assistant = AssistantMessage {
7625            content: vec![ContentBlock::Text(TextContent::new("A1"))],
7626            api: "anthropic".to_string(),
7627            provider: "anthropic".to_string(),
7628            model: "test".to_string(),
7629            usage: Usage::default(),
7630            stop_reason: StopReason::Stop,
7631            error_message: None,
7632            timestamp: 0,
7633        };
7634        session.append_message(SessionMessage::Assistant { message: assistant });
7635        session.append_message(SessionMessage::ToolResult {
7636            tool_call_id: "c1".to_string(),
7637            tool_name: "edit".to_string(),
7638            content: vec![ContentBlock::Text(TextContent::new("edited"))],
7639            details: None,
7640            is_error: false,
7641            timestamp: Some(0),
7642        });
7643
7644        // Non-message entries should NOT appear in to_messages()
7645        session.append_model_change("openai".to_string(), "gpt-4".to_string());
7646        session.append_session_info(Some("name".to_string()));
7647
7648        let messages = session.to_messages();
7649        assert_eq!(messages.len(), 3); // user + assistant + tool_result
7650    }
7651
7652    // ======================================================================
7653    // JSONL format validation
7654    // ======================================================================
7655
7656    #[test]
7657    fn test_jsonl_header_is_first_line() {
7658        let temp = tempfile::tempdir().unwrap();
7659        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7660        session.append_message(make_test_message("test"));
7661
7662        run_async(async { session.save().await }).unwrap();
7663        let path = session.path.clone().unwrap();
7664
7665        let contents = std::fs::read_to_string(path).unwrap();
7666        let first_line = contents.lines().next().unwrap();
7667        let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
7668
7669        assert_eq!(header["type"], "session");
7670        assert_eq!(header["version"], SESSION_VERSION);
7671        assert!(!header["id"].as_str().unwrap().is_empty());
7672        assert!(!header["timestamp"].as_str().unwrap().is_empty());
7673    }
7674
7675    #[test]
7676    fn test_jsonl_entries_have_camelcase_fields() {
7677        let temp = tempfile::tempdir().unwrap();
7678        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7679
7680        session.append_message(make_test_message("test"));
7681        session.append_model_change("provider".to_string(), "model".to_string());
7682
7683        run_async(async { session.save().await }).unwrap();
7684        let path = session.path.clone().unwrap();
7685
7686        let contents = std::fs::read_to_string(path).unwrap();
7687        let lines: Vec<&str> = contents.lines().collect();
7688
7689        // Check message entry (line 2)
7690        let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
7691        assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
7692
7693        // Check model change entry (line 3)
7694        let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
7695        assert!(mc_value.get("modelId").is_some());
7696    }
7697
7698    // ======================================================================
7699    // Session open errors
7700    // ======================================================================
7701
7702    #[test]
7703    fn test_open_nonexistent_file_returns_error() {
7704        let result =
7705            run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
7706        assert!(result.is_err());
7707    }
7708
7709    #[test]
7710    fn test_open_empty_file_returns_error() {
7711        let temp = tempfile::tempdir().unwrap();
7712        let path = temp.path().join("empty.jsonl");
7713        std::fs::write(&path, "").unwrap();
7714
7715        let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
7716        assert!(result.is_err());
7717    }
7718
7719    #[test]
7720    fn test_open_rejects_semantically_invalid_header() {
7721        let temp = tempfile::tempdir().unwrap();
7722        let path = temp.path().join("invalid_header.jsonl");
7723        std::fs::write(
7724            &path,
7725            r#"{"type":"note","version":3,"id":"bad","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
7726        )
7727        .unwrap();
7728
7729        let err = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7730            .expect_err("invalid header should fail");
7731        let message = err.to_string();
7732        assert!(
7733            message.contains("Invalid session header"),
7734            "expected invalid session header error, got {message}"
7735        );
7736    }
7737
7738    #[test]
7739    fn test_save_rejects_semantically_invalid_header() {
7740        let temp = tempfile::tempdir().unwrap();
7741        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7742        session.header.r#type = "note".to_string();
7743
7744        let err =
7745            run_async(async { session.save().await }).expect_err("invalid header should fail");
7746        let message = err.to_string();
7747        assert!(
7748            message.contains("Invalid session header"),
7749            "expected invalid session header error, got {message}"
7750        );
7751    }
7752
7753    // ======================================================================
7754    // get_entry / get_entry_mut
7755    // ======================================================================
7756
7757    #[test]
7758    fn test_get_entry_returns_correct_entry() {
7759        let mut session = Session::in_memory();
7760        let id = session.append_message(make_test_message("Hello"));
7761
7762        let entry = session.get_entry(&id);
7763        assert!(entry.is_some());
7764        assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
7765    }
7766
7767    #[test]
7768    fn test_get_entry_mut_allows_modification() {
7769        let mut session = Session::in_memory();
7770        let id = session.append_message(make_test_message("Original"));
7771
7772        let entry = session.get_entry_mut(&id).unwrap();
7773        if let SessionEntry::Message(msg) = entry {
7774            msg.message = SessionMessage::User {
7775                content: UserContent::Text("Modified".to_string()),
7776                timestamp: Some(0),
7777            };
7778        }
7779
7780        // Verify modification persisted
7781        let entry = session.get_entry(&id).unwrap();
7782        if let SessionEntry::Message(msg) = entry {
7783            if let SessionMessage::User { content, .. } = &msg.message {
7784                match content {
7785                    UserContent::Text(t) => assert_eq!(t, "Modified"),
7786                    UserContent::Blocks(_) => panic!("Expected UserContent::Text, got Blocks"),
7787                }
7788            } else {
7789                panic!("Expected SessionMessage::User, got {:?}", msg.message);
7790            }
7791        }
7792    }
7793
7794    #[test]
7795    fn test_get_entry_nonexistent_returns_none() {
7796        let session = Session::in_memory();
7797        assert!(session.get_entry("nonexistent").is_none());
7798    }
7799
7800    // ======================================================================
7801    // Branching round-trip (save with branches, reload, verify)
7802    // ======================================================================
7803
7804    #[test]
7805    fn test_branching_round_trip_preserves_tree_structure() {
7806        let temp = tempfile::tempdir().unwrap();
7807        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7808
7809        // Create: A -> B -> C, then branch from A: A -> D
7810        let id_a = session.append_message(make_test_message("A"));
7811        let id_b = session.append_message(make_test_message("B"));
7812        let id_c = session.append_message(make_test_message("C"));
7813
7814        session.create_branch_from(&id_a);
7815        let id_d = session.append_message(make_test_message("D"));
7816
7817        // Verify pre-save state
7818        let leaves = session.list_leaves();
7819        assert_eq!(leaves.len(), 2);
7820
7821        run_async(async { session.save().await }).unwrap();
7822        let path = session.path.clone().unwrap();
7823
7824        let loaded =
7825            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7826
7827        // Verify tree structure survived round-trip
7828        assert_eq!(loaded.entries.len(), 4);
7829        let loaded_leaves = loaded.list_leaves();
7830        assert_eq!(loaded_leaves.len(), 2);
7831        assert!(loaded_leaves.contains(&id_c));
7832        assert!(loaded_leaves.contains(&id_d));
7833
7834        // Verify parent linking
7835        let path_to_c = loaded.get_path_to_entry(&id_c);
7836        assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
7837
7838        let path_to_d = loaded.get_path_to_entry(&id_d);
7839        assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
7840    }
7841
7842    // ======================================================================
7843    // Session directory resolution from CWD
7844    // ======================================================================
7845
7846    #[test]
7847    fn test_encode_cwd_strips_leading_separators() {
7848        let path = std::path::Path::new("/home/user/my-project");
7849        let encoded = encode_cwd(path);
7850        assert_eq!(encoded, "--home-user-my-project--");
7851        assert!(!encoded.contains('/'));
7852    }
7853
7854    #[test]
7855    fn test_encode_cwd_handles_deeply_nested_path() {
7856        let path = std::path::Path::new("/a/b/c/d/e/f");
7857        let encoded = encode_cwd(path);
7858        assert_eq!(encoded, "--a-b-c-d-e-f--");
7859    }
7860
7861    #[test]
7862    fn test_save_creates_project_session_dir_from_cwd() {
7863        let temp = tempfile::tempdir().unwrap();
7864        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7865        session.append_message(make_test_message("test"));
7866
7867        run_async(async { session.save().await }).unwrap();
7868        let path = session.path.clone().unwrap();
7869
7870        // The saved path should be inside a CWD-encoded subdirectory
7871        let parent = path.parent().unwrap();
7872        let dir_name = parent.file_name().unwrap().to_string_lossy();
7873        assert!(
7874            dir_name.starts_with("--"),
7875            "session dir should start with --"
7876        );
7877        assert!(dir_name.ends_with("--"), "session dir should end with --");
7878
7879        // The file should have .jsonl extension
7880        assert_eq!(path.extension().unwrap(), "jsonl");
7881    }
7882
7883    #[test]
7884    fn test_save_uses_session_header_cwd_for_project_session_dir() {
7885        let _lock = current_dir_lock();
7886        let process_cwd = tempfile::tempdir().unwrap();
7887        let _guard = CurrentDirGuard::new(process_cwd.path());
7888
7889        let sessions_root = tempfile::tempdir().unwrap();
7890        let session_cwd = tempfile::tempdir().unwrap();
7891        let mut session = Session::create_with_dir(Some(sessions_root.path().to_path_buf()));
7892        session.header.cwd = session_cwd.path().display().to_string();
7893        session.append_message(make_test_message("test"));
7894
7895        run_async(async { session.save().await }).unwrap();
7896        let path = session.path.clone().expect("session path");
7897        let expected_dir = sessions_root.path().join(encode_cwd(session_cwd.path()));
7898        let process_dir = sessions_root.path().join(encode_cwd(process_cwd.path()));
7899
7900        assert_eq!(path.parent(), Some(expected_dir.as_path()));
7901        assert_ne!(path.parent(), Some(process_dir.as_path()));
7902    }
7903
7904    #[test]
7905    fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
7906        let known_entry = SessionPickEntry {
7907            path: PathBuf::from("session.jsonl"),
7908            id: "session-id".to_string(),
7909            cwd: "/work".to_string(),
7910            timestamp: "2026-01-01T00:00:00.000Z".to_string(),
7911            message_count: 4,
7912            name: Some("cached".to_string()),
7913            last_modified_ms: 1234,
7914            size_bytes: 4096,
7915        };
7916
7917        assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
7918        assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
7919        assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
7920    }
7921
7922    #[test]
7923    fn read_capped_utf8_line_with_limit_rejects_oversized_line_without_newline() {
7924        let oversized = "x".repeat(5);
7925        let mut reader = std::io::Cursor::new(oversized.into_bytes());
7926
7927        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
7928        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
7929        assert!(err.to_string().contains("JSONL line exceeds 4 bytes"));
7930    }
7931
7932    #[test]
7933    fn read_capped_utf8_line_with_limit_allows_exact_limit_before_newline() {
7934        let mut reader = std::io::Cursor::new(b"abcd\n".to_vec());
7935
7936        let line = read_capped_utf8_line_with_limit(&mut reader, 4)
7937            .expect("read line")
7938            .expect("line present");
7939        assert_eq!(line, "abcd\n");
7940        assert!(
7941            read_capped_utf8_line_with_limit(&mut reader, 4)
7942                .expect("read eof")
7943                .is_none()
7944        );
7945    }
7946
7947    #[test]
7948    fn read_capped_utf8_line_with_limit_drains_oversized_line_remainder() {
7949        let mut reader = std::io::Cursor::new(b"xxxxx\ny\n".to_vec());
7950
7951        let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
7952        assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
7953
7954        let next_line = read_capped_utf8_line_with_limit(&mut reader, 4)
7955            .expect("read next line")
7956            .expect("next line present");
7957        assert_eq!(next_line, "y\n");
7958    }
7959
7960    #[test]
7961    fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
7962        let temp = tempfile::tempdir().unwrap();
7963        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7964        session.append_message(make_test_message("first"));
7965        session.append_message(make_test_message("second"));
7966
7967        run_async(async { session.save().await }).unwrap();
7968        let path = session.path.clone().expect("session path");
7969        let metadata = std::fs::metadata(&path).expect("session metadata");
7970        let disk_size = metadata.len();
7971        #[allow(clippy::cast_possible_truncation)]
7972        let disk_ms = metadata
7973            .modified()
7974            .unwrap_or(SystemTime::UNIX_EPOCH)
7975            .duration_since(UNIX_EPOCH)
7976            .unwrap_or_default()
7977            .as_millis() as i64;
7978
7979        let stale_known_entry = SessionPickEntry {
7980            path: path.clone(),
7981            id: session.header.id.clone(),
7982            cwd: session.header.cwd.clone(),
7983            timestamp: session.header.timestamp.clone(),
7984            message_count: 999,
7985            name: Some("stale".to_string()),
7986            last_modified_ms: disk_ms,
7987            size_bytes: disk_size.saturating_add(1),
7988        };
7989
7990        let session_dir = path.parent().expect("session parent").to_path_buf();
7991        let scanned =
7992            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
7993                .expect("scan sessions");
7994        assert!(scanned.failed_paths.is_empty());
7995        assert_eq!(scanned.entries.len(), 1);
7996        assert_eq!(scanned.refreshed_entries.len(), 1);
7997        assert_eq!(scanned.entries[0].path, path);
7998        assert_eq!(scanned.entries[0].message_count, 2);
7999        assert_eq!(scanned.entries[0].size_bytes, disk_size);
8000    }
8001
8002    #[test]
8003    fn test_merge_scanned_session_entries_replaces_cached_entry_when_size_changes() {
8004        let path = PathBuf::from("session.jsonl");
8005        let mut by_path = HashMap::from([(
8006            path.clone(),
8007            SessionPickEntry {
8008                path: path.clone(),
8009                id: "session-id".to_string(),
8010                cwd: "/work".to_string(),
8011                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
8012                message_count: 1,
8013                name: Some("cached".to_string()),
8014                last_modified_ms: 1234,
8015                size_bytes: 4096,
8016            },
8017        )]);
8018
8019        merge_scanned_session_entries(
8020            &mut by_path,
8021            vec![SessionPickEntry {
8022                path: path.clone(),
8023                id: "session-id".to_string(),
8024                cwd: "/work".to_string(),
8025                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
8026                message_count: 2,
8027                name: Some("disk".to_string()),
8028                last_modified_ms: 1234,
8029                size_bytes: 8192,
8030            }],
8031        );
8032
8033        let merged = by_path.get(&path).expect("merged entry");
8034        assert_eq!(merged.message_count, 2);
8035        assert_eq!(merged.name.as_deref(), Some("disk"));
8036        assert_eq!(merged.size_bytes, 8192);
8037    }
8038
8039    #[test]
8040    fn test_merge_scanned_session_entries_replaces_cached_entry_even_if_disk_mtime_regresses() {
8041        let path = PathBuf::from("session.jsonl");
8042        let mut by_path = HashMap::from([(
8043            path.clone(),
8044            SessionPickEntry {
8045                path: path.clone(),
8046                id: "session-id".to_string(),
8047                cwd: "/work".to_string(),
8048                timestamp: "2026-01-02T00:00:00.000Z".to_string(),
8049                message_count: 9,
8050                name: Some("cached".to_string()),
8051                last_modified_ms: 2000,
8052                size_bytes: 4096,
8053            },
8054        )]);
8055
8056        merge_scanned_session_entries(
8057            &mut by_path,
8058            vec![SessionPickEntry {
8059                path: path.clone(),
8060                id: "session-id".to_string(),
8061                cwd: "/work".to_string(),
8062                timestamp: "2026-01-01T00:00:00.000Z".to_string(),
8063                message_count: 3,
8064                name: Some("disk".to_string()),
8065                last_modified_ms: 1000,
8066                size_bytes: 2048,
8067            }],
8068        );
8069
8070        let merged = by_path.get(&path).expect("merged entry");
8071        assert_eq!(merged.message_count, 3);
8072        assert_eq!(merged.name.as_deref(), Some("disk"));
8073        assert_eq!(merged.last_modified_ms, 1000);
8074        assert_eq!(merged.size_bytes, 2048);
8075    }
8076
8077    #[test]
8078    fn test_scan_sessions_on_disk_reports_failed_paths_for_corrupt_changed_session() {
8079        let temp = tempfile::tempdir().unwrap();
8080        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8081        session.append_message(make_test_message("first"));
8082        session.append_message(make_test_message("second"));
8083
8084        run_async(async { session.save().await }).unwrap();
8085        let path = session.path.clone().expect("session path");
8086        let metadata = std::fs::metadata(&path).expect("session metadata");
8087        let disk_size = metadata.len();
8088        #[allow(clippy::cast_possible_truncation)]
8089        let disk_ms = metadata
8090            .modified()
8091            .unwrap_or(SystemTime::UNIX_EPOCH)
8092            .duration_since(UNIX_EPOCH)
8093            .unwrap_or_default()
8094            .as_millis() as i64;
8095
8096        let stale_known_entry = SessionPickEntry {
8097            path: path.clone(),
8098            id: session.header.id.clone(),
8099            cwd: session.header.cwd.clone(),
8100            timestamp: session.header.timestamp.clone(),
8101            message_count: 999,
8102            name: Some("stale".to_string()),
8103            last_modified_ms: disk_ms,
8104            size_bytes: disk_size,
8105        };
8106
8107        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
8108
8109        let session_dir = path.parent().expect("session parent").to_path_buf();
8110        let scanned =
8111            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
8112                .expect("scan sessions");
8113
8114        assert!(scanned.entries.is_empty());
8115        assert!(scanned.refreshed_entries.is_empty());
8116        assert_eq!(scanned.failed_paths, vec![path]);
8117    }
8118
8119    #[test]
8120    fn test_continue_recent_in_dir_prunes_corrupt_stale_index_entry() {
8121        let _lock = current_dir_lock();
8122        let process_cwd = tempfile::tempdir().unwrap();
8123        let _guard = CurrentDirGuard::new(process_cwd.path());
8124
8125        let temp = tempfile::tempdir().unwrap();
8126        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8127        session.append_message(make_test_message("first"));
8128        session.append_message(make_test_message("second"));
8129
8130        run_async(async { session.save().await }).expect("save session");
8131        let path = session.path.clone().expect("session path");
8132
8133        let index = SessionIndex::for_sessions_root(temp.path());
8134        index.index_session(&session).expect("index session");
8135        let cwd_display = session.header.cwd.clone();
8136        let has_indexed_path = index
8137            .list_sessions(Some(&cwd_display))
8138            .expect("list indexed sessions")
8139            .into_iter()
8140            .any(|meta| meta.path == path.display().to_string());
8141        assert!(
8142            has_indexed_path,
8143            "expected indexed session before corruption"
8144        );
8145
8146        std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
8147
8148        let resumed = run_async(async {
8149            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
8150        })
8151        .expect("continue recent");
8152
8153        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
8154        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
8155
8156        let still_indexed = index
8157            .list_sessions(Some(&cwd_display))
8158            .expect("list indexed sessions after cleanup")
8159            .into_iter()
8160            .any(|meta| meta.path == path.display().to_string());
8161        assert!(
8162            !still_indexed,
8163            "corrupt session should be pruned from the recent-session index"
8164        );
8165    }
8166
8167    #[test]
8168    fn test_continue_recent_in_dir_prunes_missing_stale_index_entry() {
8169        let _lock = current_dir_lock();
8170        let process_cwd = tempfile::tempdir().unwrap();
8171        let _guard = CurrentDirGuard::new(process_cwd.path());
8172
8173        let temp = tempfile::tempdir().unwrap();
8174        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8175        session.append_message(make_test_message("first"));
8176
8177        run_async(async { session.save().await }).expect("save session");
8178        let path = session.path.clone().expect("session path");
8179
8180        let index = SessionIndex::for_sessions_root(temp.path());
8181        index.index_session(&session).expect("index session");
8182        let cwd_display = session.header.cwd.clone();
8183        let has_indexed_path = index
8184            .list_sessions(Some(&cwd_display))
8185            .expect("list indexed sessions")
8186            .into_iter()
8187            .any(|meta| meta.path == path.display().to_string());
8188        assert!(
8189            has_indexed_path,
8190            "expected indexed session before moving file"
8191        );
8192
8193        let moved_path = path.with_extension("bak");
8194        std::fs::rename(&path, &moved_path).expect("move session away from indexed path");
8195
8196        let resumed = run_async(async {
8197            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
8198        })
8199        .expect("continue recent");
8200
8201        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
8202        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
8203
8204        let still_indexed = index
8205            .list_sessions(Some(&cwd_display))
8206            .expect("list indexed sessions after cleanup")
8207            .into_iter()
8208            .any(|meta| meta.path == path.display().to_string());
8209        assert!(
8210            !still_indexed,
8211            "missing session should be pruned from the recent-session index"
8212        );
8213    }
8214
8215    #[test]
8216    fn test_continue_recent_in_dir_prunes_index_when_project_dir_is_missing() {
8217        let _lock = current_dir_lock();
8218        let process_cwd = tempfile::tempdir().unwrap();
8219        let _guard = CurrentDirGuard::new(process_cwd.path());
8220
8221        let temp = tempfile::tempdir().unwrap();
8222        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8223        session.append_message(make_test_message("first"));
8224
8225        run_async(async { session.save().await }).expect("save session");
8226        let path = session.path.clone().expect("session path");
8227
8228        let index = SessionIndex::for_sessions_root(temp.path());
8229        index.index_session(&session).expect("index session");
8230        let cwd_display = session.header.cwd.clone();
8231        let cwd = std::path::Path::new(&cwd_display);
8232        let project_session_dir = temp.path().join(encode_cwd(cwd));
8233        let moved_project_dir = temp.path().join("moved-project-dir");
8234
8235        std::fs::rename(&project_session_dir, &moved_project_dir)
8236            .expect("move project session dir away");
8237
8238        let resumed = run_async(async {
8239            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
8240        })
8241        .expect("continue recent");
8242
8243        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
8244        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
8245
8246        let still_indexed = index
8247            .list_sessions(Some(&cwd_display))
8248            .expect("list indexed sessions after cleanup")
8249            .into_iter()
8250            .any(|meta| meta.path == path.display().to_string());
8251        assert!(
8252            !still_indexed,
8253            "missing project dir should prune stale rows from the recent-session index"
8254        );
8255    }
8256
8257    #[cfg(unix)]
8258    #[test]
8259    fn split_indexed_session_entries_keeps_permission_denied_path_out_of_missing_bucket() {
8260        use crate::session_index::SessionMeta;
8261        use std::os::unix::fs::PermissionsExt;
8262
8263        let temp = tempfile::tempdir().unwrap();
8264        let guarded_dir = temp.path().join("guarded");
8265        std::fs::create_dir(&guarded_dir).expect("create guarded dir");
8266        let session_path = guarded_dir.join("session.jsonl");
8267        std::fs::write(&session_path, b"{\"version\":\"3\"}\n").expect("write session file");
8268
8269        let original_mode = std::fs::metadata(&guarded_dir)
8270            .expect("guarded dir metadata")
8271            .permissions()
8272            .mode();
8273        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(0o000))
8274            .expect("chmod guarded dir");
8275
8276        assert!(
8277            session_path.try_exists().is_err(),
8278            "expected permission-denied path probe for inaccessible parent directory"
8279        );
8280
8281        let meta = SessionMeta {
8282            path: session_path.display().to_string(),
8283            id: "session-id".to_string(),
8284            cwd: temp.path().display().to_string(),
8285            timestamp: "2026-03-15T00:00:00.000Z".to_string(),
8286            message_count: 1,
8287            last_modified_ms: 0,
8288            size_bytes: 16,
8289            name: Some("guarded".to_string()),
8290        };
8291
8292        let (entries, missing_paths) = split_indexed_session_entries(vec![meta]);
8293
8294        std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(original_mode))
8295            .expect("restore guarded dir permissions");
8296
8297        assert!(
8298            missing_paths.is_empty(),
8299            "permission errors must not be classified as missing indexed sessions"
8300        );
8301        assert_eq!(entries.len(), 1);
8302        assert_eq!(entries[0].path, session_path);
8303    }
8304
8305    #[cfg(unix)]
8306    #[test]
8307    fn test_continue_recent_in_dir_prunes_unreadable_cached_entry_on_open_failure() {
8308        use std::os::unix::fs::PermissionsExt;
8309
8310        let temp = tempfile::tempdir().unwrap();
8311        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8312        session.append_message(make_test_message("first"));
8313
8314        run_async(async { session.save().await }).expect("save session");
8315        let path = session.path.clone().expect("session path");
8316
8317        let original_mode = std::fs::metadata(&path)
8318            .expect("session metadata")
8319            .permissions()
8320            .mode();
8321
8322        let index = SessionIndex::for_sessions_root(temp.path());
8323        index.index_session(&session).expect("index session");
8324        let cwd_display = std::env::current_dir()
8325            .expect("current dir")
8326            .display()
8327            .to_string();
8328
8329        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
8330            .expect("chmod unreadable");
8331
8332        let resumed = run_async(async {
8333            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
8334        })
8335        .expect("continue recent");
8336
8337        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(original_mode))
8338            .expect("restore permissions");
8339
8340        assert!(resumed.path.is_none(), "expected a fresh unsaved session");
8341        assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
8342
8343        let still_indexed = index
8344            .list_sessions(Some(&cwd_display))
8345            .expect("list indexed sessions after cleanup")
8346            .into_iter()
8347            .any(|meta| meta.path == path.display().to_string());
8348        assert!(
8349            !still_indexed,
8350            "unreadable session should be pruned from the recent-session index"
8351        );
8352    }
8353
8354    #[test]
8355    fn test_continue_recent_in_dir_refreshes_index_after_changed_disk_session() {
8356        let temp = tempfile::tempdir().expect("tempdir");
8357        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8358        session.append_message(make_test_message("first"));
8359
8360        run_async(async { session.save().await }).expect("save session");
8361        let path = session.path.clone().expect("session path");
8362
8363        let index = SessionIndex::for_sessions_root(temp.path());
8364        index.index_session(&session).expect("index session");
8365        let cwd_display = std::env::current_dir()
8366            .expect("current dir")
8367            .display()
8368            .to_string();
8369
8370        std::fs::write(
8371            &path,
8372            format!(
8373                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
8374                serde_json::to_string(&session.header).expect("serialize header"),
8375            ),
8376        )
8377        .expect("rewrite session");
8378
8379        let resumed = run_async(async {
8380            Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
8381        })
8382        .expect("continue recent");
8383
8384        assert_eq!(resumed.path.as_ref(), Some(&path));
8385
8386        let indexed = index
8387            .list_sessions(Some(&cwd_display))
8388            .expect("list indexed sessions");
8389        assert_eq!(indexed.len(), 1);
8390        assert_eq!(indexed[0].path, path.display().to_string());
8391        assert_eq!(indexed[0].message_count, 2);
8392        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
8393    }
8394
8395    #[test]
8396    fn test_resume_with_picker_refreshes_index_after_changed_disk_session() {
8397        let _lock = current_dir_lock();
8398        let process_cwd = tempfile::tempdir().unwrap();
8399        let _guard = CurrentDirGuard::new(process_cwd.path());
8400
8401        let temp = tempfile::tempdir().expect("tempdir");
8402        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8403        session.append_message(make_test_message("first"));
8404
8405        run_async(async { session.save().await }).expect("save session");
8406        let path = session.path.clone().expect("session path");
8407
8408        let index = SessionIndex::for_sessions_root(temp.path());
8409        index.index_session(&session).expect("index session");
8410        let cwd_display = session.header.cwd.clone();
8411
8412        std::fs::write(
8413            &path,
8414            format!(
8415                "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
8416                serde_json::to_string(&session.header).expect("serialize header"),
8417            ),
8418        )
8419        .expect("rewrite session");
8420
8421        let resumed = run_async(async {
8422            Session::resume_with_picker(
8423                Some(temp.path()),
8424                &Config::default(),
8425                Some("1".to_string()),
8426            )
8427            .await
8428        })
8429        .expect("resume with picker");
8430
8431        assert_eq!(resumed.path.as_ref(), Some(&path));
8432
8433        let indexed = index
8434            .list_sessions(Some(&cwd_display))
8435            .expect("list indexed sessions");
8436        assert_eq!(indexed.len(), 1);
8437        assert_eq!(indexed[0].path, path.display().to_string());
8438        assert_eq!(indexed[0].message_count, 2);
8439        assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
8440    }
8441
8442    #[test]
8443    fn test_load_session_meta_jsonl_errors_on_invalid_utf8_entry_line() {
8444        use std::io::Write;
8445
8446        let temp = tempfile::tempdir().unwrap();
8447        let session_path = temp.path().join("invalid-utf8.jsonl");
8448
8449        let mut header = SessionHeader::new();
8450        header.id = "invalid-utf8".to_string();
8451        header.cwd = temp.path().display().to_string();
8452        header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
8453
8454        std::fs::write(
8455            &session_path,
8456            format!(
8457                "{}\n",
8458                serde_json::to_string(&header).expect("serialize header")
8459            ),
8460        )
8461        .expect("write header");
8462
8463        let mut file = std::fs::OpenOptions::new()
8464            .append(true)
8465            .open(&session_path)
8466            .expect("open session");
8467        file.write_all(b"{\"type\":\"message\"}\n")
8468            .expect("write valid entry");
8469        file.write_all(b"\xFF\xFE\n").expect("write invalid utf8");
8470        file.flush().expect("flush session");
8471        drop(file);
8472
8473        let err = load_session_meta_jsonl(&session_path).expect_err("invalid utf8 should error");
8474        assert!(
8475            err.to_string().contains("Failed to read session entry"),
8476            "{err}"
8477        );
8478    }
8479
8480    #[cfg(feature = "sqlite-sessions")]
8481    #[test]
8482    fn test_scan_sessions_on_disk_reloads_sqlite_when_wal_stats_change() {
8483        let temp = tempfile::tempdir().unwrap();
8484        let mut session = Session::create_with_dir_and_store(
8485            Some(temp.path().to_path_buf()),
8486            SessionStoreKind::Sqlite,
8487        );
8488        session.append_message(make_test_message("sqlite"));
8489
8490        run_async(async { session.save().await }).unwrap();
8491        let path = session.path.clone().expect("sqlite session path");
8492        let session_dir = path.parent().expect("session parent").to_path_buf();
8493        let (base_ms, base_size) = session_file_stats(&path).expect("base stats");
8494
8495        let mut wal_path = path.as_os_str().to_os_string();
8496        wal_path.push("-wal");
8497        let wal_path = PathBuf::from(wal_path);
8498        std::thread::sleep(std::time::Duration::from_millis(1_100));
8499        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
8500
8501        let stale_known_entry = SessionPickEntry {
8502            path: path.clone(),
8503            id: session.header.id.clone(),
8504            cwd: session.header.cwd.clone(),
8505            timestamp: session.header.timestamp.clone(),
8506            message_count: 999,
8507            name: Some("stale".to_string()),
8508            last_modified_ms: base_ms,
8509            size_bytes: base_size,
8510        };
8511
8512        let scanned =
8513            run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
8514                .expect("scan sessions");
8515        let (updated_ms, updated_size) = session_file_stats(&path).expect("updated stats");
8516
8517        assert!(scanned.failed_paths.is_empty());
8518        assert_eq!(scanned.entries.len(), 1);
8519        assert_eq!(scanned.refreshed_entries.len(), 1);
8520        assert_eq!(scanned.entries[0].path, path);
8521        assert_eq!(scanned.entries[0].message_count, 1);
8522        assert_eq!(scanned.entries[0].size_bytes, updated_size);
8523        assert_eq!(scanned.entries[0].last_modified_ms, updated_ms);
8524    }
8525
8526    #[cfg(feature = "sqlite-sessions")]
8527    #[test]
8528    fn test_load_session_meta_sqlite_uses_wal_aware_stats() {
8529        let temp = tempfile::tempdir().unwrap();
8530        let mut session = Session::create_with_dir_and_store(
8531            Some(temp.path().to_path_buf()),
8532            SessionStoreKind::Sqlite,
8533        );
8534        session.append_message(make_test_message("sqlite"));
8535
8536        run_async(async { session.save().await }).unwrap();
8537        let path = session.path.clone().expect("sqlite session path");
8538
8539        let mut wal_path = path.as_os_str().to_os_string();
8540        wal_path.push("-wal");
8541        let wal_path = PathBuf::from(wal_path);
8542        std::thread::sleep(std::time::Duration::from_millis(1_100));
8543        std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
8544
8545        let meta = load_session_meta_sqlite(&path).expect("load sqlite meta");
8546        let (expected_ms, expected_size) = session_file_stats(&path).expect("sqlite file stats");
8547
8548        assert_eq!(meta.path, path);
8549        assert_eq!(meta.size_bytes, expected_size);
8550        assert_eq!(meta.last_modified_ms, expected_ms);
8551    }
8552
8553    // ======================================================================
8554    // All entries corrupted (only header valid)
8555    // ======================================================================
8556
8557    #[test]
8558    fn test_all_entries_corrupted_produces_empty_session() {
8559        let temp = tempfile::tempdir().unwrap();
8560        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8561        session.append_message(make_test_message("A"));
8562        session.append_message(make_test_message("B"));
8563
8564        run_async(async { session.save().await }).unwrap();
8565        let path = session.path.clone().unwrap();
8566
8567        let mut lines: Vec<String> = std::fs::read_to_string(&path)
8568            .unwrap()
8569            .lines()
8570            .map(str::to_string)
8571            .collect();
8572        // Corrupt all entry lines (keep header at index 0)
8573        for (i, line) in lines.iter_mut().enumerate().skip(1) {
8574            *line = format!("GARBAGE_{i}");
8575        }
8576        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8577
8578        let (loaded, diagnostics) = run_async(async {
8579            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8580        })
8581        .unwrap();
8582
8583        assert_eq!(diagnostics.skipped_entries.len(), 2);
8584        assert!(loaded.entries.is_empty());
8585        assert!(loaded.leaf_id.is_none());
8586        // Header should still be valid
8587        assert_eq!(loaded.header.id, session.header.id);
8588    }
8589
8590    // ======================================================================
8591    // Unicode and special character content
8592    // ======================================================================
8593
8594    #[test]
8595    fn test_unicode_content_round_trip() {
8596        let temp = tempfile::tempdir().unwrap();
8597        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8598
8599        let unicode_texts = [
8600            "Hello \u{1F600} World",    // emoji
8601            "\u{4F60}\u{597D}",         // Chinese
8602            "\u{0410}\u{0411}\u{0412}", // Cyrillic
8603            "caf\u{00E9}",              // accented
8604            "tab\there\nnewline",       // control chars
8605            "\"quoted\" and \\escaped", // JSON special chars
8606        ];
8607
8608        for text in &unicode_texts {
8609            session.append_message(make_test_message(text));
8610        }
8611
8612        run_async(async { session.save().await }).unwrap();
8613        let path = session.path.clone().unwrap();
8614
8615        let loaded =
8616            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8617
8618        assert_eq!(loaded.entries.len(), unicode_texts.len());
8619
8620        for (i, entry) in loaded.entries.iter().enumerate() {
8621            if let SessionEntry::Message(msg) = entry {
8622                if let SessionMessage::User { content, .. } = &msg.message {
8623                    match content {
8624                        UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
8625                        UserContent::Blocks(_) => panic!("Expected UserContent::Text, got Blocks"),
8626                    }
8627                }
8628            }
8629        }
8630    }
8631
8632    // ======================================================================
8633    // Multiple compactions
8634    // ======================================================================
8635
8636    #[test]
8637    fn test_multiple_compactions_latest_wins() {
8638        let mut session = Session::in_memory();
8639
8640        let _id_a = session.append_message(make_test_message("old A"));
8641        let _id_b = session.append_message(make_test_message("old B"));
8642        let id_c = session.append_message(make_test_message("kept C"));
8643
8644        // First compaction: keep from C
8645        session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
8646
8647        let _id_d = session.append_message(make_test_message("new D"));
8648        let id_e = session.append_message(make_test_message("new E"));
8649
8650        // Second compaction: keep from E
8651        session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
8652
8653        let id_f = session.append_message(make_test_message("newest F"));
8654
8655        session.navigate_to(&id_f);
8656        let messages = session.to_messages_for_current_path();
8657
8658        // Old messages A, B should definitely not appear
8659        let all_text: String = messages
8660            .iter()
8661            .filter_map(|m| match m {
8662                Message::User(u) => match &u.content {
8663                    UserContent::Text(t) => Some(t.clone()),
8664                    UserContent::Blocks(_) => None,
8665                },
8666                _ => None,
8667            })
8668            .collect::<Vec<_>>()
8669            .join(" ");
8670
8671        assert!(!all_text.contains("old A"), "A should be compacted away");
8672        assert!(!all_text.contains("old B"), "B should be compacted away");
8673    }
8674
8675    // ======================================================================
8676    // Session with only metadata entries (no messages)
8677    // ======================================================================
8678
8679    #[test]
8680    fn test_session_with_only_metadata_entries() {
8681        let mut session = Session::in_memory();
8682
8683        session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
8684        session.append_thinking_level_change("high".to_string());
8685        session.set_name("metadata-only");
8686
8687        // to_messages should return empty (no actual messages)
8688        let messages = session.to_messages();
8689        assert!(messages.is_empty());
8690
8691        // entries_for_current_path should still return the metadata entries
8692        let entries = session.entries_for_current_path();
8693        assert_eq!(entries.len(), 3);
8694    }
8695
8696    #[test]
8697    fn test_metadata_only_session_round_trip() {
8698        let temp = tempfile::tempdir().unwrap();
8699        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8700
8701        session.append_model_change("openai".to_string(), "gpt-4o".to_string());
8702        session.append_thinking_level_change("medium".to_string());
8703
8704        run_async(async { session.save().await }).unwrap();
8705        let path = session.path.clone().unwrap();
8706
8707        let loaded =
8708            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8709
8710        assert_eq!(loaded.entries.len(), 2);
8711        assert!(
8712            loaded
8713                .entries
8714                .iter()
8715                .any(|e| matches!(e, SessionEntry::ModelChange(_)))
8716        );
8717        assert!(
8718            loaded
8719                .entries
8720                .iter()
8721                .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
8722        );
8723    }
8724
8725    // ======================================================================
8726    // Session name round-trip persistence
8727    // ======================================================================
8728
8729    #[test]
8730    fn test_session_name_survives_round_trip() {
8731        let temp = tempfile::tempdir().unwrap();
8732        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8733
8734        session.append_message(make_test_message("Hello"));
8735        session.set_name("my-important-session");
8736
8737        run_async(async { session.save().await }).unwrap();
8738        let path = session.path.clone().unwrap();
8739
8740        let loaded =
8741            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8742
8743        assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
8744    }
8745
8746    // ======================================================================
8747    // Trailing newline / whitespace in JSONL
8748    // ======================================================================
8749
8750    #[test]
8751    fn test_trailing_whitespace_in_jsonl_ignored() {
8752        let temp = tempfile::tempdir().unwrap();
8753        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8754        session.append_message(make_test_message("test"));
8755
8756        run_async(async { session.save().await }).unwrap();
8757        let path = session.path.clone().unwrap();
8758
8759        // Append extra blank lines at the end
8760        let mut contents = std::fs::read_to_string(&path).unwrap();
8761        contents.push_str("\n\n\n");
8762        std::fs::write(&path, contents).unwrap();
8763
8764        let loaded =
8765            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8766
8767        assert_eq!(loaded.entries.len(), 1);
8768    }
8769
8770    // ======================================================================
8771    // Branching after compaction
8772    // ======================================================================
8773
8774    #[test]
8775    fn test_branching_after_compaction() {
8776        let mut session = Session::in_memory();
8777
8778        let _id_a = session.append_message(make_test_message("old A"));
8779        let id_b = session.append_message(make_test_message("kept B"));
8780
8781        session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
8782
8783        let id_c = session.append_message(make_test_message("C after compaction"));
8784
8785        // Branch from B (the compaction keep-point)
8786        session.create_branch_from(&id_b);
8787        let id_d = session.append_message(make_test_message("D branch after compaction"));
8788
8789        let leaves = session.list_leaves();
8790        assert_eq!(leaves.len(), 2);
8791        assert!(leaves.contains(&id_c));
8792        assert!(leaves.contains(&id_d));
8793    }
8794
8795    // ======================================================================
8796    // Assistant message with tool calls round-trip
8797    // ======================================================================
8798
8799    #[test]
8800    fn test_assistant_with_tool_calls_round_trip() {
8801        let temp = tempfile::tempdir().unwrap();
8802        let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8803
8804        session.append_message(make_test_message("read my file"));
8805
8806        let assistant = AssistantMessage {
8807            content: vec![
8808                ContentBlock::Text(TextContent::new("Let me read that for you.")),
8809                ContentBlock::ToolCall(crate::model::ToolCall {
8810                    id: "call_abc".to_string(),
8811                    name: "read".to_string(),
8812                    arguments: serde_json::json!({"path": "src/main.rs"}),
8813                    thought_signature: None,
8814                }),
8815            ],
8816            api: "anthropic".to_string(),
8817            provider: "anthropic".to_string(),
8818            model: "claude-test".to_string(),
8819            usage: Usage {
8820                input: 100,
8821                output: 50,
8822                cache_read: 0,
8823                cache_write: 0,
8824                total_tokens: 150,
8825                cost: Cost::default(),
8826            },
8827            stop_reason: StopReason::ToolUse,
8828            error_message: None,
8829            timestamp: 12345,
8830        };
8831        session.append_message(SessionMessage::Assistant { message: assistant });
8832
8833        session.append_message(SessionMessage::ToolResult {
8834            tool_call_id: "call_abc".to_string(),
8835            tool_name: "read".to_string(),
8836            content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
8837            details: Some(serde_json::json!({"lines": 1, "truncated": false})),
8838            is_error: false,
8839            timestamp: Some(12346),
8840        });
8841
8842        run_async(async { session.save().await }).unwrap();
8843        let path = session.path.clone().unwrap();
8844
8845        let loaded =
8846            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8847
8848        assert_eq!(loaded.entries.len(), 3);
8849
8850        // Verify tool call content survived
8851        let has_tool_call = loaded.entries.iter().any(|e| {
8852            if let SessionEntry::Message(msg) = e {
8853                if let SessionMessage::Assistant { message } = &msg.message {
8854                    return message
8855                        .content
8856                        .iter()
8857                        .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id == "call_abc"));
8858                }
8859            }
8860            false
8861        });
8862        assert!(has_tool_call, "tool call should survive round-trip");
8863
8864        // Verify tool result details survived
8865        let has_details = loaded.entries.iter().any(|e| {
8866            if let SessionEntry::Message(msg) = e {
8867                if let SessionMessage::ToolResult { details, .. } = &msg.message {
8868                    return details.is_some();
8869                }
8870            }
8871            false
8872        });
8873        assert!(has_details, "tool result details should survive round-trip");
8874    }
8875
8876    // ======================================================================
8877    // FUZZ-P1.4: Proptest coverage for Session JSONL parsing
8878    // ======================================================================
8879
8880    mod proptest_session {
8881        use super::*;
8882        use proptest::prelude::*;
8883        use serde_json::json;
8884
8885        /// Generate a random valid timestamp string.
8886        fn timestamp_strategy() -> impl Strategy<Value = String> {
8887            (
8888                2020u32..2030,
8889                1u32..13,
8890                1u32..29,
8891                0u32..24,
8892                0u32..60,
8893                0u32..60,
8894            )
8895                .prop_map(|(y, mo, d, h, mi, s)| {
8896                    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
8897                })
8898        }
8899
8900        /// Generate a random entry ID (8 hex chars).
8901        fn entry_id_strategy() -> impl Strategy<Value = String> {
8902            "[0-9a-f]{8}"
8903        }
8904
8905        /// Generate an arbitrary JSON value of bounded depth/size.
8906        fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
8907            if max_depth == 0 {
8908                prop_oneof![
8909                    Just(json!(null)),
8910                    any::<bool>().prop_map(|b| json!(b)),
8911                    any::<i64>().prop_map(|n| json!(n)),
8912                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
8913                ]
8914                .boxed()
8915            } else {
8916                prop_oneof![
8917                    Just(json!(null)),
8918                    any::<bool>().prop_map(|b| json!(b)),
8919                    any::<i64>().prop_map(|n| json!(n)),
8920                    "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
8921                    prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
8922                        .prop_map(serde_json::Value::Array),
8923                ]
8924                .boxed()
8925            }
8926        }
8927
8928        /// Generate a valid `SessionEntry` JSON object for one of the known types.
8929        #[allow(clippy::too_many_lines)]
8930        fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
8931            let ts = timestamp_strategy();
8932            let eid = entry_id_strategy();
8933            let parent = prop::option::of(entry_id_strategy());
8934
8935            (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
8936                let base = json!({
8937                    "id": eid,
8938                    "parentId": parent,
8939                    "timestamp": ts,
8940                });
8941
8942                match variant {
8943                    0 => {
8944                        // Message - User
8945                        "[a-zA-Z0-9 ]{1,64}"
8946                            .prop_map(move |text| {
8947                                let mut v = base.clone();
8948                                v["type"] = json!("message");
8949                                v["message"] = json!({
8950                                    "role": "user",
8951                                    "content": text,
8952                                });
8953                                v
8954                            })
8955                            .boxed()
8956                    }
8957                    1 => {
8958                        // Message - Assistant
8959                        "[a-zA-Z0-9 ]{1,64}"
8960                            .prop_map(move |text| {
8961                                let mut v = base.clone();
8962                                v["type"] = json!("message");
8963                                v["message"] = json!({
8964                                    "role": "assistant",
8965                                    "content": [{"type": "text", "text": text}],
8966                                    "api": "anthropic",
8967                                    "provider": "anthropic",
8968                                    "model": "test-model",
8969                                    "usage": {
8970                                        "input": 10,
8971                                        "output": 5,
8972                                        "cacheRead": 0,
8973                                        "cacheWrite": 0,
8974                                        "totalTokens": 15,
8975                                        "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
8976                                    },
8977                                    "stopReason": "end_turn",
8978                                    "timestamp": 12345,
8979                                });
8980                                v
8981                            })
8982                            .boxed()
8983                    }
8984                    2 => {
8985                        // ModelChange
8986                        ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
8987                            .prop_map(move |(provider, model)| {
8988                                let mut v = base.clone();
8989                                v["type"] = json!("model_change");
8990                                v["provider"] = json!(provider);
8991                                v["modelId"] = json!(model);
8992                                v
8993                            })
8994                            .boxed()
8995                    }
8996                    3 => {
8997                        // ThinkingLevelChange
8998                        prop_oneof![
8999                            Just("off".to_string()),
9000                            Just("low".to_string()),
9001                            Just("medium".to_string()),
9002                            Just("high".to_string()),
9003                        ]
9004                        .prop_map(move |level| {
9005                            let mut v = base.clone();
9006                            v["type"] = json!("thinking_level_change");
9007                            v["thinkingLevel"] = json!(level);
9008                            v
9009                        })
9010                        .boxed()
9011                    }
9012                    4 => {
9013                        // Compaction
9014                        ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
9015                            .prop_map(move |(summary, kept_id, tokens)| {
9016                                let mut v = base.clone();
9017                                v["type"] = json!("compaction");
9018                                v["summary"] = json!(summary);
9019                                v["firstKeptEntryId"] = json!(kept_id);
9020                                v["tokensBefore"] = json!(tokens);
9021                                v
9022                            })
9023                            .boxed()
9024                    }
9025                    5 => {
9026                        // Label
9027                        (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
9028                            .prop_map(move |(target, label)| {
9029                                let mut v = base.clone();
9030                                v["type"] = json!("label");
9031                                v["targetId"] = json!(target);
9032                                if let Some(l) = label {
9033                                    v["label"] = json!(l);
9034                                }
9035                                v
9036                            })
9037                            .boxed()
9038                    }
9039                    6 => {
9040                        // SessionInfo
9041                        prop::option::of("[a-zA-Z0-9 ]{1,32}")
9042                            .prop_map(move |name| {
9043                                let mut v = base.clone();
9044                                v["type"] = json!("session_info");
9045                                if let Some(n) = name {
9046                                    v["name"] = json!(n);
9047                                }
9048                                v
9049                            })
9050                            .boxed()
9051                    }
9052                    _ => {
9053                        // Custom
9054                        ("[a-z_]{3,12}", bounded_json_value(2))
9055                            .prop_map(move |(custom_type, data)| {
9056                                let mut v = base.clone();
9057                                v["type"] = json!("custom");
9058                                v["customType"] = json!(custom_type);
9059                                v["data"] = data;
9060                                v
9061                            })
9062                            .boxed()
9063                    }
9064                }
9065            })
9066        }
9067
9068        /// Generate a corrupted JSON line (valid JSON but wrong shape for `SessionEntry`).
9069        fn corrupted_entry_json() -> impl Strategy<Value = String> {
9070            prop_oneof![
9071                // Missing "type" field
9072                Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9073                // Unknown type
9074                Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9075                // Empty object
9076                Just(r"{}".to_string()),
9077                // Array instead of object
9078                Just(r"[1,2,3]".to_string()),
9079                // Scalar values
9080                Just(r"42".to_string()),
9081                Just(r#""just a string""#.to_string()),
9082                Just(r"null".to_string()),
9083                Just(r"true".to_string()),
9084                // Truncated JSON (simulating crash)
9085                Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
9086                // Valid JSON with wrong field types
9087                Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9088            ]
9089        }
9090
9091        /// Build a complete JSONL file string from header + entries.
9092        fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
9093            let mut lines = vec![header.to_string()];
9094            lines.extend(entry_lines.iter().cloned());
9095            lines.join("\n")
9096        }
9097
9098        // ------------------------------------------------------------------
9099        // Proptest 1: SessionEntry deserialization never panics
9100        // ------------------------------------------------------------------
9101        proptest! {
9102            #![proptest_config(ProptestConfig {
9103                cases: 256,
9104                max_shrink_iters: 200,
9105                .. ProptestConfig::default()
9106            })]
9107
9108            #[test]
9109            fn session_entry_deser_never_panics(
9110                entry_json in valid_session_entry_json()
9111            ) {
9112                let json_str = entry_json.to_string();
9113                // Must not panic — Ok or Err is fine
9114                let _ = serde_json::from_str::<SessionEntry>(&json_str);
9115            }
9116        }
9117
9118        // ------------------------------------------------------------------
9119        // Proptest 2: Corrupted/malformed input never panics
9120        // ------------------------------------------------------------------
9121        proptest! {
9122            #![proptest_config(ProptestConfig {
9123                cases: 256,
9124                max_shrink_iters: 200,
9125                .. ProptestConfig::default()
9126            })]
9127
9128            #[test]
9129            fn corrupted_entry_deser_never_panics(
9130                line in corrupted_entry_json()
9131            ) {
9132                let _ = serde_json::from_str::<SessionEntry>(&line);
9133            }
9134
9135            #[test]
9136            fn arbitrary_bytes_deser_never_panics(
9137                raw in prop::collection::vec(any::<u8>(), 0..512)
9138            ) {
9139                // Even random bytes must not panic serde
9140                if let Ok(s) = String::from_utf8(raw) {
9141                    let _ = serde_json::from_str::<SessionEntry>(&s);
9142                }
9143            }
9144        }
9145
9146        // ------------------------------------------------------------------
9147        // Proptest 3: Valid entries round-trip through serialization
9148        // ------------------------------------------------------------------
9149        proptest! {
9150            #![proptest_config(ProptestConfig {
9151                cases: 256,
9152                max_shrink_iters: 200,
9153                .. ProptestConfig::default()
9154            })]
9155
9156            #[test]
9157            fn valid_entry_round_trip(
9158                entry_json in valid_session_entry_json()
9159            ) {
9160                let json_str = entry_json.to_string();
9161                if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
9162                    // Serialize back
9163                    let reserialized = serde_json::to_string(&entry).unwrap();
9164                    // Deserialize again
9165                    let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
9166                    // Both should have the same entry ID
9167                    assert_eq!(entry.base_id(), re_entry.base_id());
9168                    // Both should have the same type tag
9169                    assert_eq!(
9170                        std::mem::discriminant(&entry),
9171                        std::mem::discriminant(&re_entry)
9172                    );
9173                }
9174            }
9175        }
9176
9177        // ------------------------------------------------------------------
9178        // Proptest 4: Full JSONL load with mixed valid/invalid lines
9179        //             recovers valid entries and reports diagnostics
9180        // ------------------------------------------------------------------
9181        proptest! {
9182            #![proptest_config(ProptestConfig {
9183                cases: 128,
9184                max_shrink_iters: 100,
9185                .. ProptestConfig::default()
9186            })]
9187
9188            #[test]
9189            fn jsonl_corrupted_recovery(
9190                valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
9191                corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
9192                interleave_seed in any::<u64>(),
9193            ) {
9194                let header_json = json!({
9195                    "type": "session",
9196                    "version": 3,
9197                    "id": "testid01",
9198                    "timestamp": "2024-01-01T00:00:00.000Z",
9199                    "cwd": "/tmp/test"
9200                }).to_string();
9201
9202                // Interleave valid and corrupted lines deterministically
9203                let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
9204                let total = valid_strs.len() + corrupted_lines.len();
9205                let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
9206                for s in &valid_strs {
9207                    all_lines.push((true, s.clone()));
9208                }
9209                for s in &corrupted_lines {
9210                    all_lines.push((false, s.clone()));
9211                }
9212
9213                // Deterministic shuffle based on seed
9214                let mut seed = interleave_seed;
9215                for i in (1..all_lines.len()).rev() {
9216                    seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
9217                    let j = (seed >> 33) as usize % (i + 1);
9218                    all_lines.swap(i, j);
9219                }
9220
9221                let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
9222                let content = build_jsonl(&header_json, &entry_lines);
9223
9224                // Write to temp file and load
9225                let temp_dir = tempfile::tempdir().unwrap();
9226                let file_path = temp_dir.path().join("test_session.jsonl");
9227                std::fs::write(&file_path, &content).unwrap();
9228
9229                let (session, diagnostics) = run_async(async {
9230                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9231                }).unwrap();
9232
9233                // Invariant: parsed + skipped == total lines (all non-empty)
9234                let total_parsed = session.entries.len();
9235                assert_eq!(
9236                    total_parsed + diagnostics.skipped_entries.len(),
9237                    total,
9238                    "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
9239                    diagnostics.skipped_entries.len()
9240                );
9241            }
9242        }
9243
9244        // ------------------------------------------------------------------
9245        // Proptest 5: Orphaned parent links are detected
9246        // ------------------------------------------------------------------
9247        proptest! {
9248            #![proptest_config(ProptestConfig {
9249                cases: 128,
9250                max_shrink_iters: 100,
9251                .. ProptestConfig::default()
9252            })]
9253
9254            #[test]
9255            fn orphaned_parent_links_detected(
9256                n_entries in 2usize..10,
9257                orphan_idx in 0usize..8,
9258            ) {
9259                let orphan_idx = orphan_idx % n_entries;
9260                let header_json = json!({
9261                    "type": "session",
9262                    "version": 3,
9263                    "id": "testid01",
9264                    "timestamp": "2024-01-01T00:00:00.000Z",
9265                    "cwd": "/tmp/test"
9266                }).to_string();
9267
9268                let mut entry_lines = Vec::new();
9269                let mut prev_id: Option<String> = None;
9270
9271                for i in 0..n_entries {
9272                    let eid = format!("{i:08x}");
9273                    let parent = if i == orphan_idx {
9274                        // Point to a nonexistent parent
9275                        Some("deadbeef".to_string())
9276                    } else {
9277                        prev_id.clone()
9278                    };
9279
9280                    let entry = json!({
9281                        "type": "message",
9282                        "id": eid,
9283                        "parentId": parent,
9284                        "timestamp": "2024-01-01T00:00:00.000Z",
9285                        "message": {
9286                            "role": "user",
9287                            "content": format!("msg {i}"),
9288                        }
9289                    });
9290                    entry_lines.push(entry.to_string());
9291                    prev_id = Some(eid);
9292                }
9293
9294                let content = build_jsonl(&header_json, &entry_lines);
9295                let temp_dir = tempfile::tempdir().unwrap();
9296                let file_path = temp_dir.path().join("orphan_test.jsonl");
9297                std::fs::write(&file_path, &content).unwrap();
9298
9299                let (_session, diagnostics) = run_async(async {
9300                    Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9301                }).unwrap();
9302
9303                // The orphaned entry should be detected
9304                let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
9305                    o.missing_parent_id == "deadbeef"
9306                });
9307                assert!(
9308                    has_orphan,
9309                    "orphaned parent link to 'deadbeef' should be detected"
9310                );
9311            }
9312        }
9313
9314        // ------------------------------------------------------------------
9315        // Proptest 6: ensure_entry_ids assigns IDs to entries without them
9316        // ------------------------------------------------------------------
9317        proptest! {
9318            #![proptest_config(ProptestConfig {
9319                cases: 128,
9320                max_shrink_iters: 100,
9321                .. ProptestConfig::default()
9322            })]
9323
9324            #[test]
9325            fn ensure_entry_ids_fills_gaps(
9326                n_total in 1usize..20,
9327                missing_mask in prop::collection::vec(any::<bool>(), 1..20),
9328            ) {
9329                let n = n_total.min(missing_mask.len());
9330                let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
9331                    let id = if missing_mask[i] {
9332                        None
9333                    } else {
9334                        Some(format!("{i:08x}"))
9335                    };
9336                    SessionEntry::Message(MessageEntry {
9337                        base: EntryBase {
9338                            id,
9339                            parent_id: None,
9340                            timestamp: "2024-01-01T00:00:00.000Z".to_string(),
9341                        },
9342                        message: SessionMessage::User {
9343                            content: UserContent::Text(format!("msg {i}")),
9344                            timestamp: Some(0),
9345                        },
9346                    })
9347                }).collect();
9348
9349                ensure_entry_ids(&mut entries);
9350
9351                // All entries must have IDs after the call
9352                for entry in &entries {
9353                    assert!(
9354                        entry.base_id().is_some(),
9355                        "all entries must have IDs after ensure_entry_ids"
9356                    );
9357                }
9358
9359                // All IDs must be unique
9360                let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
9361                let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
9362                assert_eq!(
9363                    ids.len(),
9364                    unique.len(),
9365                    "all entry IDs must be unique"
9366                );
9367            }
9368        }
9369
9370        // ------------------------------------------------------------------
9371        // Proptest 7: SessionHeader deserialization with boundary values
9372        // ------------------------------------------------------------------
9373        proptest! {
9374            #![proptest_config(ProptestConfig {
9375                cases: 256,
9376                max_shrink_iters: 200,
9377                .. ProptestConfig::default()
9378            })]
9379
9380            #[test]
9381            fn session_header_deser_never_panics(
9382                version in prop::option::of(0u8..255),
9383                id in "[a-zA-Z0-9-]{0,64}",
9384                ts in timestamp_strategy(),
9385                cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
9386                provider in prop::option::of("[a-z]{2,10}"),
9387                model_id in prop::option::of("[a-z0-9-]{2,20}"),
9388                thinking_level in prop::option::of("[a-z]{2,8}"),
9389            ) {
9390                let mut obj = json!({
9391                    "type": "session",
9392                    "id": id,
9393                    "timestamp": ts,
9394                    "cwd": cwd,
9395                });
9396                if let Some(v) = version {
9397                    obj["version"] = json!(v);
9398                }
9399                if let Some(p) = &provider {
9400                    obj["provider"] = json!(p);
9401                }
9402                if let Some(m) = &model_id {
9403                    obj["modelId"] = json!(m);
9404                }
9405                if let Some(t) = &thinking_level {
9406                    obj["thinkingLevel"] = json!(t);
9407                }
9408                let json_str = obj.to_string();
9409                let _ = serde_json::from_str::<SessionHeader>(&json_str);
9410            }
9411        }
9412
9413        // ------------------------------------------------------------------
9414        // Proptest 8: Edge-case JSONL files
9415        // ------------------------------------------------------------------
9416
9417        #[test]
9418        fn empty_file_returns_error() {
9419            let temp_dir = tempfile::tempdir().unwrap();
9420            let file_path = temp_dir.path().join("empty.jsonl");
9421            std::fs::write(&file_path, "").unwrap();
9422
9423            let result = run_async(async {
9424                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9425            });
9426            assert!(result.is_err(), "empty file should return error");
9427        }
9428
9429        #[test]
9430        fn header_only_file_produces_empty_session() {
9431            let header = json!({
9432                "type": "session",
9433                "version": 3,
9434                "id": "testid01",
9435                "timestamp": "2024-01-01T00:00:00.000Z",
9436                "cwd": "/tmp/test"
9437            })
9438            .to_string();
9439
9440            let temp_dir = tempfile::tempdir().unwrap();
9441            let file_path = temp_dir.path().join("header_only.jsonl");
9442            std::fs::write(&file_path, &header).unwrap();
9443
9444            let (session, diagnostics) = run_async(async {
9445                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9446            })
9447            .unwrap();
9448
9449            assert!(
9450                session.entries.is_empty(),
9451                "header-only file should have no entries"
9452            );
9453            assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
9454        }
9455
9456        #[test]
9457        fn file_with_only_invalid_lines_has_diagnostics() {
9458            let header = json!({
9459                "type": "session",
9460                "version": 3,
9461                "id": "testid01",
9462                "timestamp": "2024-01-01T00:00:00.000Z",
9463                "cwd": "/tmp/test"
9464            })
9465            .to_string();
9466
9467            let content = format!(
9468                "{}\n{}\n{}\n{}",
9469                header,
9470                r#"{"bad":"json","no":"type"}"#,
9471                r"not json at all",
9472                r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
9473            );
9474
9475            let temp_dir = tempfile::tempdir().unwrap();
9476            let file_path = temp_dir.path().join("all_invalid.jsonl");
9477            std::fs::write(&file_path, &content).unwrap();
9478
9479            let (session, diagnostics) = run_async(async {
9480                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9481            })
9482            .unwrap();
9483
9484            assert!(
9485                session.entries.is_empty(),
9486                "all-invalid file should have no entries"
9487            );
9488            assert_eq!(
9489                diagnostics.skipped_entries.len(),
9490                3,
9491                "should have 3 skipped entries"
9492            );
9493        }
9494
9495        #[test]
9496        fn duplicate_entry_ids_are_loaded_without_panic() {
9497            let header = json!({
9498                "type": "session",
9499                "version": 3,
9500                "id": "testid01",
9501                "timestamp": "2024-01-01T00:00:00.000Z",
9502                "cwd": "/tmp/test"
9503            })
9504            .to_string();
9505
9506            let entry1 = json!({
9507                "type": "message",
9508                "id": "deadbeef",
9509                "timestamp": "2024-01-01T00:00:00.000Z",
9510                "message": {"role": "user", "content": "first"}
9511            })
9512            .to_string();
9513
9514            let entry2 = json!({
9515                "type": "message",
9516                "id": "deadbeef",
9517                "timestamp": "2024-01-01T00:00:01.000Z",
9518                "message": {"role": "user", "content": "second (duplicate id)"}
9519            })
9520            .to_string();
9521
9522            let content = format!("{header}\n{entry1}\n{entry2}");
9523
9524            let temp_dir = tempfile::tempdir().unwrap();
9525            let file_path = temp_dir.path().join("dup_ids.jsonl");
9526            std::fs::write(&file_path, &content).unwrap();
9527
9528            // Must not panic
9529            let (session, _diagnostics) = run_async(async {
9530                Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9531            })
9532            .unwrap();
9533
9534            assert_eq!(session.entries.len(), 2, "both entries should be loaded");
9535        }
9536    }
9537
9538    // ------------------------------------------------------------------
9539    // Incremental append tests
9540    // ------------------------------------------------------------------
9541
9542    #[test]
9543    fn test_incremental_append_writes_only_new_entries() {
9544        let temp_dir = tempfile::tempdir().expect("temp dir");
9545        let mut session = Session::create();
9546        session.session_dir = Some(temp_dir.path().to_path_buf());
9547
9548        // First save: full rewrite (persisted_entry_count == 0).
9549        session.append_message(make_test_message("msg A"));
9550        session.append_message(make_test_message("msg B"));
9551        run_async(async { session.save().await }).unwrap();
9552
9553        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
9554        assert_eq!(session.appends_since_checkpoint, 0);
9555
9556        let path = session.path.clone().unwrap();
9557        let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
9558        // 1 header + 2 entries = 3 lines
9559        assert_eq!(lines_after_first, 3);
9560
9561        // Add more entries and save again (incremental append).
9562        session.append_message(make_test_message("msg C"));
9563        run_async(async { session.save().await }).unwrap();
9564
9565        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
9566        assert_eq!(session.appends_since_checkpoint, 1);
9567
9568        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
9569        // 1 header + 3 entries = 4 lines
9570        assert_eq!(lines_after_second, 4);
9571    }
9572
9573    #[test]
9574    fn test_header_change_forces_full_rewrite() {
9575        let temp_dir = tempfile::tempdir().expect("temp dir");
9576        let mut session = Session::create();
9577        session.session_dir = Some(temp_dir.path().to_path_buf());
9578
9579        session.append_message(make_test_message("msg A"));
9580        run_async(async { session.save().await }).unwrap();
9581        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
9582        assert!(!session.header_dirty);
9583
9584        // Modify header.
9585        session.set_model_header(Some("new-provider".to_string()), None, None);
9586        assert!(session.header_dirty);
9587
9588        session.append_message(make_test_message("msg B"));
9589        run_async(async { session.save().await }).unwrap();
9590
9591        // Full rewrite resets all counters.
9592        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
9593        assert!(!session.header_dirty);
9594        assert_eq!(session.appends_since_checkpoint, 0);
9595
9596        // Verify header on disk has the new provider.
9597        let path = session.path.clone().unwrap();
9598        let first_line = std::fs::read_to_string(&path)
9599            .unwrap()
9600            .lines()
9601            .next()
9602            .unwrap()
9603            .to_string();
9604        let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
9605        assert_eq!(header["provider"], "new-provider");
9606    }
9607
9608    #[test]
9609    fn test_compaction_entry_uses_incremental_append() {
9610        let temp_dir = tempfile::tempdir().expect("temp dir");
9611        let mut session = Session::create();
9612        session.session_dir = Some(temp_dir.path().to_path_buf());
9613
9614        let id_a = session.append_message(make_test_message("msg A"));
9615        run_async(async { session.save().await }).unwrap();
9616        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
9617
9618        // Append a compaction entry. This should still be eligible for
9619        // incremental append; checkpoint rewrite cadence handles periodic
9620        // full rewrites for cleanup/corruption recovery.
9621        session.append_compaction("summary".to_string(), id_a, 100, None, None);
9622        session.append_message(make_test_message("msg B"));
9623
9624        run_async(async { session.save().await }).unwrap();
9625
9626        // Incremental append: persisted count advances and checkpoint counter increments.
9627        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
9628        assert_eq!(session.appends_since_checkpoint, 1);
9629
9630        let path = session.path.clone().unwrap();
9631        let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
9632        // 1 header + 3 entries = 4 lines
9633        assert_eq!(lines_after_second, 4);
9634    }
9635
9636    #[test]
9637    fn test_checkpoint_interval_forces_full_rewrite() {
9638        let temp_dir = tempfile::tempdir().expect("temp dir");
9639        let mut session = Session::create();
9640        session.session_dir = Some(temp_dir.path().to_path_buf());
9641
9642        // First save (full rewrite).
9643        session.append_message(make_test_message("initial"));
9644        run_async(async { session.save().await }).unwrap();
9645
9646        // Simulate many incremental appends by setting the counter near threshold.
9647        let interval = compaction_checkpoint_interval();
9648        session.appends_since_checkpoint = interval;
9649
9650        // Next save should trigger full rewrite due to checkpoint.
9651        session.append_message(make_test_message("triggers checkpoint"));
9652        run_async(async { session.save().await }).unwrap();
9653
9654        // Full rewrite resets counters.
9655        assert_eq!(session.appends_since_checkpoint, 0);
9656        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
9657    }
9658
9659    #[test]
9660    fn test_incremental_append_load_round_trip() {
9661        let temp_dir = tempfile::tempdir().expect("temp dir");
9662        let mut session = Session::create();
9663        session.session_dir = Some(temp_dir.path().to_path_buf());
9664
9665        // First save.
9666        session.append_message(make_test_message("msg A"));
9667        session.append_message(make_test_message("msg B"));
9668        run_async(async { session.save().await }).unwrap();
9669
9670        // Incremental append.
9671        session.append_message(make_test_message("msg C"));
9672        run_async(async { session.save().await }).unwrap();
9673
9674        let path = session.path.clone().unwrap();
9675
9676        // Reload and verify all entries present.
9677        let loaded =
9678            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9679
9680        assert_eq!(loaded.entries.len(), 3);
9681        // Verify the entry content by checking that we have messages A, B, C.
9682        let texts: Vec<&str> = loaded
9683            .entries
9684            .iter()
9685            .filter_map(|e| match e {
9686                SessionEntry::Message(m) => match &m.message {
9687                    SessionMessage::User {
9688                        content: UserContent::Text(t),
9689                        ..
9690                    } => Some(t.as_str()),
9691                    _ => None,
9692                },
9693                _ => None,
9694            })
9695            .collect();
9696        assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
9697    }
9698
9699    #[test]
9700    fn test_persisted_entry_count_set_on_open() {
9701        let temp_dir = tempfile::tempdir().expect("temp dir");
9702        let mut session = Session::create();
9703        session.session_dir = Some(temp_dir.path().to_path_buf());
9704
9705        session.append_message(make_test_message("msg A"));
9706        session.append_message(make_test_message("msg B"));
9707        session.append_message(make_test_message("msg C"));
9708        run_async(async { session.save().await }).unwrap();
9709
9710        let path = session.path.clone().unwrap();
9711        let loaded =
9712            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9713
9714        assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
9715        assert!(!loaded.header_dirty);
9716        assert_eq!(loaded.appends_since_checkpoint, 0);
9717    }
9718
9719    #[test]
9720    fn test_no_new_entries_is_noop() {
9721        let temp_dir = tempfile::tempdir().expect("temp dir");
9722        let mut session = Session::create();
9723        session.session_dir = Some(temp_dir.path().to_path_buf());
9724
9725        session.append_message(make_test_message("msg A"));
9726        run_async(async { session.save().await }).unwrap();
9727
9728        let path = session.path.clone().unwrap();
9729        let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
9730
9731        // Sleep briefly to ensure mtime would change if file was written.
9732        std::thread::sleep(std::time::Duration::from_millis(50));
9733
9734        // Save again with no changes.
9735        run_async(async { session.save().await }).unwrap();
9736
9737        let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
9738        assert_eq!(
9739            mtime_before, mtime_after,
9740            "file should not be modified on no-op save"
9741        );
9742        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
9743    }
9744
9745    #[test]
9746    fn test_incremental_append_caches_stay_valid() {
9747        let temp_dir = tempfile::tempdir().expect("temp dir");
9748        let mut session = Session::create();
9749        session.session_dir = Some(temp_dir.path().to_path_buf());
9750
9751        session.append_message(make_test_message("msg A"));
9752        run_async(async { session.save().await }).unwrap();
9753
9754        // After full rewrite, caches rebuilt.
9755        assert_eq!(session.entry_index.len(), 1);
9756
9757        // Incremental append: add more entries.
9758        let id_b = session.append_message(make_test_message("msg B"));
9759        let id_c = session.append_message(make_test_message("msg C"));
9760        run_async(async { session.save().await }).unwrap();
9761
9762        // Caches should still be valid (not rebuilt, but maintained incrementally).
9763        assert_eq!(session.entry_index.len(), 3);
9764        assert!(session.entry_index.contains_key(&id_b));
9765        assert!(session.entry_index.contains_key(&id_c));
9766        assert_eq!(session.cached_message_count, 3);
9767    }
9768
9769    #[test]
9770    fn test_set_branched_from_marks_header_dirty() {
9771        let mut session = Session::create();
9772        assert!(!session.header_dirty);
9773
9774        session.set_branched_from(Some("/some/path".to_string()));
9775        assert!(session.header_dirty);
9776    }
9777
9778    // ====================================================================
9779    // Crash-consistency and recovery tests (bd-3ar8v.2.7)
9780    // ====================================================================
9781
9782    /// Helper: build a valid JSONL session file string with header + N entries.
9783    fn build_crash_test_session_file(num_entries: usize) -> String {
9784        let header = serde_json::json!({
9785            "type": "session",
9786            "version": 3,
9787            "id": "crash-test",
9788            "timestamp": "2024-06-01T00:00:00.000Z",
9789            "cwd": "/tmp/test"
9790        });
9791        let mut lines = vec![serde_json::to_string(&header).unwrap()];
9792        for i in 0..num_entries {
9793            let entry = serde_json::json!({
9794                "type": "message",
9795                "id": format!("entry-{i}"),
9796                "timestamp": "2024-06-01T00:00:00.000Z",
9797                "message": {"role": "user", "content": format!("message {i}")}
9798            });
9799            lines.push(serde_json::to_string(&entry).unwrap());
9800        }
9801        lines.join("\n")
9802    }
9803
9804    #[test]
9805    fn crash_empty_file_returns_error() {
9806        let temp_dir = tempfile::tempdir().unwrap();
9807        let file_path = temp_dir.path().join("empty.jsonl");
9808        std::fs::write(&file_path, "").unwrap();
9809
9810        let result = run_async(async {
9811            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9812        });
9813        assert!(result.is_err(), "empty file should fail to open");
9814    }
9815
9816    #[test]
9817    fn crash_corrupted_header_returns_error() {
9818        let temp_dir = tempfile::tempdir().unwrap();
9819        let file_path = temp_dir.path().join("bad_header.jsonl");
9820        std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
9821
9822        let result = run_async(async {
9823            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9824        });
9825        assert!(result.is_err(), "corrupted header should fail");
9826    }
9827
9828    #[test]
9829    fn crash_header_only_loads_empty() {
9830        let temp_dir = tempfile::tempdir().unwrap();
9831        let file_path = temp_dir.path().join("header_only.jsonl");
9832        let header = serde_json::json!({
9833            "type": "session",
9834            "version": 3,
9835            "id": "hdr-only",
9836            "timestamp": "2024-06-01T00:00:00.000Z",
9837            "cwd": "/tmp/test"
9838        });
9839        std::fs::write(
9840            &file_path,
9841            format!("{}\n", serde_json::to_string(&header).unwrap()),
9842        )
9843        .unwrap();
9844
9845        let (session, diagnostics) = run_async(async {
9846            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9847        })
9848        .unwrap();
9849
9850        assert!(session.entries.is_empty());
9851        assert!(diagnostics.skipped_entries.is_empty());
9852    }
9853
9854    #[test]
9855    fn crash_truncated_last_entry_recovers_preceding() {
9856        let temp_dir = tempfile::tempdir().unwrap();
9857        let file_path = temp_dir.path().join("truncated.jsonl");
9858
9859        let mut content = build_crash_test_session_file(3);
9860        let truncation_point = content.rfind('\n').unwrap();
9861        content.truncate(truncation_point);
9862        content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
9863
9864        std::fs::write(&file_path, &content).unwrap();
9865
9866        let (session, diagnostics) = run_async(async {
9867            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9868        })
9869        .unwrap();
9870
9871        assert_eq!(session.entries.len(), 2);
9872        assert_eq!(diagnostics.skipped_entries.len(), 1);
9873    }
9874
9875    #[test]
9876    fn crash_multiple_corrupted_entries_recovers_valid() {
9877        let temp_dir = tempfile::tempdir().unwrap();
9878        let file_path = temp_dir.path().join("multi_corrupt.jsonl");
9879
9880        let header = serde_json::json!({
9881            "type": "session",
9882            "version": 3,
9883            "id": "multi-corrupt",
9884            "timestamp": "2024-06-01T00:00:00.000Z",
9885            "cwd": "/tmp/test"
9886        });
9887
9888        let valid_entry = |id: &str, text: &str| {
9889            serde_json::json!({
9890                "type": "message",
9891                "id": id,
9892                "timestamp": "2024-06-01T00:00:00.000Z",
9893                "message": {"role": "user", "content": text}
9894            })
9895            .to_string()
9896        };
9897
9898        let lines = [
9899            serde_json::to_string(&header).unwrap(),
9900            valid_entry("v1", "first"),
9901            "GARBAGE LINE 1".to_string(),
9902            valid_entry("v2", "second"),
9903            "{incomplete json".to_string(),
9904            valid_entry("v3", "third"),
9905        ];
9906
9907        std::fs::write(&file_path, lines.join("\n")).unwrap();
9908
9909        let (session, diagnostics) = run_async(async {
9910            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
9911        })
9912        .unwrap();
9913
9914        assert_eq!(session.entries.len(), 3, "3 valid entries survive");
9915        assert_eq!(diagnostics.skipped_entries.len(), 2);
9916    }
9917
9918    #[test]
9919    fn crash_incremental_append_survives_partial_write() {
9920        use std::io::Write;
9921
9922        let temp_dir = tempfile::tempdir().unwrap();
9923        let mut session = Session::create();
9924        session.session_dir = Some(temp_dir.path().to_path_buf());
9925
9926        session.append_message(make_test_message("msg A"));
9927        session.append_message(make_test_message("msg B"));
9928        run_async(async { session.save().await }).unwrap();
9929        let path = session.path.clone().unwrap();
9930
9931        // Simulate crash during append: write truncated entry.
9932        let mut file = std::fs::OpenOptions::new()
9933            .append(true)
9934            .open(&path)
9935            .unwrap();
9936        write!(
9937            file,
9938            "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
9939        )
9940        .unwrap();
9941        drop(file);
9942
9943        let (loaded, diagnostics) = run_async(async {
9944            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
9945        })
9946        .unwrap();
9947
9948        assert_eq!(loaded.entries.len(), 2, "original entries recovered");
9949        assert_eq!(diagnostics.skipped_entries.len(), 1);
9950    }
9951
9952    #[test]
9953    fn crash_full_rewrite_atomic_persist() {
9954        let temp_dir = tempfile::tempdir().unwrap();
9955        let mut session = Session::create();
9956        session.session_dir = Some(temp_dir.path().to_path_buf());
9957
9958        session.append_message(make_test_message("original"));
9959        run_async(async { session.save().await }).unwrap();
9960        let path = session.path.clone().unwrap();
9961
9962        let original_content = std::fs::read_to_string(&path).unwrap();
9963
9964        session.set_model_header(Some("new-provider".to_string()), None, None);
9965        session.append_message(make_test_message("second"));
9966        run_async(async { session.save().await }).unwrap();
9967
9968        let new_content = std::fs::read_to_string(&path).unwrap();
9969        assert_ne!(original_content, new_content);
9970
9971        let loaded =
9972            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9973        assert_eq!(loaded.entries.len(), 2);
9974    }
9975
9976    #[test]
9977    fn full_rewrite_preserves_entries_appended_by_other_writer() {
9978        let temp_dir = tempfile::tempdir().unwrap();
9979        let mut session = Session::create();
9980        session.session_dir = Some(temp_dir.path().to_path_buf());
9981
9982        session.append_message(make_test_message("original"));
9983        run_async(async { session.save().await }).unwrap();
9984        let path = session.path.clone().unwrap();
9985
9986        let mut stale_rewriter =
9987            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9988        let mut appender =
9989            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9990
9991        appender.append_message(make_test_message("from appender"));
9992        run_async(async { appender.save().await }).unwrap();
9993
9994        stale_rewriter.set_model_header(Some("new-provider".to_string()), None, None);
9995        run_async(async { stale_rewriter.save().await }).unwrap();
9996
9997        let loaded =
9998            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9999        let entry_texts = loaded
10000            .entries
10001            .iter()
10002            .filter_map(|entry| match entry {
10003                SessionEntry::Message(message) => match &message.message {
10004                    SessionMessage::User { content, .. } => match content {
10005                        UserContent::Text(text) => Some(text.clone()),
10006                        UserContent::Blocks(_) => None,
10007                    },
10008                    SessionMessage::Assistant { message } => {
10009                        message.content.iter().find_map(|block| match block {
10010                            ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()),
10011                            _ => None,
10012                        })
10013                    }
10014                    SessionMessage::ToolResult { .. } => None,
10015                    SessionMessage::Custom { .. } => None,
10016                    SessionMessage::BashExecution { .. } => None,
10017                    SessionMessage::BranchSummary { .. } => None,
10018                    SessionMessage::CompactionSummary { .. } => None,
10019                },
10020                _ => None,
10021            })
10022            .collect::<Vec<_>>();
10023
10024        assert!(
10025            entry_texts.iter().any(|text| text == "from appender"),
10026            "full rewrite should preserve entries appended after this session was opened"
10027        );
10028        assert_eq!(loaded.header.provider.as_deref(), Some("new-provider"));
10029    }
10030
10031    #[test]
10032    fn crash_flush_failure_restores_pending_mutations() {
10033        let mut queue = AutosaveQueue::with_limit(10);
10034
10035        queue.enqueue_mutation(AutosaveMutationKind::Message);
10036        queue.enqueue_mutation(AutosaveMutationKind::Message);
10037        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10038        assert_eq!(queue.pending_mutations, 3);
10039
10040        let ticket = queue
10041            .begin_flush(AutosaveFlushTrigger::Periodic)
10042            .expect("should have ticket");
10043        assert_eq!(queue.pending_mutations, 0);
10044
10045        queue.finish_flush(ticket, false);
10046        assert_eq!(queue.pending_mutations, 3, "mutations restored");
10047        assert_eq!(queue.flush_failed, 1);
10048    }
10049
10050    #[test]
10051    fn crash_flush_failure_respects_queue_capacity() {
10052        let mut queue = AutosaveQueue::with_limit(3);
10053
10054        for _ in 0..3 {
10055            queue.enqueue_mutation(AutosaveMutationKind::Message);
10056        }
10057        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10058
10059        queue.enqueue_mutation(AutosaveMutationKind::Message);
10060        queue.enqueue_mutation(AutosaveMutationKind::Message);
10061        assert_eq!(queue.pending_mutations, 2);
10062
10063        queue.finish_flush(ticket, false);
10064        assert_eq!(queue.pending_mutations, 3, "capped at max");
10065        assert!(queue.backpressure_events >= 2);
10066    }
10067
10068    #[test]
10069    fn crash_shutdown_strict_propagates_error() {
10070        let temp_dir = tempfile::tempdir().unwrap();
10071        let mut session = Session::create();
10072        session.path = Some(
10073            temp_dir
10074                .path()
10075                .join("nonexistent_dir")
10076                .join("session.jsonl"),
10077        );
10078        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
10079        session.append_message(make_test_message("must save"));
10080        session
10081            .autosave_queue
10082            .enqueue_mutation(AutosaveMutationKind::Message);
10083
10084        let result = run_async(async { session.flush_autosave_on_shutdown().await });
10085        assert!(result.is_err(), "strict mode propagates errors");
10086    }
10087
10088    #[test]
10089    fn crash_shutdown_balanced_swallows_error() {
10090        let temp_dir = tempfile::tempdir().unwrap();
10091        let mut session = Session::create();
10092        session.path = Some(
10093            temp_dir
10094                .path()
10095                .join("nonexistent_dir")
10096                .join("session.jsonl"),
10097        );
10098        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
10099        session.append_message(make_test_message("best effort"));
10100        session
10101            .autosave_queue
10102            .enqueue_mutation(AutosaveMutationKind::Message);
10103
10104        let result = run_async(async { session.flush_autosave_on_shutdown().await });
10105        assert!(result.is_ok(), "balanced mode swallows errors");
10106    }
10107
10108    #[test]
10109    fn crash_shutdown_throughput_skips_flush() {
10110        let temp_dir = tempfile::tempdir().unwrap();
10111        let mut session = Session::create();
10112        session.path = Some(
10113            temp_dir
10114                .path()
10115                .join("nonexistent_dir")
10116                .join("session.jsonl"),
10117        );
10118        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
10119        session.append_message(make_test_message("no flush"));
10120        session
10121            .autosave_queue
10122            .enqueue_mutation(AutosaveMutationKind::Message);
10123
10124        let result = run_async(async { session.flush_autosave_on_shutdown().await });
10125        assert!(result.is_ok());
10126        assert!(session.autosave_queue.pending_mutations > 0);
10127    }
10128
10129    #[test]
10130    fn crash_save_reload_preserves_all_entry_types() {
10131        let temp_dir = tempfile::tempdir().unwrap();
10132        let mut session = Session::create();
10133        session.session_dir = Some(temp_dir.path().to_path_buf());
10134
10135        let id_a = session.append_message(make_test_message("msg A"));
10136        session.append_model_change("provider-x".to_string(), "model-y".to_string());
10137        session.append_thinking_level_change("high".to_string());
10138        session.append_compaction("summary".to_string(), id_a, 500, None, None);
10139        session.append_message(make_test_message("msg B"));
10140
10141        run_async(async { session.save().await }).unwrap();
10142        let path = session.path.clone().unwrap();
10143
10144        let loaded =
10145            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10146        assert_eq!(loaded.entries.len(), session.entries.len());
10147    }
10148
10149    #[test]
10150    fn crash_checkpoint_rewrite_cleans_corruption() {
10151        let temp_dir = tempfile::tempdir().unwrap();
10152        let mut session = Session::create();
10153        session.session_dir = Some(temp_dir.path().to_path_buf());
10154
10155        session.append_message(make_test_message("initial"));
10156        run_async(async { session.save().await }).unwrap();
10157        let path = session.path.clone().unwrap();
10158
10159        for i in 0..5 {
10160            session.append_message(make_test_message(&format!("msg {i}")));
10161            run_async(async { session.save().await }).unwrap();
10162        }
10163
10164        // Corrupt an appended entry on disk.
10165        let content = std::fs::read_to_string(&path).unwrap();
10166        let mut lines: Vec<String> = content.lines().map(String::from).collect();
10167        lines[3] = "CORRUPTED_ENTRY".to_string();
10168        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
10169
10170        // Force checkpoint: full rewrite replaces corrupted file with clean data.
10171        session.appends_since_checkpoint = compaction_checkpoint_interval();
10172        session.append_message(make_test_message("post checkpoint"));
10173        run_async(async { session.save().await }).unwrap();
10174        assert_eq!(session.appends_since_checkpoint, 0);
10175
10176        let (reloaded, diagnostics) = run_async(async {
10177            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
10178        })
10179        .unwrap();
10180        assert!(diagnostics.skipped_entries.is_empty());
10181        assert_eq!(reloaded.entries.len(), 7);
10182    }
10183
10184    #[test]
10185    fn crash_trailing_newlines_loads_cleanly() {
10186        let temp_dir = tempfile::tempdir().unwrap();
10187        let file_path = temp_dir.path().join("trailing_nl.jsonl");
10188
10189        let mut content = build_crash_test_session_file(2);
10190        content.push_str("\n\n\n");
10191        std::fs::write(&file_path, &content).unwrap();
10192
10193        let (session, diagnostics) = run_async(async {
10194            Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10195        })
10196        .unwrap();
10197
10198        assert_eq!(session.entries.len(), 2);
10199        assert!(diagnostics.skipped_entries.is_empty());
10200    }
10201
10202    #[test]
10203    fn crash_noop_save_after_reload_is_idempotent() {
10204        let temp_dir = tempfile::tempdir().unwrap();
10205        let mut session = Session::create();
10206        session.session_dir = Some(temp_dir.path().to_path_buf());
10207
10208        session.append_message(make_test_message("hello"));
10209        run_async(async { session.save().await }).unwrap();
10210        let path = session.path.clone().unwrap();
10211        let content_before = std::fs::read_to_string(&path).unwrap();
10212
10213        let mut loaded =
10214            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10215        run_async(async { loaded.save().await }).unwrap();
10216
10217        let content_after = std::fs::read_to_string(&path).unwrap();
10218        assert_eq!(content_before, content_after);
10219    }
10220
10221    #[test]
10222    fn crash_corrupt_then_continue_operation() {
10223        let temp_dir = tempfile::tempdir().unwrap();
10224        let mut session = Session::create();
10225        session.session_dir = Some(temp_dir.path().to_path_buf());
10226
10227        session.append_message(make_test_message("msg A"));
10228        session.append_message(make_test_message("msg B"));
10229        run_async(async { session.save().await }).unwrap();
10230        let path = session.path.clone().unwrap();
10231
10232        // Corrupt last entry.
10233        let content = std::fs::read_to_string(&path).unwrap();
10234        let mut lines: Vec<String> = content.lines().map(String::from).collect();
10235        *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
10236        std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
10237
10238        let (mut recovered, diagnostics) = run_async(async {
10239            Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
10240        })
10241        .unwrap();
10242        assert_eq!(diagnostics.skipped_entries.len(), 1);
10243        assert_eq!(recovered.entries.len(), 1);
10244
10245        // Continue: add and save.
10246        recovered.path = Some(path.clone());
10247        recovered.session_dir = Some(temp_dir.path().to_path_buf());
10248        recovered.append_message(make_test_message("msg C"));
10249        run_async(async { recovered.save().await }).unwrap();
10250
10251        let reloaded =
10252            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10253        assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
10254    }
10255
10256    #[test]
10257    fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
10258        let temp_dir = tempfile::tempdir().unwrap();
10259        let mut session = Session::create();
10260        session.session_dir = Some(temp_dir.path().to_path_buf());
10261
10262        session.append_message(make_test_message("msg A"));
10263        run_async(async { session.save().await }).unwrap();
10264
10265        session.persisted_entry_count.store(999, Ordering::SeqCst);
10266        assert!(session.should_full_rewrite());
10267
10268        session.append_message(make_test_message("msg B"));
10269        run_async(async { session.save().await }).unwrap();
10270        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10271        assert_eq!(session.appends_since_checkpoint, 0);
10272    }
10273
10274    #[test]
10275    fn crash_persisted_count_unchanged_on_append_failure() {
10276        let temp_dir = tempfile::tempdir().unwrap();
10277        let mut session = Session::create();
10278        session.session_dir = Some(temp_dir.path().to_path_buf());
10279
10280        session.append_message(make_test_message("msg A"));
10281        run_async(async { session.save().await }).unwrap();
10282        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10283
10284        let path = session.path.clone().unwrap();
10285        session.append_message(make_test_message("msg B"));
10286
10287        #[cfg(unix)]
10288        {
10289            use std::os::unix::fs::PermissionsExt;
10290            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
10291            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
10292                // Some environments (for example root-run test runners) bypass chmod restrictions.
10293                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10294                return;
10295            }
10296        }
10297        #[cfg(not(unix))]
10298        {
10299            return;
10300        }
10301
10302        let result = run_async(async { session.save().await });
10303
10304        #[cfg(unix)]
10305        {
10306            use std::os::unix::fs::PermissionsExt;
10307            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10308        }
10309
10310        assert!(result.is_err());
10311        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10312
10313        run_async(async { session.save().await }).unwrap();
10314        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10315    }
10316
10317    #[test]
10318    fn crash_missing_session_file_forces_full_rewrite_recovery() {
10319        let temp_dir = tempfile::tempdir().unwrap();
10320        let mut session = Session::create();
10321        session.session_dir = Some(temp_dir.path().to_path_buf());
10322
10323        session.append_message(make_test_message("msg A"));
10324        run_async(async { session.save().await }).unwrap();
10325
10326        let path = session.path.clone().unwrap();
10327        std::fs::remove_file(&path).unwrap();
10328        assert!(session.should_full_rewrite());
10329
10330        session.append_message(make_test_message("msg B"));
10331        run_async(async { session.save().await }).unwrap();
10332
10333        let reloaded =
10334            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10335        assert_eq!(reloaded.entries.len(), 2);
10336        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10337        assert_eq!(session.appends_since_checkpoint, 0);
10338    }
10339
10340    #[test]
10341    fn crash_queue_backpressure_at_limit() {
10342        let mut queue = AutosaveQueue::with_limit(3);
10343
10344        queue.enqueue_mutation(AutosaveMutationKind::Message);
10345        queue.enqueue_mutation(AutosaveMutationKind::Message);
10346        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10347        assert_eq!(queue.pending_mutations, 3);
10348
10349        queue.enqueue_mutation(AutosaveMutationKind::Label);
10350        assert_eq!(queue.pending_mutations, 3, "capped");
10351        assert_eq!(queue.backpressure_events, 1);
10352    }
10353
10354    #[test]
10355    fn crash_flush_failure_with_intervening_mutations() {
10356        let mut queue = AutosaveQueue::with_limit(8);
10357
10358        for _ in 0..4 {
10359            queue.enqueue_mutation(AutosaveMutationKind::Message);
10360        }
10361        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10362
10363        queue.enqueue_mutation(AutosaveMutationKind::Message);
10364        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10365        assert_eq!(queue.pending_mutations, 2);
10366
10367        // restore_budget = 8 - 2 = 6, restored = min(4, 6) = 4
10368        queue.finish_flush(ticket, false);
10369        assert_eq!(queue.pending_mutations, 6);
10370        assert_eq!(queue.flush_failed, 1);
10371    }
10372
10373    #[test]
10374    fn crash_queue_metrics_snapshot() {
10375        let mut queue = AutosaveQueue::with_limit(5);
10376        queue.enqueue_mutation(AutosaveMutationKind::Message);
10377        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10378        queue.enqueue_mutation(AutosaveMutationKind::Label);
10379
10380        let metrics = queue.metrics();
10381        assert_eq!(metrics.pending_mutations, 3);
10382        assert_eq!(metrics.max_pending_mutations, 5);
10383        assert_eq!(metrics.coalesced_mutations, 2);
10384        assert_eq!(metrics.flush_started, 0);
10385        assert!(metrics.last_flush_duration_ms.is_none());
10386    }
10387
10388    #[test]
10389    fn crash_double_flush_is_noop() {
10390        let mut queue = AutosaveQueue::with_limit(10);
10391        queue.enqueue_mutation(AutosaveMutationKind::Message);
10392        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10393        queue.finish_flush(ticket, true);
10394
10395        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
10396    }
10397
10398    #[test]
10399    fn crash_finish_worker_result_propagates_panic_before_cancellation() {
10400        let handle = thread::spawn(|| -> () {
10401            panic!("jsonl worker panic");
10402        });
10403
10404        let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
10405            let _: Result<()> =
10406                finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled");
10407        }));
10408
10409        assert!(
10410            panic.is_err(),
10411            "worker panic should not be masked as cancellation"
10412        );
10413    }
10414
10415    #[test]
10416    fn crash_finish_worker_result_maps_nonpanic_cancellation_to_session_error() {
10417        let handle = thread::spawn(|| {});
10418
10419        let err = finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled")
10420            .expect_err("error");
10421
10422        assert!(
10423            err.to_string().contains("Save task cancelled"),
10424            "unexpected error: {err}"
10425        );
10426    }
10427
10428    #[test]
10429    fn crash_finish_worker_result_returns_success_payload() {
10430        let handle = thread::spawn(|| {});
10431
10432        let value =
10433            finish_worker_result::<usize, ()>(handle, Ok(Ok(7usize)), "task cancelled").unwrap();
10434
10435        assert_eq!(value, 7);
10436    }
10437
10438    #[test]
10439    fn crash_entries_survive_failed_full_rewrite() {
10440        // Entries are cloned during full rewrite to avoid losing them if the async future drops.
10441        // On error, the session must still contain the entries in memory.
10442        let temp_dir = tempfile::tempdir().unwrap();
10443        let mut session = Session::create();
10444        session.session_dir = Some(temp_dir.path().to_path_buf());
10445
10446        session.append_message(make_test_message("msg A"));
10447        run_async(async { session.save().await }).unwrap();
10448        let path = session.path.clone().unwrap();
10449
10450        session.set_model_header(Some("new-provider".to_string()), None, None);
10451        session.append_message(make_test_message("msg B"));
10452
10453        #[cfg(unix)]
10454        {
10455            use std::os::unix::fs::PermissionsExt;
10456            let parent = path.parent().unwrap();
10457            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
10458            if tempfile::NamedTempFile::new_in(parent).is_ok() {
10459                // Some environments (for example root-run test runners) bypass chmod restrictions.
10460                std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
10461                return;
10462            }
10463        }
10464        #[cfg(not(unix))]
10465        {
10466            return;
10467        }
10468
10469        let result = run_async(async { session.save().await });
10470        assert!(result.is_err());
10471
10472        assert_eq!(session.entries.len(), 2, "entries restored");
10473        assert_eq!(session.entry_index.len(), 2);
10474        assert!(session.header_dirty);
10475
10476        #[cfg(unix)]
10477        {
10478            use std::os::unix::fs::PermissionsExt;
10479            let parent = path.parent().unwrap();
10480            std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
10481        }
10482
10483        run_async(async { session.save().await }).unwrap();
10484        assert!(!session.header_dirty);
10485    }
10486
10487    #[test]
10488    fn crash_metrics_accumulate_across_failure_recovery() {
10489        let temp_dir = tempfile::tempdir().unwrap();
10490        let mut session = Session::create();
10491        session.session_dir = Some(temp_dir.path().to_path_buf());
10492
10493        session.append_message(make_test_message("msg A"));
10494        run_async(async { session.save().await }).unwrap();
10495        let path = session.path.clone().unwrap();
10496
10497        let m = session.autosave_metrics();
10498        assert_eq!(m.flush_succeeded, 1);
10499        assert_eq!(m.flush_failed, 0);
10500
10501        #[cfg(unix)]
10502        {
10503            use std::os::unix::fs::PermissionsExt;
10504            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
10505            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
10506                // Some environments (for example root-run test runners) bypass chmod restrictions.
10507                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10508                return;
10509            }
10510        }
10511        #[cfg(not(unix))]
10512        {
10513            return;
10514        }
10515
10516        session.append_message(make_test_message("msg B"));
10517        let _ = run_async(async { session.save().await });
10518
10519        let m = session.autosave_metrics();
10520        assert_eq!(m.flush_failed, 1);
10521        assert!(m.pending_mutations > 0);
10522
10523        #[cfg(unix)]
10524        {
10525            use std::os::unix::fs::PermissionsExt;
10526            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10527        }
10528        run_async(async { session.save().await }).unwrap();
10529
10530        let m = session.autosave_metrics();
10531        assert_eq!(m.flush_succeeded, 2);
10532        assert_eq!(m.flush_failed, 1);
10533        assert_eq!(m.pending_mutations, 0);
10534        assert_eq!(m.flush_started, 3);
10535    }
10536
10537    #[test]
10538    fn crash_many_sequential_appends_accumulate() {
10539        let temp_dir = tempfile::tempdir().unwrap();
10540        let mut session = Session::create();
10541        session.session_dir = Some(temp_dir.path().to_path_buf());
10542
10543        session.append_message(make_test_message("initial"));
10544        run_async(async { session.save().await }).unwrap();
10545
10546        for i in 0..10 {
10547            session.append_message(make_test_message(&format!("append-{i}")));
10548            run_async(async { session.save().await }).unwrap();
10549        }
10550
10551        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
10552        assert_eq!(session.appends_since_checkpoint, 10);
10553
10554        let path = session.path.clone().unwrap();
10555        let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
10556        assert_eq!(line_count, 12, "1 header + 11 entries");
10557
10558        let loaded =
10559            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10560        assert_eq!(loaded.entries.len(), 11);
10561    }
10562
10563    #[test]
10564    fn crash_load_unsaved_entry_absent() {
10565        let temp_dir = tempfile::tempdir().unwrap();
10566        let mut session = Session::create();
10567        session.session_dir = Some(temp_dir.path().to_path_buf());
10568
10569        session.append_message(make_test_message("saved A"));
10570        session.append_message(make_test_message("saved B"));
10571        run_async(async { session.save().await }).unwrap();
10572        let path = session.path.clone().unwrap();
10573
10574        session.append_message(make_test_message("unsaved C"));
10575        assert_eq!(session.entries.len(), 3);
10576
10577        let loaded =
10578            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10579        assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
10580    }
10581
10582    #[test]
10583    fn test_clone_has_independent_persisted_entry_count() {
10584        let session = Session::create();
10585        // Set initial count
10586        session.persisted_entry_count.store(10, Ordering::SeqCst);
10587
10588        // Clone the session
10589        let clone = session.clone();
10590
10591        // Verify clone sees initial value
10592        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
10593
10594        // Update original
10595        session.persisted_entry_count.store(20, Ordering::SeqCst);
10596
10597        // Verify clone is UNCHANGED (independent atomic)
10598        assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
10599
10600        // Update clone
10601        clone.persisted_entry_count.store(30, Ordering::SeqCst);
10602
10603        // Verify original is UNCHANGED
10604        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
10605    }
10606
10607    #[test]
10608    fn crash_append_retry_after_transient_failure() {
10609        let temp_dir = tempfile::tempdir().unwrap();
10610        let mut session = Session::create();
10611        session.session_dir = Some(temp_dir.path().to_path_buf());
10612
10613        session.append_message(make_test_message("msg A"));
10614        run_async(async { session.save().await }).unwrap();
10615        let path = session.path.clone().unwrap();
10616
10617        session.append_message(make_test_message("msg B"));
10618
10619        #[cfg(unix)]
10620        {
10621            use std::os::unix::fs::PermissionsExt;
10622            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
10623            if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
10624                // Some environments (for example root-run test runners) bypass chmod restrictions.
10625                std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10626                return;
10627            }
10628        }
10629        #[cfg(not(unix))]
10630        {
10631            return;
10632        }
10633
10634        let result = run_async(async { session.save().await });
10635        assert!(result.is_err());
10636        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10637
10638        #[cfg(unix)]
10639        {
10640            use std::os::unix::fs::PermissionsExt;
10641            std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
10642        }
10643
10644        run_async(async { session.save().await }).unwrap();
10645        assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10646
10647        let loaded =
10648            run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10649        assert_eq!(loaded.entries.len(), 2);
10650    }
10651
10652    #[test]
10653    fn crash_durability_mode_parsing() {
10654        assert_eq!(
10655            AutosaveDurabilityMode::parse("strict"),
10656            Some(AutosaveDurabilityMode::Strict)
10657        );
10658        assert_eq!(
10659            AutosaveDurabilityMode::parse("BALANCED"),
10660            Some(AutosaveDurabilityMode::Balanced)
10661        );
10662        assert_eq!(
10663            AutosaveDurabilityMode::parse("  Throughput  "),
10664            Some(AutosaveDurabilityMode::Throughput)
10665        );
10666        assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
10667        assert_eq!(AutosaveDurabilityMode::parse(""), None);
10668    }
10669
10670    #[test]
10671    fn crash_durability_resolve_precedence() {
10672        assert_eq!(
10673            resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
10674            AutosaveDurabilityMode::Strict
10675        );
10676        assert_eq!(
10677            resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
10678            AutosaveDurabilityMode::Throughput
10679        );
10680        assert_eq!(
10681            resolve_autosave_durability_mode(None, None, Some("strict")),
10682            AutosaveDurabilityMode::Strict
10683        );
10684        assert_eq!(
10685            resolve_autosave_durability_mode(None, None, None),
10686            AutosaveDurabilityMode::Balanced
10687        );
10688    }
10689
10690    // =========================================================================
10691    // bd-3ar8v.2.9: Comprehensive autosave queue and durability state machine
10692    // =========================================================================
10693
10694    // --- Queue boundary: minimum capacity (limit=1) ---
10695
10696    #[test]
10697    fn autosave_queue_limit_one_accepts_single_mutation() {
10698        let mut queue = AutosaveQueue::with_limit(1);
10699        queue.enqueue_mutation(AutosaveMutationKind::Message);
10700        assert_eq!(queue.pending_mutations, 1);
10701        assert_eq!(queue.coalesced_mutations, 0);
10702        assert_eq!(queue.backpressure_events, 0);
10703    }
10704
10705    #[test]
10706    fn autosave_queue_limit_one_backpressures_second_mutation() {
10707        let mut queue = AutosaveQueue::with_limit(1);
10708        queue.enqueue_mutation(AutosaveMutationKind::Message);
10709        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10710        assert_eq!(queue.pending_mutations, 1, "capped at 1");
10711        assert_eq!(queue.backpressure_events, 1);
10712        assert_eq!(queue.coalesced_mutations, 1);
10713    }
10714
10715    #[test]
10716    fn autosave_queue_limit_one_flush_and_refill() {
10717        let mut queue = AutosaveQueue::with_limit(1);
10718        queue.enqueue_mutation(AutosaveMutationKind::Message);
10719
10720        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
10721        assert_eq!(queue.pending_mutations, 0);
10722        assert_eq!(ticket.batch_size, 1);
10723        queue.finish_flush(ticket, true);
10724
10725        // Refill works after flush.
10726        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10727        assert_eq!(queue.pending_mutations, 1);
10728        assert_eq!(queue.flush_succeeded, 1);
10729    }
10730
10731    // --- Queue boundary: with_limit enforces minimum of 1 ---
10732
10733    #[test]
10734    fn autosave_queue_with_limit_zero_clamps_to_one() {
10735        let queue = AutosaveQueue::with_limit(0);
10736        assert_eq!(queue.max_pending_mutations, 1);
10737    }
10738
10739    // --- Empty queue operations ---
10740
10741    #[test]
10742    fn autosave_queue_begin_flush_on_empty_returns_none() {
10743        let mut queue = AutosaveQueue::with_limit(10);
10744        assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
10745        assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
10746    }
10747
10748    #[test]
10749    fn autosave_queue_metrics_on_fresh_queue() {
10750        let queue = AutosaveQueue::with_limit(256);
10751        let m = queue.metrics();
10752        assert_eq!(m.pending_mutations, 0);
10753        assert_eq!(m.max_pending_mutations, 256);
10754        assert_eq!(m.coalesced_mutations, 0);
10755        assert_eq!(m.backpressure_events, 0);
10756        assert_eq!(m.flush_started, 0);
10757        assert_eq!(m.flush_succeeded, 0);
10758        assert_eq!(m.flush_failed, 0);
10759        assert_eq!(m.last_flush_batch_size, 0);
10760        assert!(m.last_flush_duration_ms.is_none());
10761        assert!(m.last_flush_trigger.is_none());
10762    }
10763
10764    // --- All three mutation kinds ---
10765
10766    #[test]
10767    fn autosave_queue_all_mutation_kinds() {
10768        let mut queue = AutosaveQueue::with_limit(10);
10769        queue.enqueue_mutation(AutosaveMutationKind::Message);
10770        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10771        queue.enqueue_mutation(AutosaveMutationKind::Label);
10772        assert_eq!(queue.pending_mutations, 3);
10773        // First mutation has no coalescing; subsequent two do.
10774        assert_eq!(queue.coalesced_mutations, 2);
10775    }
10776
10777    // --- Multiple consecutive flushes with mixed outcomes ---
10778
10779    #[test]
10780    fn autosave_queue_consecutive_success_flushes() {
10781        let mut queue = AutosaveQueue::with_limit(5);
10782
10783        for round in 1..=3_u64 {
10784            queue.enqueue_mutation(AutosaveMutationKind::Message);
10785            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10786            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10787            queue.finish_flush(ticket, true);
10788            assert_eq!(queue.pending_mutations, 0);
10789            assert_eq!(queue.flush_succeeded, round);
10790            assert_eq!(queue.flush_started, round);
10791            assert_eq!(queue.last_flush_batch_size, 2);
10792        }
10793        assert_eq!(queue.flush_failed, 0);
10794    }
10795
10796    #[test]
10797    fn autosave_queue_alternating_success_failure() {
10798        let mut queue = AutosaveQueue::with_limit(10);
10799
10800        // Round 1: success
10801        queue.enqueue_mutation(AutosaveMutationKind::Message);
10802        let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10803        queue.finish_flush(t1, true);
10804        assert_eq!(queue.flush_succeeded, 1);
10805        assert_eq!(queue.flush_failed, 0);
10806        assert_eq!(queue.pending_mutations, 0);
10807
10808        // Round 2: failure (mutations restored)
10809        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10810        queue.enqueue_mutation(AutosaveMutationKind::Label);
10811        let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
10812        queue.finish_flush(t2, false);
10813        assert_eq!(queue.flush_succeeded, 1);
10814        assert_eq!(queue.flush_failed, 1);
10815        assert_eq!(queue.pending_mutations, 2, "restored from failure");
10816
10817        // Round 3: success (clears the restored mutations)
10818        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
10819        assert_eq!(t3.batch_size, 2);
10820        queue.finish_flush(t3, true);
10821        assert_eq!(queue.flush_succeeded, 2);
10822        assert_eq!(queue.flush_failed, 1);
10823        assert_eq!(queue.pending_mutations, 0);
10824        assert_eq!(queue.flush_started, 3);
10825    }
10826
10827    // --- Failure when queue is completely full (zero capacity to restore) ---
10828
10829    #[test]
10830    fn autosave_queue_failure_drops_all_when_full() {
10831        let mut queue = AutosaveQueue::with_limit(3);
10832
10833        // Fill to capacity and flush.
10834        for _ in 0..3 {
10835            queue.enqueue_mutation(AutosaveMutationKind::Message);
10836        }
10837        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10838        assert_eq!(ticket.batch_size, 3);
10839        assert_eq!(queue.pending_mutations, 0);
10840
10841        // Fill queue completely while flush is in flight.
10842        for _ in 0..3 {
10843            queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10844        }
10845        assert_eq!(queue.pending_mutations, 3);
10846
10847        // Flush fails — no capacity to restore, all 3 batch mutations are dropped.
10848        let bp_before = queue.backpressure_events;
10849        queue.finish_flush(ticket, false);
10850        assert_eq!(queue.pending_mutations, 3, "capped at max");
10851        assert_eq!(queue.flush_failed, 1);
10852        assert_eq!(
10853            queue.backpressure_events,
10854            bp_before + 3,
10855            "dropped mutations counted as backpressure"
10856        );
10857    }
10858
10859    // --- Flush trigger tracking ---
10860
10861    #[test]
10862    fn autosave_queue_tracks_trigger_across_flushes() {
10863        let mut queue = AutosaveQueue::with_limit(10);
10864
10865        // Manual trigger.
10866        queue.enqueue_mutation(AutosaveMutationKind::Message);
10867        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
10868        assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
10869        queue.finish_flush(t1, true);
10870        assert_eq!(
10871            queue.metrics().last_flush_trigger,
10872            Some(AutosaveFlushTrigger::Manual)
10873        );
10874
10875        // Periodic trigger.
10876        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10877        let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10878        queue.finish_flush(t2, true);
10879        assert_eq!(
10880            queue.metrics().last_flush_trigger,
10881            Some(AutosaveFlushTrigger::Periodic)
10882        );
10883
10884        // Shutdown trigger.
10885        queue.enqueue_mutation(AutosaveMutationKind::Label);
10886        let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
10887        queue.finish_flush(t3, true);
10888        assert_eq!(
10889            queue.metrics().last_flush_trigger,
10890            Some(AutosaveFlushTrigger::Shutdown)
10891        );
10892    }
10893
10894    // --- Flush records duration ---
10895
10896    #[test]
10897    fn autosave_queue_flush_records_duration() {
10898        let mut queue = AutosaveQueue::with_limit(10);
10899        queue.enqueue_mutation(AutosaveMutationKind::Message);
10900        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
10901        queue.finish_flush(ticket, true);
10902        // Duration should be recorded (>= 0ms).
10903        assert!(queue.metrics().last_flush_duration_ms.is_some());
10904    }
10905
10906    // --- Rapid enqueue-flush cycles ---
10907
10908    #[test]
10909    fn autosave_queue_rapid_single_mutation_flushes() {
10910        let mut queue = AutosaveQueue::with_limit(10);
10911        let rounds = 20;
10912
10913        for _ in 0..rounds {
10914            queue.enqueue_mutation(AutosaveMutationKind::Message);
10915            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
10916            queue.finish_flush(ticket, true);
10917        }
10918
10919        let m = queue.metrics();
10920        assert_eq!(m.flush_started, rounds);
10921        assert_eq!(m.flush_succeeded, rounds);
10922        assert_eq!(m.flush_failed, 0);
10923        assert_eq!(m.pending_mutations, 0);
10924        assert_eq!(m.last_flush_batch_size, 1);
10925    }
10926
10927    // --- Saturating counter behavior under heavy load ---
10928
10929    #[test]
10930    fn autosave_queue_many_backpressure_events_accumulate() {
10931        let mut queue = AutosaveQueue::with_limit(1);
10932        let excess: u64 = 100;
10933
10934        // First enqueue goes into the queue; rest are backpressure.
10935        for _ in 0..=excess {
10936            queue.enqueue_mutation(AutosaveMutationKind::Message);
10937        }
10938        assert_eq!(queue.pending_mutations, 1);
10939        assert_eq!(queue.backpressure_events, excess);
10940    }
10941
10942    // --- Durability mode: as_str roundtrip ---
10943
10944    #[test]
10945    fn autosave_durability_mode_as_str_roundtrip() {
10946        for mode in [
10947            AutosaveDurabilityMode::Strict,
10948            AutosaveDurabilityMode::Balanced,
10949            AutosaveDurabilityMode::Throughput,
10950        ] {
10951            let s = mode.as_str();
10952            let parsed = AutosaveDurabilityMode::parse(s);
10953            assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
10954        }
10955    }
10956
10957    // --- Durability mode: should_flush/best_effort truth table ---
10958
10959    #[test]
10960    fn autosave_durability_mode_shutdown_behavior_truth_table() {
10961        assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
10962        assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
10963
10964        assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
10965        assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
10966
10967        assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
10968        assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
10969    }
10970
10971    // --- Durability mode: case-insensitive parsing ---
10972
10973    #[test]
10974    fn autosave_durability_mode_parse_case_insensitive() {
10975        assert_eq!(
10976            AutosaveDurabilityMode::parse("STRICT"),
10977            Some(AutosaveDurabilityMode::Strict)
10978        );
10979        assert_eq!(
10980            AutosaveDurabilityMode::parse("Balanced"),
10981            Some(AutosaveDurabilityMode::Balanced)
10982        );
10983        assert_eq!(
10984            AutosaveDurabilityMode::parse("tHrOuGhPuT"),
10985            Some(AutosaveDurabilityMode::Throughput)
10986        );
10987    }
10988
10989    // --- Durability mode: whitespace trimming ---
10990
10991    #[test]
10992    fn autosave_durability_mode_parse_trims_whitespace() {
10993        assert_eq!(
10994            AutosaveDurabilityMode::parse("  strict  "),
10995            Some(AutosaveDurabilityMode::Strict)
10996        );
10997        assert_eq!(
10998            AutosaveDurabilityMode::parse("\tbalanced\n"),
10999            Some(AutosaveDurabilityMode::Balanced)
11000        );
11001    }
11002
11003    // --- Session-level: save on empty queue is no-op ---
11004
11005    #[test]
11006    fn autosave_session_save_on_empty_queue_is_noop() {
11007        let temp_dir = tempfile::tempdir().unwrap();
11008        let mut session = Session::create();
11009        session.session_dir = Some(temp_dir.path().to_path_buf());
11010
11011        // Save without any mutations — should succeed and not change metrics.
11012        let m_before = session.autosave_metrics();
11013        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
11014        let m_after = session.autosave_metrics();
11015
11016        assert_eq!(m_before.flush_started, m_after.flush_started);
11017        assert_eq!(m_after.pending_mutations, 0);
11018    }
11019
11020    // --- Session-level: mode change mid-session ---
11021
11022    #[test]
11023    fn autosave_session_mode_change_mid_session() {
11024        let mut session = Session::create();
11025        assert_eq!(
11026            session.autosave_durability_mode(),
11027            AutosaveDurabilityMode::Balanced,
11028            "default is balanced"
11029        );
11030
11031        session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
11032        assert_eq!(
11033            session.autosave_durability_mode(),
11034            AutosaveDurabilityMode::Strict
11035        );
11036
11037        session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
11038        assert_eq!(
11039            session.autosave_durability_mode(),
11040            AutosaveDurabilityMode::Throughput
11041        );
11042    }
11043
11044    // --- Session-level: all mutation types enqueue correctly ---
11045
11046    #[test]
11047    fn autosave_session_all_mutation_types_enqueue() {
11048        let mut session = Session::create();
11049
11050        let first_entry_id = session.append_message(make_test_message("msg"));
11051        assert_eq!(session.autosave_metrics().pending_mutations, 1);
11052
11053        session.append_model_change("prov".to_string(), "model".to_string());
11054        assert_eq!(session.autosave_metrics().pending_mutations, 2);
11055
11056        session.append_thinking_level_change("high".to_string());
11057        assert_eq!(session.autosave_metrics().pending_mutations, 3);
11058
11059        session.append_session_info(Some("test-session".to_string()));
11060        assert_eq!(session.autosave_metrics().pending_mutations, 4);
11061
11062        session.append_custom_entry("custom".to_string(), None);
11063        assert_eq!(session.autosave_metrics().pending_mutations, 5);
11064
11065        // Label mutation (needs existing entry to target).
11066        session.add_label(&first_entry_id, Some("test-label".to_string()));
11067        assert_eq!(session.autosave_metrics().pending_mutations, 6);
11068    }
11069
11070    // --- Session-level: flush then verify metrics ---
11071
11072    #[test]
11073    fn autosave_session_manual_save_resets_pending() {
11074        let temp_dir = tempfile::tempdir().unwrap();
11075        let mut session = Session::create();
11076        session.session_dir = Some(temp_dir.path().to_path_buf());
11077
11078        session.append_message(make_test_message("a"));
11079        session.append_message(make_test_message("b"));
11080        session.append_message(make_test_message("c"));
11081        assert_eq!(session.autosave_metrics().pending_mutations, 3);
11082
11083        run_async(async { session.save().await }).unwrap();
11084
11085        let m = session.autosave_metrics();
11086        assert_eq!(m.pending_mutations, 0);
11087        assert_eq!(m.flush_succeeded, 1);
11088        assert_eq!(m.last_flush_batch_size, 3);
11089        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
11090    }
11091
11092    // --- Session-level: periodic flush trigger tracking ---
11093
11094    #[test]
11095    fn autosave_session_periodic_flush_tracks_trigger() {
11096        let temp_dir = tempfile::tempdir().unwrap();
11097        let mut session = Session::create();
11098        session.session_dir = Some(temp_dir.path().to_path_buf());
11099
11100        session.append_message(make_test_message("periodic msg"));
11101        run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
11102
11103        let m = session.autosave_metrics();
11104        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
11105        assert_eq!(m.flush_succeeded, 1);
11106    }
11107
11108    // --- Session-level: shutdown flush with balanced mode success ---
11109
11110    #[test]
11111    fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
11112        let temp_dir = tempfile::tempdir().unwrap();
11113        let mut session = Session::create();
11114        session.session_dir = Some(temp_dir.path().to_path_buf());
11115        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
11116
11117        session.append_message(make_test_message("balanced ok"));
11118        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
11119
11120        let m = session.autosave_metrics();
11121        assert_eq!(m.flush_succeeded, 1);
11122        assert_eq!(m.pending_mutations, 0);
11123        assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
11124    }
11125
11126    // --- Queue: partial restoration on failure with various fill levels ---
11127
11128    #[test]
11129    fn autosave_queue_failure_partial_restoration() {
11130        let mut queue = AutosaveQueue::with_limit(5);
11131
11132        // Fill to 4 and flush (batch=4).
11133        for _ in 0..4 {
11134            queue.enqueue_mutation(AutosaveMutationKind::Message);
11135        }
11136        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11137        assert_eq!(ticket.batch_size, 4);
11138
11139        // Add 2 while flush is in flight.
11140        queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11141        queue.enqueue_mutation(AutosaveMutationKind::Label);
11142        assert_eq!(queue.pending_mutations, 2);
11143
11144        // Fail: available_capacity = 5 - 2 = 3, restored = min(4,3) = 3, dropped = 1.
11145        let bp_before = queue.backpressure_events;
11146        let coal_before = queue.coalesced_mutations;
11147        queue.finish_flush(ticket, false);
11148        assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
11149        assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
11150        assert_eq!(
11151            queue.coalesced_mutations,
11152            coal_before + 1,
11153            "1 dropped coalesced"
11154        );
11155    }
11156
11157    // --- Queue: success flush does not restore ---
11158
11159    #[test]
11160    fn autosave_queue_success_does_not_restore_pending() {
11161        let mut queue = AutosaveQueue::with_limit(10);
11162
11163        queue.enqueue_mutation(AutosaveMutationKind::Message);
11164        queue.enqueue_mutation(AutosaveMutationKind::Message);
11165        let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
11166
11167        // Add 1 mutation while flush is in flight.
11168        queue.enqueue_mutation(AutosaveMutationKind::Label);
11169        assert_eq!(queue.pending_mutations, 1);
11170
11171        // Success: only the in-flight mutation remains.
11172        queue.finish_flush(ticket, true);
11173        assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
11174        assert_eq!(queue.flush_succeeded, 1);
11175    }
11176
11177    // --- Queue: large batch size tracking ---
11178
11179    #[test]
11180    fn autosave_queue_large_batch_tracking() {
11181        let mut queue = AutosaveQueue::with_limit(500);
11182
11183        for _ in 0..200 {
11184            queue.enqueue_mutation(AutosaveMutationKind::Message);
11185        }
11186
11187        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11188        assert_eq!(ticket.batch_size, 200);
11189        queue.finish_flush(ticket, true);
11190
11191        let m = queue.metrics();
11192        assert_eq!(m.last_flush_batch_size, 200);
11193        assert_eq!(m.flush_succeeded, 1);
11194        assert_eq!(m.pending_mutations, 0);
11195    }
11196
11197    // --- Durability resolve: all invalid falls through to default ---
11198
11199    #[test]
11200    fn autosave_resolve_all_invalid_returns_balanced() {
11201        assert_eq!(
11202            resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
11203            AutosaveDurabilityMode::Balanced
11204        );
11205    }
11206
11207    // --- Session-level: metrics accumulate across many save/flush cycles ---
11208
11209    #[test]
11210    fn autosave_session_metrics_accumulate_over_many_cycles() {
11211        let temp_dir = tempfile::tempdir().unwrap();
11212        let mut session = Session::create();
11213        session.session_dir = Some(temp_dir.path().to_path_buf());
11214
11215        let cycles: u64 = 10;
11216        for i in 0..cycles {
11217            session.append_message(make_test_message(&format!("cycle-{i}")));
11218            run_async(async { session.save().await }).unwrap();
11219        }
11220
11221        let m = session.autosave_metrics();
11222        assert_eq!(m.flush_started, cycles);
11223        assert_eq!(m.flush_succeeded, cycles);
11224        assert_eq!(m.flush_failed, 0);
11225        assert_eq!(m.pending_mutations, 0);
11226        assert_eq!(m.last_flush_batch_size, 1);
11227    }
11228
11229    // --- Queue: coalesced count is cumulative (not per-flush) ---
11230
11231    #[test]
11232    fn autosave_queue_coalesced_is_cumulative() {
11233        let mut queue = AutosaveQueue::with_limit(10);
11234
11235        // Batch 1: 3 mutations => 2 coalesced.
11236        queue.enqueue_mutation(AutosaveMutationKind::Message);
11237        queue.enqueue_mutation(AutosaveMutationKind::Message);
11238        queue.enqueue_mutation(AutosaveMutationKind::Message);
11239        assert_eq!(queue.coalesced_mutations, 2);
11240
11241        let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
11242        queue.finish_flush(t1, true);
11243
11244        // Batch 2: 2 mutations => 1 more coalesced (total 3).
11245        queue.enqueue_mutation(AutosaveMutationKind::Label);
11246        queue.enqueue_mutation(AutosaveMutationKind::Label);
11247        assert_eq!(queue.coalesced_mutations, 3);
11248    }
11249
11250    // --- Session-level: autosave_queue_limit changes batch size behavior ---
11251
11252    #[test]
11253    fn autosave_session_respects_queue_limit() {
11254        let temp_dir = tempfile::tempdir().unwrap();
11255        let mut session = Session::create();
11256        session.session_dir = Some(temp_dir.path().to_path_buf());
11257        session.set_autosave_queue_limit_for_test(3);
11258
11259        for i in 0..10 {
11260            session.append_message(make_test_message(&format!("lim-{i}")));
11261        }
11262
11263        let m = session.autosave_metrics();
11264        assert_eq!(m.pending_mutations, 3);
11265        assert_eq!(m.max_pending_mutations, 3);
11266        assert_eq!(m.backpressure_events, 7);
11267
11268        // Flush should only capture 3 (the capped count).
11269        run_async(async { session.save().await }).unwrap();
11270        let m = session.autosave_metrics();
11271        assert_eq!(m.last_flush_batch_size, 3);
11272        assert_eq!(m.pending_mutations, 0);
11273    }
11274
11275    // --- Session-level: throughput mode shutdown with successful prior manual save ---
11276
11277    #[test]
11278    fn autosave_session_throughput_shutdown_skips_after_manual_save() {
11279        let temp_dir = tempfile::tempdir().unwrap();
11280        let mut session = Session::create();
11281        session.session_dir = Some(temp_dir.path().to_path_buf());
11282        session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
11283
11284        session.append_message(make_test_message("saved"));
11285        run_async(async { session.save().await }).unwrap();
11286        assert_eq!(session.autosave_metrics().flush_succeeded, 1);
11287
11288        // Add more mutations but don't save.
11289        session.append_message(make_test_message("unsaved"));
11290        assert_eq!(session.autosave_metrics().pending_mutations, 1);
11291
11292        // Shutdown skips flush in throughput mode.
11293        run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
11294        assert_eq!(
11295            session.autosave_metrics().pending_mutations,
11296            1,
11297            "unsaved mutation remains"
11298        );
11299        assert_eq!(
11300            session.autosave_metrics().flush_succeeded,
11301            1,
11302            "no new flush"
11303        );
11304    }
11305
11306    // --- Queue: begin_flush atomically clears pending ---
11307
11308    #[test]
11309    fn autosave_queue_begin_flush_is_atomic_clear() {
11310        let mut queue = AutosaveQueue::with_limit(10);
11311
11312        queue.enqueue_mutation(AutosaveMutationKind::Message);
11313        queue.enqueue_mutation(AutosaveMutationKind::Message);
11314        queue.enqueue_mutation(AutosaveMutationKind::Message);
11315        assert_eq!(queue.pending_mutations, 3);
11316
11317        let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11318
11319        // Pending is immediately 0, even before finish_flush.
11320        assert_eq!(queue.pending_mutations, 0);
11321        assert_eq!(ticket.batch_size, 3);
11322
11323        // New mutations start fresh.
11324        queue.enqueue_mutation(AutosaveMutationKind::Label);
11325        assert_eq!(queue.pending_mutations, 1);
11326
11327        queue.finish_flush(ticket, true);
11328        assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
11329    }
11330
11331    // --- Queue: multiple failures accumulate flush_failed ---
11332
11333    #[test]
11334    fn autosave_queue_multiple_failures_accumulate() {
11335        let mut queue = AutosaveQueue::with_limit(10);
11336
11337        // Each round: enqueue 1 new + restored from prior failure.
11338        // Round 1: enqueue → pending=1, flush fails → restore 1 → pending=1
11339        // Round 2: enqueue → pending=2, flush fails → restore 2 → pending=2
11340        // Round N: pending grows by 1 each round because failures restore.
11341        for round in 1..=5_u64 {
11342            queue.enqueue_mutation(AutosaveMutationKind::Message);
11343            #[allow(clippy::cast_possible_truncation)]
11344            let expected_batch = round as usize;
11345            let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11346            assert_eq!(ticket.batch_size, expected_batch);
11347            queue.finish_flush(ticket, false);
11348            assert_eq!(queue.flush_failed, round);
11349            assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
11350        }
11351        assert_eq!(queue.flush_succeeded, 0);
11352        assert_eq!(queue.flush_started, 5);
11353    }
11354
11355    // --- ExportSnapshot and non-blocking export ---
11356
11357    #[test]
11358    fn export_snapshot_captures_header_and_entries() {
11359        let mut session = Session::create();
11360        session.append_message(make_test_message("hello world"));
11361        session.append_message(make_test_message("second message"));
11362
11363        let snapshot = session.export_snapshot();
11364        assert_eq!(snapshot.header.id, session.header.id);
11365        assert_eq!(snapshot.header.timestamp, session.header.timestamp);
11366        assert_eq!(snapshot.header.cwd, session.header.cwd);
11367        assert_eq!(snapshot.entries.len(), session.entries.len());
11368        assert_eq!(snapshot.path, session.path);
11369    }
11370
11371    #[test]
11372    fn export_snapshot_does_not_include_internal_caches() {
11373        let mut session = Session::create();
11374        for i in 0..10 {
11375            session.append_message(make_test_message(&format!("msg {i}")));
11376        }
11377        // The snapshot should be lighter than a full Session clone because
11378        // it skips autosave_queue, entry_index, entry_ids, and other caches.
11379        let snapshot = session.export_snapshot();
11380        assert_eq!(snapshot.entries.len(), 10);
11381        // Verify the snapshot is a distinct copy (not sharing references).
11382        assert_eq!(snapshot.header.id, session.header.id);
11383    }
11384
11385    #[test]
11386    fn export_snapshot_html_matches_session_html() {
11387        let mut session = Session::create();
11388        session.append_message(make_test_message("hello"));
11389        session.append_message(make_test_message("world"));
11390
11391        let session_html = session.to_html();
11392        let snapshot_html = session.export_snapshot().to_html();
11393        assert_eq!(session_html, snapshot_html);
11394    }
11395
11396    #[test]
11397    fn export_snapshot_empty_session() {
11398        let session = Session::create();
11399        let snapshot = session.export_snapshot();
11400        assert!(snapshot.entries.is_empty());
11401        let html = snapshot.to_html();
11402        assert!(html.contains("Pi Session"));
11403        assert!(html.contains("</html>"));
11404    }
11405
11406    #[test]
11407    fn render_session_html_contains_header_info() {
11408        let mut session = Session::create();
11409        session.header.id = "test-session-id-xyz".to_string();
11410        session.header.cwd = "/test/cwd/path".to_string();
11411
11412        let html = render_session_html(&session.header, &session.entries);
11413        assert!(html.contains("test-session-id-xyz"));
11414        assert!(html.contains("/test/cwd/path"));
11415    }
11416
11417    #[test]
11418    fn render_session_html_renders_all_entry_types() {
11419        let mut session = Session::create();
11420
11421        // Message entry.
11422        session.append_message(make_test_message("user text here"));
11423
11424        // Model change entry.
11425        session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
11426
11427        // Thinking level change entry.
11428        session.entries.push(SessionEntry::ThinkingLevelChange(
11429            ThinkingLevelChangeEntry {
11430                base: EntryBase::new(None, "tlc1".to_string()),
11431                thinking_level: "high".to_string(),
11432            },
11433        ));
11434
11435        let html = render_session_html(&session.header, &session.entries);
11436        assert!(html.contains("user text here"));
11437        assert!(html.contains("anthropic"));
11438        assert!(html.contains("claude-sonnet-4-5"));
11439        assert!(html.contains("high"));
11440    }
11441
11442    #[test]
11443    fn export_snapshot_with_path() {
11444        let mut session = Session::create();
11445        session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
11446        session.append_message(make_test_message("msg"));
11447
11448        let snapshot = session.export_snapshot();
11449        assert_eq!(
11450            snapshot.path.as_deref(),
11451            Some(Path::new("/tmp/my-session.jsonl"))
11452        );
11453    }
11454
11455    #[test]
11456    fn fork_plan_snapshot_consistency() {
11457        let mut session = Session::create();
11458        let msg1 = make_test_message("first message");
11459        session.append_message(msg1);
11460        let msg1_id = session.entries[0].base_id().unwrap().clone();
11461
11462        let msg2 = make_test_message("second message");
11463        session.append_message(msg2);
11464        let msg2_id = session.entries[1].base_id().unwrap().clone();
11465
11466        // Plan fork from the second message.
11467        let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
11468
11469        // Fork plan entries should include the path up to the parent.
11470        assert_eq!(plan.leaf_id, Some(msg1_id));
11471        // The plan captures a snapshot of entries — modifying session shouldn't affect plan.
11472        let plan_entry_count = plan.entries.len();
11473        session.append_message(make_test_message("third message"));
11474        assert_eq!(plan.entries.len(), plan_entry_count);
11475    }
11476}