1use 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
40pub 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#[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 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(¤t_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
515pub 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#[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#[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
583const 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
594const 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#[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#[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#[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 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#[derive(Debug)]
813#[allow(clippy::struct_excessive_bools)]
814pub struct Session {
815 pub header: SessionHeader,
817 pub entries: Vec<SessionEntry>,
819 pub path: Option<PathBuf>,
821 pub(crate) leaf_id: Option<String>,
824 pub session_dir: Option<PathBuf>,
826 store_kind: SessionStoreKind,
827 entry_ids: HashSet<String>,
829
830 is_linear: bool,
835 entry_index: HashMap<String, usize>,
837 cached_message_count: u64,
839 cached_name: Option<String>,
841 autosave_queue: AutosaveQueue,
843 autosave_durability: AutosaveDurabilityMode,
845
846 persisted_entry_count: Arc<AtomicUsize>,
851 header_dirty: bool,
853 appends_since_checkpoint: u64,
855 v2_sidecar_root: Option<PathBuf>,
857 v2_partial_hydration: bool,
859 v2_resume_mode: Option<V2OpenMode>,
861 v2_sidecar_stale: bool,
863 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 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#[derive(Debug, Clone)]
909pub struct ForkPlan {
910 pub entries: Vec<SessionEntry>,
912 pub leaf_id: Option<String>,
914 pub selected_text: String,
916}
917
918#[derive(Debug, Clone)]
924pub struct ExportSnapshot {
925 pub header: SessionHeader,
927 pub entries: Vec<SessionEntry>,
929 pub path: Option<PathBuf>,
931}
932
933impl ExportSnapshot {
934 pub fn to_html(&self) -> String {
938 render_session_html(&self.header, &self.entries)
939 }
940}
941
942#[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 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#[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 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 Ok(session)
1103 }
1104
1105 #[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 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 pub fn create() -> Self {
1314 Self::create_with_dir(None)
1315 }
1316
1317 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn should_full_rewrite(&self) -> bool {
1824 let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
1825
1826 if persisted_count == 0 {
1828 return true;
1829 }
1830 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 if self.header_dirty {
1841 return true;
1842 }
1843 if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
1845 return true;
1846 }
1847 if persisted_count > self.entries.len() {
1849 return true;
1850 }
1851 false
1852 }
1853
1854 #[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 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 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 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 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 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 let new_entries = &self.entries[new_start..];
2005 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 }
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 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 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 }
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 let Some(model) = self.latest_model_change_for_current_path() {
2119 return Some(model);
2120 }
2121
2122 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 let Some(level) = self.latest_thinking_level_for_current_path() {
2141 return Some(level);
2142 }
2143
2144 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 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 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 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 pub fn get_name(&self) -> Option<String> {
2359 self.cached_name.clone()
2360 }
2361
2362 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 self.rebuild_all_caches();
2423 }
2424
2425 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 self.is_linear = finalized.is_linear && self.leaf_id == finalized.leaf_id;
2442 }
2443
2444 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 pub fn to_html(&self) -> String {
2463 render_session_html(&self.header, &self.entries)
2464 }
2465
2466 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 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 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 generate_entry_id(&self.entry_ids)
2567 } else {
2568 let existing = entry_id_set(&self.entries);
2571 generate_entry_id(&existing)
2572 }
2573 }
2574
2575 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 pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
2597 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 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 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 pub fn navigate_to(&mut self, entry_id: &str) -> bool {
2671 let exists = self.entry_index.contains_key(entry_id);
2673 if exists {
2674 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 pub fn leaf_id(&self) -> Option<&str> {
2693 self.leaf_id.as_deref()
2694 }
2695
2696 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 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 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 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 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 pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
2768 self.navigate_to(entry_id)
2769 }
2770
2771 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 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 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 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 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 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 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 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 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 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 pub fn branch_summary(&self) -> BranchInfo {
3045 let leaves = self.list_leaves();
3046 let children_map = self.build_children_map();
3047
3048 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 pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
3075 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#[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#[derive(Debug, Clone)]
3108pub struct SiblingBranch {
3109 pub root_id: String,
3111 pub leaf_id: String,
3113 pub preview: String,
3115 pub message_count: usize,
3117 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 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 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#[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 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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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#[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
3793pub 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#[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("&"),
4168 '<' => escaped.push_str("<"),
4169 '>' => escaped.push_str(">"),
4170 '"' => escaped.push_str("""),
4171 '\'' => escaped.push_str("'"),
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
4233const PARALLEL_THRESHOLD: usize = 512;
4235const JSONL_PARSE_BATCH_SIZE: usize = 8192;
4237
4238#[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 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 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; 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 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 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#[allow(clippy::too_many_lines)]
4427fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4428 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 let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
4444 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
4445
4446 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 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
4503pub 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
4613pub 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 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 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
4708pub fn verify_v2_against_jsonl(
4713 jsonl_path: &Path,
4714 store: &SessionStoreV2,
4715) -> Result<session_store_v2::MigrationVerification> {
4716 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 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 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
4808pub 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 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#[derive(Debug, Clone, PartialEq, Eq)]
4847pub enum MigrationState {
4848 Unmigrated,
4850 Migrated,
4852 Corrupt { error: String },
4854 Partial,
4856}
4857
4858pub 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
4943pub 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 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_v2_against_jsonl(jsonl_path, &store)
4989 }
4991
4992pub 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 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
5043struct 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
5058fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
5066 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 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 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 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 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 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 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
5167fn 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 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 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 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 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 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 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 assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
6051
6052 let path = session.get_path_to_entry(&id3);
6054 assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
6055
6056 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 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 assert!(session.create_branch_from(&id_b));
6073 let id_d = session.append_message(make_test_message("D"));
6074
6075 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 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 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 assert!(session.navigate_to(&id1));
6099 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6100
6101 assert!(!session.navigate_to("nonexistent"));
6103 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6105
6106 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 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 session.create_branch_from(&id_a);
6224 let id_d = session.append_message(make_test_message("D"));
6225
6226 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 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 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 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(); 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 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 session.create_branch_from(&id_b);
6510 let id_d = session.append_message(make_test_message("D"));
6511
6512 session.navigate_to(&id_d);
6514 let messages = session.to_messages_for_current_path();
6515 assert_eq!(messages.len(), 3);
6516
6517 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 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 #[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 #[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 let entry = session.get_entry(&tool_id).unwrap();
6616 assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
6617
6618 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 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 let messages = session.to_messages();
6717 assert_eq!(messages.len(), 1); }
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 #[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 let e1 = session.get_entry(&id1).unwrap();
6788 assert!(e1.base().parent_id.is_none());
6789
6790 let e2 = session.get_entry(&id2).unwrap();
6792 assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
6793
6794 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 #[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 #[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 #[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 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 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 #[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 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 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 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); }
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 #[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 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 session.create_branch_from(&id_a);
7250 let _id_d = session.append_message(make_test_message("D"));
7251
7252 session.create_branch_from(&id_b);
7254 let id_e = session.append_message(make_test_message("E"));
7255
7256 let leaves = session.list_leaves();
7258 assert_eq!(leaves.len(), 3);
7259
7260 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 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 session.create_branch_from(&id_a);
7279 let id_d = session.append_message(make_test_message("D"));
7280
7281 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 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 assert!(session.sibling_branches().is_none());
7303 }
7304
7305 #[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 let plan = session.plan_fork_from_user_message(&id_c).unwrap();
7329 assert_eq!(plan.selected_text, "Second question");
7330 assert_eq!(plan.entries.len(), 2); }
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()); 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 #[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 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 session.navigate_to(&id_d);
7395
7396 let messages = session.to_messages_for_current_path();
7397 assert!(messages.len() <= 4); 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 #[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 let path = session.get_path_to_entry(&last_id);
7458 assert_eq!(path.len(), 500);
7459
7460 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 #[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 assert!(entries[0].base().id.is_some());
7519 assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
7521 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 #[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 #[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 #[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 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); }
7651
7652 #[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 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 let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
7695 assert!(mc_value.get("modelId").is_some());
7696 }
7697
7698 #[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 #[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 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 #[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 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 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 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 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 #[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 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 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 #[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 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 assert_eq!(loaded.header.id, session.header.id);
8588 }
8589
8590 #[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", "\u{4F60}\u{597D}", "\u{0410}\u{0411}\u{0412}", "caf\u{00E9}", "tab\there\nnewline", "\"quoted\" and \\escaped", ];
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 #[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 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 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 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 #[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 let messages = session.to_messages();
8689 assert!(messages.is_empty());
8690
8691 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 #[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 #[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 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 #[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 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 #[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 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 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 mod proptest_session {
8881 use super::*;
8882 use proptest::prelude::*;
8883 use serde_json::json;
8884
8885 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 fn entry_id_strategy() -> impl Strategy<Value = String> {
8902 "[0-9a-f]{8}"
8903 }
8904
8905 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 #[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 "[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 "[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 ("[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 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 ("[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 (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 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 ("[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 fn corrupted_entry_json() -> impl Strategy<Value = String> {
9070 prop_oneof![
9071 Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9073 Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9075 Just(r"{}".to_string()),
9077 Just(r"[1,2,3]".to_string()),
9079 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 Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
9086 Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
9088 ]
9089 }
9090
9091 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 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 let _ = serde_json::from_str::<SessionEntry>(&json_str);
9115 }
9116 }
9117
9118 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 if let Ok(s) = String::from_utf8(raw) {
9141 let _ = serde_json::from_str::<SessionEntry>(&s);
9142 }
9143 }
9144 }
9145
9146 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 let reserialized = serde_json::to_string(&entry).unwrap();
9164 let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
9166 assert_eq!(entry.base_id(), re_entry.base_id());
9168 assert_eq!(
9170 std::mem::discriminant(&entry),
9171 std::mem::discriminant(&re_entry)
9172 );
9173 }
9174 }
9175 }
9176
9177 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 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 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 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 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 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 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 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 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 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 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 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 #[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 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 #[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 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 assert_eq!(lines_after_first, 3);
9560
9561 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 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 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 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 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 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 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 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 session.append_message(make_test_message("initial"));
9644 run_async(async { session.save().await }).unwrap();
9645
9646 let interval = compaction_checkpoint_interval();
9648 session.appends_since_checkpoint = interval;
9649
9650 session.append_message(make_test_message("triggers checkpoint"));
9652 run_async(async { session.save().await }).unwrap();
9653
9654 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 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 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 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 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 std::thread::sleep(std::time::Duration::from_millis(50));
9733
9734 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 assert_eq!(session.entry_index.len(), 1);
9756
9757 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 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 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 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 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 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 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 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 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 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 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 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 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 session.persisted_entry_count.store(10, Ordering::SeqCst);
10587
10588 let clone = session.clone();
10590
10591 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
10593
10594 session.persisted_entry_count.store(20, Ordering::SeqCst);
10596
10597 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
10599
10600 clone.persisted_entry_count.store(30, Ordering::SeqCst);
10602
10603 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 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 #[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 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10727 assert_eq!(queue.pending_mutations, 1);
10728 assert_eq!(queue.flush_succeeded, 1);
10729 }
10730
10731 #[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 #[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 #[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 assert_eq!(queue.coalesced_mutations, 2);
10775 }
10776
10777 #[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 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 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 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 #[test]
10830 fn autosave_queue_failure_drops_all_when_full() {
10831 let mut queue = AutosaveQueue::with_limit(3);
10832
10833 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 for _ in 0..3 {
10843 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
10844 }
10845 assert_eq!(queue.pending_mutations, 3);
10846
10847 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 #[test]
10862 fn autosave_queue_tracks_trigger_across_flushes() {
10863 let mut queue = AutosaveQueue::with_limit(10);
10864
10865 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 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 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 #[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 assert!(queue.metrics().last_flush_duration_ms.is_some());
10904 }
10905
10906 #[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 #[test]
10930 fn autosave_queue_many_backpressure_events_accumulate() {
10931 let mut queue = AutosaveQueue::with_limit(1);
10932 let excess: u64 = 100;
10933
10934 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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 session.add_label(&first_entry_id, Some("test-label".to_string()));
11067 assert_eq!(session.autosave_metrics().pending_mutations, 6);
11068 }
11069
11070 #[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 #[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 #[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 #[test]
11129 fn autosave_queue_failure_partial_restoration() {
11130 let mut queue = AutosaveQueue::with_limit(5);
11131
11132 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 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11141 queue.enqueue_mutation(AutosaveMutationKind::Label);
11142 assert_eq!(queue.pending_mutations, 2);
11143
11144 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 #[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 queue.enqueue_mutation(AutosaveMutationKind::Label);
11169 assert_eq!(queue.pending_mutations, 1);
11170
11171 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 #[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 #[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 #[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 #[test]
11232 fn autosave_queue_coalesced_is_cumulative() {
11233 let mut queue = AutosaveQueue::with_limit(10);
11234
11235 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 queue.enqueue_mutation(AutosaveMutationKind::Label);
11246 queue.enqueue_mutation(AutosaveMutationKind::Label);
11247 assert_eq!(queue.coalesced_mutations, 3);
11248 }
11249
11250 #[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 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 #[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 session.append_message(make_test_message("unsaved"));
11290 assert_eq!(session.autosave_metrics().pending_mutations, 1);
11291
11292 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 #[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 assert_eq!(queue.pending_mutations, 0);
11321 assert_eq!(ticket.batch_size, 3);
11322
11323 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 #[test]
11334 fn autosave_queue_multiple_failures_accumulate() {
11335 let mut queue = AutosaveQueue::with_limit(10);
11336
11337 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 #[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 let snapshot = session.export_snapshot();
11380 assert_eq!(snapshot.entries.len(), 10);
11381 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 session.append_message(make_test_message("user text here"));
11423
11424 session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
11426
11427 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 let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
11468
11469 assert_eq!(plan.leaf_id, Some(msg1_id));
11471 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}