1use crate::agent_cx::AgentCx;
7use crate::cli::Cli;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::extensions::ExtensionSession;
11use crate::model::{
12 AssistantMessage, ContentBlock, Message, TextContent, ToolResultMessage, UserContent,
13 UserMessage,
14};
15use crate::provider_metadata::{canonical_provider_id, provider_ids_match};
16use crate::session_index::{
17 SessionIndex, SessionIndexRefreshSummary, enqueue_session_index_snapshot_update,
18 is_session_file_path, session_file_stats,
19};
20use crate::session_store_v2::{self, SessionStoreV2};
21use crate::tui::PiConsole;
22use asupersync::channel::oneshot;
23use asupersync::sync::Mutex;
24use async_trait::async_trait;
25use fs4::fs_std::FileExt;
26use serde::{Deserialize, Serialize};
27use serde_json::Value;
28use sha2::{Digest, Sha256};
29use std::collections::{HashMap, HashSet};
30use std::fmt::Write as _;
31use std::io::{BufReader, IsTerminal, Read, Write};
32use std::path::{Path, PathBuf};
33use std::sync::atomic::{AtomicUsize, Ordering};
34use std::sync::{Arc, OnceLock};
35use std::thread;
36use std::time::Instant;
37#[cfg(test)]
38use std::time::{SystemTime, UNIX_EPOCH};
39use tracing::warn;
40
41pub const SESSION_VERSION: u8 = 3;
43const MAX_JSONL_LINE_BYTES: usize = 100 * 1024 * 1024;
44const V2_CHAIN_HASH_GENESIS: &str =
45 "0000000000000000000000000000000000000000000000000000000000000000";
46const ROOT_LEAF_OVERRIDE_SENTINEL: &str = "";
47
48fn finish_worker_result<T, E>(
49 handle: thread::JoinHandle<()>,
50 recv_result: std::result::Result<Result<T>, E>,
51 cancelled_message: &'static str,
52) -> Result<T> {
53 if let Err(panic_payload) = handle.join() {
54 std::panic::resume_unwind(panic_payload);
55 }
56 recv_result.map_err(|_| crate::Error::session(cancelled_message))?
57}
58
59fn read_capped_utf8_line_with_limit<R: std::io::BufRead>(
60 reader: &mut R,
61 max_bytes: usize,
62) -> std::io::Result<Option<String>> {
63 use std::io::BufRead;
64
65 let limit = u64::try_from(max_bytes)
66 .unwrap_or(u64::MAX.saturating_sub(2))
67 .saturating_add(2);
68 let mut bytes = Vec::new();
69 let bytes_read = reader.take(limit).read_until(b'\n', &mut bytes)?;
70 if bytes_read == 0 {
71 return Ok(None);
72 }
73
74 let content_len = bytes.strip_suffix(b"\n").map_or(bytes.len(), <[u8]>::len);
75 if content_len > max_bytes {
76 if !bytes.ends_with(b"\n") {
77 let mut discard = Vec::new();
78 loop {
79 discard.clear();
80 let discarded = reader.read_until(b'\n', &mut discard)?;
81 if discarded == 0 || discard.ends_with(b"\n") {
82 break;
83 }
84 }
85 }
86 return Err(std::io::Error::new(
87 std::io::ErrorKind::InvalidData,
88 format!("JSONL line exceeds {max_bytes} bytes"),
89 ));
90 }
91
92 String::from_utf8(bytes)
93 .map(Some)
94 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
95}
96
97fn read_capped_utf8_line<R: std::io::BufRead>(reader: &mut R) -> std::io::Result<Option<String>> {
98 read_capped_utf8_line_with_limit(reader, MAX_JSONL_LINE_BYTES)
99}
100
101#[cfg(unix)]
102fn sync_parent_dir(path: &Path) -> std::io::Result<()> {
103 let Some(parent) = path.parent() else {
104 return Ok(());
105 };
106 std::fs::File::open(parent)?.sync_all()
107}
108
109#[cfg(not(unix))]
110fn sync_parent_dir(_path: &Path) -> std::io::Result<()> {
111 Ok(())
112}
113
114fn save_jsonl_full_rewrite_blocking(
115 path: &Path,
116 sessions_root: &Path,
117 header: &SessionHeader,
118 entries: &[SessionEntry],
119 persisted_entry_count: usize,
120 header_dirty: bool,
121) -> Result<(SessionHeader, Vec<SessionEntry>)> {
122 let _lock = lock_session_persistence(path)?;
123 let (header_to_write, entries_to_write) =
124 prepare_jsonl_full_rewrite(path, header, entries, persisted_entry_count, header_dirty)?;
125 let original_perms = std::fs::metadata(path).ok().map(|meta| meta.permissions());
126 let parent = path.parent().unwrap_or_else(|| Path::new("."));
127 let mut temp_file = tempfile::NamedTempFile::new_in(parent)?;
128 {
129 let mut writer = std::io::BufWriter::with_capacity(1 << 20, temp_file.as_file());
130 serde_json::to_writer(&mut writer, &header_to_write)?;
131 writer.write_all(b"\n")?;
132 for entry in &entries_to_write {
133 serde_json::to_writer(&mut writer, entry)?;
134 writer.write_all(b"\n")?;
135 }
136 writer.flush()?;
137 }
138 temp_file
139 .as_file_mut()
140 .sync_all()
141 .map_err(|e| crate::Error::Io(Box::new(e)))?;
142 if let Some(perms) = original_perms {
143 temp_file
144 .as_file()
145 .set_permissions(perms)
146 .map_err(|e| crate::Error::Io(Box::new(e)))?;
147 }
148 temp_file
149 .persist(path)
150 .map_err(|e| crate::Error::Io(Box::new(e.error)))?;
151 sync_parent_dir(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
152 let mut entries_for_stats = entries_to_write.clone();
153 let finalized = finalize_loaded_entries(&mut entries_for_stats);
154 let message_count = finalized.message_count;
155 let session_name = finalized.name;
156 enqueue_session_index_snapshot_update(
157 sessions_root,
158 path,
159 &header_to_write,
160 message_count,
161 session_name,
162 );
163 Ok((header_to_write, entries_to_write))
164}
165
166fn append_jsonl_entries_blocking(
167 path: &Path,
168 sessions_root: &Path,
169 header: &SessionHeader,
170 serialized_entries: &[u8],
171 message_count: u64,
172 session_name: Option<String>,
173) -> Result<()> {
174 let _lock = lock_session_persistence(path)?;
175 let mut file = std::fs::OpenOptions::new()
176 .append(true)
177 .open(path)
178 .map_err(|e| crate::Error::Io(Box::new(e)))?;
179 file.write_all(serialized_entries)?;
180 file.sync_all().map_err(|e| crate::Error::Io(Box::new(e)))?;
181
182 enqueue_session_index_snapshot_update(sessions_root, path, header, message_count, session_name);
183 Ok(())
184}
185
186fn session_persistence_lock_path(path: &Path) -> PathBuf {
187 let mut lock_path = path.as_os_str().to_os_string();
188 lock_path.push(".lock");
189 PathBuf::from(lock_path)
190}
191
192fn lock_session_persistence(path: &Path) -> Result<SessionPersistenceLockGuard> {
193 let lock_path = session_persistence_lock_path(path);
194 let file = std::fs::OpenOptions::new()
195 .read(true)
196 .write(true)
197 .create(true)
198 .truncate(false)
199 .open(&lock_path)
200 .map_err(|e| crate::Error::Io(Box::new(e)))?;
201 file.lock_exclusive()?;
202 Ok(SessionPersistenceLockGuard { file })
203}
204
205#[derive(Debug)]
206struct SessionPersistenceLockGuard {
207 file: std::fs::File,
208}
209
210impl Drop for SessionPersistenceLockGuard {
211 fn drop(&mut self) {
212 let _ = FileExt::unlock(&self.file);
213 }
214}
215
216fn prepare_jsonl_full_rewrite(
217 path: &Path,
218 header: &SessionHeader,
219 entries: &[SessionEntry],
220 persisted_entry_count: usize,
221 header_dirty: bool,
222) -> Result<(SessionHeader, Vec<SessionEntry>)> {
223 let pending_start = persisted_entry_count.min(entries.len());
224 let mut merged_entries = entries[..pending_start].to_vec();
225 let local_pending = &entries[pending_start..];
226 let mut header_to_write = header.clone();
227
228 if path
229 .try_exists()
230 .map_err(|e| crate::Error::Io(Box::new(e)))?
231 {
232 let (disk_session, _) = open_jsonl_blocking(path.to_path_buf())?;
233 if !header_dirty {
234 header_to_write = disk_session.header;
235 }
236
237 let known_ids: HashSet<&str> = entries
238 .iter()
239 .filter_map(|entry| entry.base_id().map(String::as_str))
240 .collect();
241
242 for disk_entry in disk_session.entries.into_iter().skip(pending_start) {
243 let should_merge = disk_entry
244 .base_id()
245 .is_none_or(|id| !known_ids.contains(id.as_str()));
246 if should_merge {
247 merged_entries.push(disk_entry);
248 }
249 }
250 }
251
252 merged_entries.extend_from_slice(local_pending);
253 Ok((header_to_write, merged_entries))
254}
255
256fn resolve_loaded_leaf_id(
257 header: &SessionHeader,
258 natural_leaf_id: Option<String>,
259 entry_index: &HashMap<String, usize>,
260) -> Option<String> {
261 match header.current_leaf.as_deref() {
262 Some(ROOT_LEAF_OVERRIDE_SENTINEL) => None,
263 Some(leaf_id) if entry_index.contains_key(leaf_id) => Some(leaf_id.to_string()),
264 _ => natural_leaf_id,
265 }
266}
267
268fn normalize_loaded_header(mut header: SessionHeader) -> (SessionHeader, bool) {
269 let header_dirty = header.materialize_branch_fallbacks();
270 (header, header_dirty)
271}
272
273fn total_v2_message_count(store: &SessionStoreV2) -> Result<Option<u64>> {
274 if let Some(manifest) = store.read_manifest()? {
275 return Ok(Some(manifest.counters.messages_total));
276 }
277
278 let mut total = 0u64;
279 for frame in store.read_all_entries()? {
280 if frame.entry_type.eq("message") {
281 total = total.saturating_add(1);
282 }
283 }
284 Ok(Some(total))
285}
286
287#[derive(Clone, Debug)]
289pub struct SessionHandle(pub Arc<Mutex<Session>>);
290
291fn current_path_model_pair(session: &Session) -> Option<(String, String)> {
292 session.effective_model_for_current_path()
293}
294
295fn current_path_model_fields(session: &Session) -> (Option<String>, Option<String>) {
296 if let Some((provider, model_id)) = current_path_model_pair(session) {
297 (Some(provider), Some(model_id))
298 } else {
299 session.header.branch_fallback_model_fields()
300 }
301}
302
303fn current_path_thinking_level(session: &Session) -> Option<String> {
304 session.effective_thinking_level_for_current_path()
305}
306
307#[async_trait]
308impl ExtensionSession for SessionHandle {
309 async fn get_state(&self) -> Value {
310 let cx = AgentCx::for_current_or_request();
311 let Ok(session) = self.0.lock(cx.cx()).await else {
312 return serde_json::json!({
313 "model": null,
314 "thinkingLevel": "off",
315 "durabilityMode": "balanced",
316 "isStreaming": false,
317 "isCompacting": false,
318 "steeringMode": "one-at-a-time",
319 "followUpMode": "one-at-a-time",
320 "sessionFile": null,
321 "sessionId": "",
322 "sessionName": null,
323 "autoCompactionEnabled": false,
324 "messageCount": 0,
325 "pendingMessageCount": 0,
326 });
327 };
328 let session_file = session.path.as_ref().map(|p| p.display().to_string());
329 let session_id = session.header.id.clone();
330 let session_name = session.get_name();
331 let model =
332 current_path_model_pair(&session).map_or(Value::Null, |(provider, model_id)| {
333 serde_json::json!({
334 "provider": provider,
335 "id": model_id,
336 })
337 });
338 let thinking_level =
339 current_path_thinking_level(&session).unwrap_or_else(|| "off".to_string());
340 let message_count = session
341 .entries_for_current_path()
342 .iter()
343 .filter(|entry| matches!(entry, SessionEntry::Message(_)))
344 .count();
345 let pending_message_count = session.autosave_metrics().pending_mutations;
346 let durability_mode = session.autosave_durability_mode().as_str();
347 serde_json::json!({
348 "model": model,
349 "thinkingLevel": thinking_level,
350 "durabilityMode": durability_mode,
351 "isStreaming": false,
352 "isCompacting": false,
353 "steeringMode": "one-at-a-time",
354 "followUpMode": "one-at-a-time",
355 "sessionFile": session_file,
356 "sessionId": session_id,
357 "sessionName": session_name,
358 "autoCompactionEnabled": false,
359 "messageCount": message_count,
360 "pendingMessageCount": pending_message_count,
361 })
362 }
363
364 async fn get_messages(&self) -> Vec<SessionMessage> {
365 let cx = AgentCx::for_current_or_request();
366 let Ok(session) = self.0.lock(cx.cx()).await else {
367 return Vec::new();
368 };
369 session
372 .entries_for_current_path()
373 .iter()
374 .filter_map(|entry| match entry {
375 SessionEntry::Message(msg) => match msg.message {
376 SessionMessage::User { .. }
377 | SessionMessage::Assistant { .. }
378 | SessionMessage::ToolResult { .. }
379 | SessionMessage::BashExecution { .. }
380 | SessionMessage::Custom { .. } => Some(msg.message.clone()),
381 _ => None,
382 },
383 _ => None,
384 })
385 .collect()
386 }
387
388 async fn get_entries(&self) -> Vec<Value> {
389 let cx = AgentCx::for_current_or_request();
390 let Ok(session) = self.0.lock(cx.cx()).await else {
391 return Vec::new();
392 };
393 session
394 .entries
395 .iter()
396 .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
397 .collect()
398 }
399
400 async fn get_branch(&self) -> Vec<Value> {
401 let cx = AgentCx::for_current_or_request();
402 let Ok(session) = self.0.lock(cx.cx()).await else {
403 return Vec::new();
404 };
405 session
406 .entries_for_current_path()
407 .iter()
408 .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
409 .collect()
410 }
411
412 async fn set_name(&self, name: String) -> Result<()> {
413 let cx = AgentCx::for_current_or_request();
414 let mut session = self
415 .0
416 .lock(cx.cx())
417 .await
418 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
419 #[cfg(test)]
420 emit_set_name_deadline_probe(&session.header.id, cx.budget().deadline);
421 session.set_name(&name);
422 Ok(())
423 }
424
425 async fn append_message(&self, message: SessionMessage) -> Result<()> {
426 let cx = AgentCx::for_current_or_request();
427 let mut session = self
428 .0
429 .lock(cx.cx())
430 .await
431 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
432 session.append_message(message);
433 Ok(())
434 }
435
436 async fn append_custom_entry(&self, custom_type: String, data: Option<Value>) -> Result<()> {
437 let cx = AgentCx::for_current_or_request();
438 let mut session = self
439 .0
440 .lock(cx.cx())
441 .await
442 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
443 if custom_type.trim().is_empty() {
444 return Err(Error::validation("customType must not be empty"));
445 }
446 session.append_custom_entry(custom_type, data);
447 Ok(())
448 }
449
450 async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
451 let cx = AgentCx::for_current_or_request();
452 let mut session = self
453 .0
454 .lock(cx.cx())
455 .await
456 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
457 let normalized_provider = canonical_provider_id(&provider)
458 .unwrap_or(&provider)
459 .to_string();
460 let (stored_provider, stored_model_id, changed) = match current_path_model_pair(&session) {
461 Some((current_provider, current_model_id))
462 if provider_ids_match(¤t_provider, &provider)
463 && current_model_id.eq_ignore_ascii_case(&model_id) =>
464 {
465 (current_provider, current_model_id, false)
466 }
467 _ => (normalized_provider, model_id.clone(), true),
468 };
469 if changed {
470 session.append_model_change(stored_provider.clone(), stored_model_id.clone());
471 }
472 session.set_model_header(Some(stored_provider), Some(stored_model_id), None);
473 Ok(())
474 }
475
476 async fn get_model(&self) -> (Option<String>, Option<String>) {
477 let cx = AgentCx::for_current_or_request();
478 let Ok(session) = self.0.lock(cx.cx()).await else {
479 return (None, None);
480 };
481 current_path_model_fields(&session)
482 }
483
484 async fn set_thinking_level(&self, level: String) -> Result<()> {
485 let cx = AgentCx::for_current_or_request();
486 let mut session = self
487 .0
488 .lock(cx.cx())
489 .await
490 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
491 let changed = !current_path_thinking_level(&session)
492 .as_deref()
493 .is_some_and(|current| current.eq(level.as_str()));
494 if changed {
495 session.append_thinking_level_change(level.clone());
496 }
497 session.set_model_header(None, None, Some(level));
498 Ok(())
499 }
500
501 async fn get_thinking_level(&self) -> Option<String> {
502 let cx = AgentCx::for_current_or_request();
503 let Ok(session) = self.0.lock(cx.cx()).await else {
504 return None;
505 };
506 current_path_thinking_level(&session)
507 }
508
509 async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
510 let cx = AgentCx::for_current_or_request();
511 let mut session = self
512 .0
513 .lock(cx.cx())
514 .await
515 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
516 if session.add_label(&target_id, label).is_none() {
517 return Err(Error::validation(format!(
518 "target entry '{target_id}' not found in session"
519 )));
520 }
521 Ok(())
522 }
523}
524
525pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://buildwithpi.ai/session/";
527
528fn build_share_viewer_url(base_url: Option<&str>, gist_id: &str) -> String {
529 let base_url = base_url
530 .filter(|value| !value.is_empty())
531 .unwrap_or(DEFAULT_SHARE_VIEWER_URL);
532 format!("{base_url}#{gist_id}")
533}
534
535#[must_use]
542pub fn get_share_viewer_url(gist_id: &str) -> String {
543 let base_url = std::env::var("PI_SHARE_VIEWER_URL").ok();
544 build_share_viewer_url(base_url.as_deref(), gist_id)
545}
546
547#[derive(Debug, Clone, Copy, PartialEq, Eq)]
549pub enum SessionStoreKind {
550 Jsonl,
551 #[cfg(feature = "sqlite-sessions")]
552 Sqlite,
553}
554
555impl SessionStoreKind {
556 fn from_config(config: &Config) -> Self {
557 let Some(value) = config.session_store.as_deref() else {
558 return Self::Jsonl;
559 };
560
561 if value.eq_ignore_ascii_case("jsonl") {
562 return Self::Jsonl;
563 }
564
565 if value.eq_ignore_ascii_case("sqlite") {
566 #[cfg(feature = "sqlite-sessions")]
567 {
568 return Self::Sqlite;
569 }
570
571 #[cfg(not(feature = "sqlite-sessions"))]
572 {
573 tracing::warn!(
574 "Config requests session_store=sqlite but binary lacks `sqlite-sessions`; falling back to jsonl"
575 );
576 return Self::Jsonl;
577 }
578 }
579
580 tracing::warn!("Unknown session_store `{value}`, falling back to jsonl");
581 Self::Jsonl
582 }
583
584 const fn extension(self) -> &'static str {
585 match self {
586 Self::Jsonl => "jsonl",
587 #[cfg(feature = "sqlite-sessions")]
588 Self::Sqlite => "sqlite",
589 }
590 }
591}
592
593const DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS: usize = 256;
595
596fn autosave_max_pending_mutations() -> usize {
597 std::env::var("PI_SESSION_AUTOSAVE_MAX_PENDING")
598 .ok()
599 .and_then(|raw| raw.parse::<usize>().ok())
600 .filter(|value| *value > 0)
601 .unwrap_or(DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS)
602}
603
604const DEFAULT_COMPACTION_CHECKPOINT_INTERVAL: u64 = 50;
606
607fn compaction_checkpoint_interval() -> u64 {
608 std::env::var("PI_SESSION_COMPACTION_INTERVAL")
609 .ok()
610 .and_then(|raw| raw.parse::<u64>().ok())
611 .filter(|value| *value > 0)
612 .unwrap_or(DEFAULT_COMPACTION_CHECKPOINT_INTERVAL)
613}
614
615#[derive(Debug, Clone, Copy, PartialEq, Eq)]
617pub enum AutosaveDurabilityMode {
618 Strict,
619 Balanced,
620 Throughput,
621}
622
623impl AutosaveDurabilityMode {
624 fn parse(raw: &str) -> Option<Self> {
625 match raw.trim().to_ascii_lowercase().as_str() {
626 "strict" => Some(Self::Strict),
627 "balanced" => Some(Self::Balanced),
628 "throughput" => Some(Self::Throughput),
629 _ => None,
630 }
631 }
632
633 fn from_env() -> Self {
634 std::env::var("PI_SESSION_DURABILITY_MODE")
635 .ok()
636 .as_deref()
637 .and_then(Self::parse)
638 .unwrap_or(Self::Balanced)
639 }
640
641 const fn should_flush_on_shutdown(self) -> bool {
642 matches!(self, Self::Strict | Self::Balanced)
643 }
644
645 const fn best_effort_on_shutdown(self) -> bool {
646 matches!(self, Self::Balanced)
647 }
648
649 pub const fn as_str(self) -> &'static str {
650 match self {
651 Self::Strict => "strict",
652 Self::Balanced => "balanced",
653 Self::Throughput => "throughput",
654 }
655 }
656}
657
658fn resolve_autosave_durability_mode(
659 cli_mode: Option<&str>,
660 config_mode: Option<&str>,
661 env_mode: Option<&str>,
662) -> AutosaveDurabilityMode {
663 cli_mode
664 .and_then(AutosaveDurabilityMode::parse)
665 .or_else(|| config_mode.and_then(AutosaveDurabilityMode::parse))
666 .or_else(|| env_mode.and_then(AutosaveDurabilityMode::parse))
667 .unwrap_or(AutosaveDurabilityMode::Balanced)
668}
669
670#[derive(Debug, Clone, Copy, PartialEq, Eq)]
672pub enum AutosaveFlushTrigger {
673 Manual,
674 Periodic,
675 Shutdown,
676}
677
678#[derive(Debug, Clone, Copy, PartialEq, Eq)]
679enum AutosaveMutationKind {
680 Message,
681 Metadata,
682 Label,
683}
684
685#[derive(Debug, Clone, Copy, PartialEq, Eq)]
686struct AutosaveFlushTicket {
687 batch_size: usize,
688 started_at: Instant,
689 trigger: AutosaveFlushTrigger,
690}
691
692#[derive(Debug, Clone, Copy, Default)]
694pub struct AutosaveQueueMetrics {
695 pub pending_mutations: usize,
696 pub max_pending_mutations: usize,
697 pub coalesced_mutations: u64,
698 pub backpressure_events: u64,
699 pub flush_started: u64,
700 pub flush_succeeded: u64,
701 pub flush_failed: u64,
702 pub last_flush_batch_size: usize,
703 pub last_flush_duration_ms: Option<u64>,
704 pub last_flush_trigger: Option<AutosaveFlushTrigger>,
705}
706
707#[derive(Debug, Clone)]
708struct AutosaveQueue {
709 pending_mutations: usize,
710 max_pending_mutations: usize,
711 coalesced_mutations: u64,
712 backpressure_events: u64,
713 flush_started: u64,
714 flush_succeeded: u64,
715 flush_failed: u64,
716 last_flush_batch_size: usize,
717 last_flush_duration_ms: Option<u64>,
718 last_flush_trigger: Option<AutosaveFlushTrigger>,
719}
720
721impl AutosaveQueue {
722 fn new() -> Self {
723 Self {
724 pending_mutations: 0,
725 max_pending_mutations: autosave_max_pending_mutations(),
726 coalesced_mutations: 0,
727 backpressure_events: 0,
728 flush_started: 0,
729 flush_succeeded: 0,
730 flush_failed: 0,
731 last_flush_batch_size: 0,
732 last_flush_duration_ms: None,
733 last_flush_trigger: None,
734 }
735 }
736
737 #[cfg(test)]
738 fn with_limit(max_pending_mutations: usize) -> Self {
739 let mut queue = Self::new();
740 queue.max_pending_mutations = max_pending_mutations.max(1);
741 queue
742 }
743
744 const fn metrics(&self) -> AutosaveQueueMetrics {
745 AutosaveQueueMetrics {
746 pending_mutations: self.pending_mutations,
747 max_pending_mutations: self.max_pending_mutations,
748 coalesced_mutations: self.coalesced_mutations,
749 backpressure_events: self.backpressure_events,
750 flush_started: self.flush_started,
751 flush_succeeded: self.flush_succeeded,
752 flush_failed: self.flush_failed,
753 last_flush_batch_size: self.last_flush_batch_size,
754 last_flush_duration_ms: self.last_flush_duration_ms,
755 last_flush_trigger: self.last_flush_trigger,
756 }
757 }
758
759 const fn enqueue_mutation(&mut self, _kind: AutosaveMutationKind) {
760 if self.pending_mutations == 0 {
761 self.pending_mutations = 1;
762 return;
763 }
764 self.coalesced_mutations = self.coalesced_mutations.saturating_add(1);
765 if self.pending_mutations < self.max_pending_mutations {
766 self.pending_mutations += 1;
767 } else {
768 self.backpressure_events = self.backpressure_events.saturating_add(1);
769 }
770 }
771
772 fn begin_flush(&mut self, trigger: AutosaveFlushTrigger) -> Option<AutosaveFlushTicket> {
773 if self.pending_mutations == 0 {
774 return None;
775 }
776 let batch_size = self.pending_mutations;
777 self.pending_mutations = 0;
778 self.flush_started = self.flush_started.saturating_add(1);
779 self.last_flush_batch_size = batch_size;
780 self.last_flush_trigger = Some(trigger);
781 Some(AutosaveFlushTicket {
782 batch_size,
783 started_at: Instant::now(),
784 trigger,
785 })
786 }
787
788 fn finish_flush(&mut self, ticket: AutosaveFlushTicket, success: bool) {
789 let elapsed = ticket.started_at.elapsed().as_millis();
790 let elapsed = u64::try_from(elapsed.min(u128::from(u64::MAX)))
791 .expect("elapsed milliseconds clamped to u64::MAX");
792 self.last_flush_duration_ms = Some(elapsed);
793 self.last_flush_trigger = Some(ticket.trigger);
794 if success {
795 self.flush_succeeded = self.flush_succeeded.saturating_add(1);
796 return;
797 }
798
799 self.flush_failed = self.flush_failed.saturating_add(1);
800 let available_capacity = self
804 .max_pending_mutations
805 .saturating_sub(self.pending_mutations);
806 let restored = ticket.batch_size.min(available_capacity);
807 self.pending_mutations = self.pending_mutations.saturating_add(restored);
808 let dropped = ticket.batch_size.saturating_sub(restored);
809 if dropped > 0 {
810 let dropped = dropped as u64;
811 self.backpressure_events = self.backpressure_events.saturating_add(dropped);
812 self.coalesced_mutations = self.coalesced_mutations.saturating_add(dropped);
813 }
814 }
815}
816
817#[derive(Debug)]
823#[allow(clippy::struct_excessive_bools)]
824pub struct Session {
825 pub header: SessionHeader,
827 pub entries: Vec<SessionEntry>,
829 pub path: Option<PathBuf>,
831 pub(crate) leaf_id: Option<String>,
834 pub session_dir: Option<PathBuf>,
836 store_kind: SessionStoreKind,
837 entry_ids: HashSet<String>,
839
840 is_linear: bool,
845 entry_index: HashMap<String, usize>,
847 cached_message_count: u64,
849 cached_name: Option<String>,
851 autosave_queue: AutosaveQueue,
853 autosave_durability: AutosaveDurabilityMode,
855
856 persisted_entry_count: Arc<AtomicUsize>,
861 header_dirty: bool,
863 appends_since_checkpoint: u64,
865 v2_sidecar_root: Option<PathBuf>,
867 v2_partial_hydration: bool,
869 v2_resume_mode: Option<V2OpenMode>,
871 v2_sidecar_stale: bool,
873 v2_message_count_offset: u64,
876}
877
878impl Clone for Session {
879 fn clone(&self) -> Self {
880 Self {
881 header: self.header.clone(),
882 entries: self.entries.clone(),
883 path: self.path.clone(),
884 leaf_id: self.leaf_id.clone(),
885 session_dir: self.session_dir.clone(),
886 store_kind: self.store_kind,
887 entry_ids: self.entry_ids.clone(),
888 is_linear: self.is_linear,
889 entry_index: self.entry_index.clone(),
890 cached_message_count: self.cached_message_count,
891 cached_name: self.cached_name.clone(),
892 autosave_queue: self.autosave_queue.clone(),
893 autosave_durability: self.autosave_durability,
894 persisted_entry_count: Arc::new(AtomicUsize::new(
898 self.persisted_entry_count.load(Ordering::SeqCst),
899 )),
900 header_dirty: self.header_dirty,
901 appends_since_checkpoint: self.appends_since_checkpoint,
902 v2_sidecar_root: self.v2_sidecar_root.clone(),
903 v2_partial_hydration: self.v2_partial_hydration,
904 v2_resume_mode: self.v2_resume_mode,
905 v2_sidecar_stale: self.v2_sidecar_stale,
906 v2_message_count_offset: self.v2_message_count_offset,
907 }
908 }
909}
910
911#[derive(Debug, Clone)]
919pub struct ForkPlan {
920 pub entries: Vec<SessionEntry>,
922 pub leaf_id: Option<String>,
924 pub selected_text: String,
926}
927
928#[derive(Debug, Clone)]
934pub struct ExportSnapshot {
935 pub header: SessionHeader,
937 pub entries: Vec<SessionEntry>,
939 pub path: Option<PathBuf>,
941}
942
943impl ExportSnapshot {
944 pub fn to_html(&self) -> String {
948 render_session_html(&self.header, &self.entries)
949 }
950}
951
952#[derive(Debug, Clone, Default)]
954pub struct SessionOpenDiagnostics {
955 pub skipped_entries: Vec<SessionOpenSkippedEntry>,
956 pub orphaned_parent_links: Vec<SessionOpenOrphanedParentLink>,
957}
958
959#[derive(Debug, Clone)]
960pub struct SessionOpenSkippedEntry {
961 pub line_number: usize,
963 pub error: String,
964}
965
966#[derive(Debug, Clone)]
967pub struct SessionOpenOrphanedParentLink {
968 pub entry_id: String,
969 pub missing_parent_id: String,
970}
971
972pub const SESSION_COLD_START_TRACE_SCHEMA: &str = "pi.session.cold_start_trace.v1";
974pub const SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA: &str =
975 "pi.session.replay_minimization_trace.v1";
976
977#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
979pub struct SessionColdStartTraceBundle {
980 pub schema: String,
981 pub session_path_hash: String,
982 pub storage: SessionColdStartStorageTrace,
983 pub input: SessionColdStartInputTrace,
984 pub phases: Vec<SessionColdStartPhaseTrace>,
985 pub index_refresh: SessionColdStartIndexRefreshTrace,
986 pub open_diagnostics: SessionColdStartOpenDiagnosticsTrace,
987 pub replay_minimization: SessionReplayMinimizationTrace,
988 pub compaction_scan: SessionColdStartCompactionTrace,
989 pub first_render: SessionColdStartFirstRenderTrace,
990 pub bounds: SessionColdStartBoundsTrace,
991 pub total_elapsed_us: u64,
992}
993
994#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
996pub struct SessionColdStartStorageTrace {
997 pub selected_backend: String,
998 pub opened_backend: String,
999 pub path_extension: String,
1000 pub sqlite_feature_enabled: bool,
1001 pub v2_sidecar_present: bool,
1002 pub v2_sidecar_stale: bool,
1003 pub fallback_reason: Option<String>,
1004}
1005
1006#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1008pub struct SessionColdStartInputTrace {
1009 pub total_entries: usize,
1010 pub total_messages: u64,
1011}
1012
1013#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1015pub struct SessionColdStartPhaseTrace {
1016 pub name: String,
1017 pub elapsed_us: u64,
1018 pub status: String,
1019}
1020
1021#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1023pub struct SessionColdStartIndexRefreshTrace {
1024 pub scanned_files: usize,
1025 pub cache_hit_files: usize,
1026 pub reused_files: usize,
1027 pub refreshed_files: usize,
1028 pub pruned_rows: usize,
1029 pub failed_files: usize,
1030}
1031
1032#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1034pub struct SessionColdStartOpenDiagnosticsTrace {
1035 pub skipped_entries: usize,
1036 pub orphaned_parent_links: usize,
1037}
1038
1039#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1041pub struct SessionReplayMinimizationTrace {
1042 pub schema: String,
1043 pub branch_count: usize,
1044 pub entry_count: usize,
1045 pub selected_depth: usize,
1046 pub scanned_files: usize,
1047 pub replayed_entries: usize,
1048 pub skipped_sibling_entries: usize,
1049 pub deterministic_steps: usize,
1050 pub fallback_behavior: Option<String>,
1051 pub verdict: String,
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1056pub struct SessionColdStartCompactionTrace {
1057 pub scanned_entries: usize,
1058 pub compaction_entries: usize,
1059 pub latest_compaction_present: bool,
1060 pub latest_compaction_index_from_end: Option<usize>,
1061 pub first_kept_entry_found: Option<bool>,
1062}
1063
1064#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1066pub struct SessionColdStartFirstRenderTrace {
1067 pub current_path_entries: usize,
1068 pub projected_messages: usize,
1069 pub user_messages: usize,
1070 pub assistant_messages: usize,
1071 pub tool_messages: usize,
1072 pub system_messages: usize,
1073 pub input_tokens: u64,
1074 pub output_tokens: u64,
1075 pub cache_read_tokens: u64,
1076 pub cache_write_tokens: u64,
1077 pub total_tokens: u64,
1078 pub ready: bool,
1079}
1080
1081#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1083pub struct SessionColdStartBoundsTrace {
1084 pub max_phase_count: usize,
1085 pub raw_path_included: bool,
1086 pub raw_cwd_included: bool,
1087 pub raw_message_content_included: bool,
1088}
1089
1090#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1092pub enum V2OpenMode {
1093 Full,
1094 ActivePath,
1095 Tail(u64),
1096}
1097
1098const DEFAULT_V2_LAZY_HYDRATION_THRESHOLD: u64 = 10_000;
1099const DEFAULT_V2_TAIL_HYDRATION_COUNT: u64 = 256;
1100
1101fn parse_v2_open_mode(raw: &str) -> Option<V2OpenMode> {
1102 let normalized = raw.trim().to_ascii_lowercase();
1103 if normalized.is_empty() {
1104 return None;
1105 }
1106 match normalized.as_str() {
1107 "full" => Some(V2OpenMode::Full),
1108 "active" | "active_path" | "active-path" => Some(V2OpenMode::ActivePath),
1109 "tail" => Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT)),
1110 _ => normalized
1111 .strip_prefix("tail:")
1112 .and_then(|value| value.parse::<u64>().ok().map(V2OpenMode::Tail)),
1113 }
1114}
1115
1116fn resolve_v2_lazy_hydration_threshold(env_raw: Option<&str>) -> u64 {
1117 env_raw
1118 .and_then(|raw| raw.trim().parse::<u64>().ok())
1119 .unwrap_or(DEFAULT_V2_LAZY_HYDRATION_THRESHOLD)
1120}
1121
1122fn select_v2_open_mode_for_resume(
1123 entry_count: u64,
1124 mode_override_raw: Option<&str>,
1125 threshold_override_raw: Option<&str>,
1126) -> (V2OpenMode, &'static str, u64) {
1127 let lazy_threshold = resolve_v2_lazy_hydration_threshold(threshold_override_raw);
1128 if let Some(raw) = mode_override_raw {
1129 if let Some(mode) = parse_v2_open_mode(raw) {
1130 return (mode, "env_override", lazy_threshold);
1131 }
1132 }
1133
1134 if lazy_threshold > 0 && entry_count > lazy_threshold {
1135 return (
1136 V2OpenMode::ActivePath,
1137 "entry_count_above_lazy_threshold",
1138 lazy_threshold,
1139 );
1140 }
1141
1142 (V2OpenMode::Full, "default_full", lazy_threshold)
1143}
1144
1145impl SessionOpenDiagnostics {
1146 fn warning_lines(&self) -> Vec<String> {
1147 let mut lines = Vec::new();
1148 for skipped in &self.skipped_entries {
1149 lines.push(format!(
1150 "Warning: Skipping corrupted entry at line {} in session file: {}",
1151 skipped.line_number, skipped.error
1152 ));
1153 }
1154
1155 if !self.skipped_entries.is_empty() {
1156 lines.push(format!(
1157 "Warning: Skipped {} corrupted entries while loading session",
1158 self.skipped_entries.len()
1159 ));
1160 }
1161
1162 for orphan in &self.orphaned_parent_links {
1163 lines.push(format!(
1164 "Warning: Entry {} references missing parent {}",
1165 orphan.entry_id, orphan.missing_parent_id
1166 ));
1167 }
1168
1169 if !self.orphaned_parent_links.is_empty() {
1170 lines.push(format!(
1171 "Warning: Detected {} orphaned parent links while loading session",
1172 self.orphaned_parent_links.len()
1173 ));
1174 }
1175
1176 lines
1177 }
1178}
1179
1180impl SessionColdStartTraceBundle {
1181 pub fn emit_log(&self) {
1183 tracing::info!(
1184 schema = self.schema.as_str(),
1185 session_path_hash = self.session_path_hash.as_str(),
1186 selected_backend = self.storage.selected_backend.as_str(),
1187 opened_backend = self.storage.opened_backend.as_str(),
1188 total_entries = self.input.total_entries,
1189 total_messages = self.input.total_messages,
1190 phase_count = self.phases.len(),
1191 open_skipped_entries = self.open_diagnostics.skipped_entries,
1192 open_orphaned_parent_links = self.open_diagnostics.orphaned_parent_links,
1193 index_cache_hit_files = self.index_refresh.cache_hit_files,
1194 replay_branch_count = self.replay_minimization.branch_count,
1195 replay_entry_count = self.replay_minimization.entry_count,
1196 replay_selected_depth = self.replay_minimization.selected_depth,
1197 replay_skipped_sibling_entries = self.replay_minimization.skipped_sibling_entries,
1198 replay_verdict = self.replay_minimization.verdict.as_str(),
1199 first_render_projected_messages = self.first_render.projected_messages,
1200 total_elapsed_us = self.total_elapsed_us,
1201 "session cold-start trace bundle"
1202 );
1203 }
1204}
1205
1206fn elapsed_us_since(start: Instant) -> u64 {
1207 u64::try_from(start.elapsed().as_micros()).unwrap_or(u64::MAX)
1208}
1209
1210fn cold_start_hash_path(path: &Path) -> String {
1211 let mut digest = format!("{:x}", Sha256::digest(path.to_string_lossy().as_bytes()));
1212 digest.truncate(16);
1213 digest
1214}
1215
1216fn session_cold_start_storage_trace(path: &Path) -> SessionColdStartStorageTrace {
1217 let path_extension = path
1218 .extension()
1219 .and_then(|ext| ext.to_str())
1220 .unwrap_or("none")
1221 .to_string();
1222 let sqlite_feature_enabled = cfg!(feature = "sqlite-sessions");
1223 let v2_sidecar_present = session_store_v2::has_v2_sidecar(path);
1224 let v2_sidecar_stale = if v2_sidecar_present {
1225 let v2_root = session_store_v2::v2_sidecar_path(path);
1226 is_v2_sidecar_stale(path, &v2_root)
1227 } else {
1228 false
1229 };
1230
1231 let (selected_backend, fallback_reason) = if matches!(path_extension.as_str(), "sqlite") {
1232 if sqlite_feature_enabled {
1233 ("sqlite", None)
1234 } else {
1235 (
1236 "sqlite_unavailable",
1237 Some("sqlite_sessions_feature_disabled".to_string()),
1238 )
1239 }
1240 } else if v2_sidecar_present && !v2_sidecar_stale {
1241 ("v2_sidecar", None)
1242 } else if v2_sidecar_present {
1243 ("jsonl", Some("v2_sidecar_stale".to_string()))
1244 } else {
1245 ("jsonl", None)
1246 };
1247
1248 SessionColdStartStorageTrace {
1249 selected_backend: selected_backend.to_string(),
1250 opened_backend: "not_opened".to_string(),
1251 path_extension,
1252 sqlite_feature_enabled,
1253 v2_sidecar_present,
1254 v2_sidecar_stale,
1255 fallback_reason,
1256 }
1257}
1258
1259impl Session {
1260 pub async fn new(cli: &Cli, config: &Config) -> Result<Self> {
1262 let session_dir = cli.session_dir.as_ref().map(PathBuf::from);
1263 let durability_mode = resolve_autosave_durability_mode(
1264 cli.session_durability.as_deref(),
1265 config.session_durability.as_deref(),
1266 std::env::var("PI_SESSION_DURABILITY_MODE").ok().as_deref(),
1267 );
1268 if cli.no_session {
1269 let mut session = Self::in_memory();
1270 session.set_autosave_durability_mode(durability_mode);
1271 return Ok(session);
1272 }
1273
1274 if let Some(path) = &cli.session {
1275 let mut session = Self::open(path).await?;
1276 session.session_dir = session_dir
1277 .clone()
1278 .or_else(|| infer_session_root_from_path(Path::new(path)));
1279 session.set_autosave_durability_mode(durability_mode);
1280 return Ok(session);
1281 }
1282
1283 if cli.resume {
1284 let picker_input_override = config
1285 .session_picker_input
1286 .filter(|value| *value > 0)
1287 .map(|value| value.to_string());
1288 let mut session = Box::pin(Self::resume_with_picker(
1289 session_dir.as_deref(),
1290 config,
1291 picker_input_override,
1292 ))
1293 .await?;
1294 session.set_autosave_durability_mode(durability_mode);
1295 return Ok(session);
1296 }
1297
1298 if cli.r#continue {
1299 let mut session = Self::continue_recent_in_dir(session_dir.as_deref(), config).await?;
1300 session.set_autosave_durability_mode(durability_mode);
1301 return Ok(session);
1302 }
1303
1304 let store_kind = SessionStoreKind::from_config(config);
1305 let mut session = Self::create_with_dir_and_store(session_dir, store_kind);
1306 session.set_autosave_durability_mode(durability_mode);
1307
1308 Ok(session)
1310 }
1311
1312 #[allow(clippy::too_many_lines)]
1314 pub async fn resume_with_picker(
1315 override_dir: Option<&Path>,
1316 config: &Config,
1317 picker_input_override: Option<String>,
1318 ) -> Result<Self> {
1319 let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
1320 let mut picker_input_override = picker_input_override;
1321 #[cfg(feature = "tui")]
1325 if picker_input_override.is_none() && is_interactive {
1326 if let Some(session) = crate::session_picker::pick_session(override_dir).await {
1327 return Ok(session);
1328 }
1329 }
1330
1331 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1332 let store_kind = SessionStoreKind::from_config(config);
1333 let cwd = std::env::current_dir()?;
1334 let encoded_cwd = encode_cwd(&cwd);
1335 let project_session_dir = base_dir.join(&encoded_cwd);
1336 let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
1337
1338 let base_dir_clone = base_dir.clone();
1339 let cwd_display = cwd.display().to_string();
1340 let (tx, mut rx) = oneshot::channel();
1341
1342 let handle = thread::spawn(move || {
1343 let indexed_meta = SessionIndex::for_sessions_root(&base_dir_clone)
1344 .list_sessions(Some(&cwd_display))
1345 .unwrap_or_default();
1346 let cx = AgentCx::for_request();
1347 let _ = tx.send(cx.cx(), Ok(indexed_meta));
1348 });
1349
1350 let cx = AgentCx::for_request();
1351 let recv_result = rx.recv(cx.cx()).await;
1352 let indexed_meta =
1353 finish_worker_result(handle, recv_result, "Session picker index task cancelled")
1354 .unwrap_or_default();
1355 let session_index = SessionIndex::for_sessions_root(&base_dir);
1356 let (entries, missing_paths) = split_indexed_session_entries(indexed_meta);
1357 for path in &missing_paths {
1358 prune_session_index_path(
1359 &session_index,
1360 path,
1361 "Failed to prune missing session from index during picker refresh",
1362 );
1363 }
1364
1365 if project_session_dir_missing {
1366 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1367 }
1368
1369 let scanned = scan_sessions_on_disk(&project_session_dir, entries.clone()).await?;
1370 let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1371 for entry in entries {
1372 by_path.insert(entry.path.clone(), entry);
1373 }
1374 for path in &scanned.failed_paths {
1375 prune_session_index_path(
1376 &session_index,
1377 path,
1378 "Failed to prune unreadable session from index during picker refresh",
1379 );
1380 by_path.remove(path);
1381 }
1382 refresh_session_index_entries(
1383 &session_index,
1384 &scanned.refreshed_entries,
1385 "Failed to refresh session metadata in index during picker refresh",
1386 );
1387 merge_scanned_session_entries(&mut by_path, scanned.entries);
1388 let mut entries = by_path.into_values().collect::<Vec<_>>();
1389
1390 if entries.is_empty() {
1391 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1392 }
1393
1394 entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1395 let max_entries = 20usize.min(entries.len());
1396 let mut entries = entries.into_iter().take(max_entries).collect::<Vec<_>>();
1397
1398 let console = PiConsole::new();
1399 console.render_info("Select a session to resume:");
1400
1401 let headers = ["#", "Timestamp", "Messages", "Name", "Path"];
1402
1403 let mut attempts = 0;
1404 loop {
1405 if entries.is_empty() {
1406 console.render_warning("No resumable sessions available. Starting a new session.");
1407 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1408 }
1409
1410 let mut rows: Vec<Vec<String>> = Vec::new();
1411 for (idx, entry) in entries.iter().enumerate() {
1412 rows.push(vec![
1413 format!("{}", idx + 1),
1414 entry.timestamp.clone(),
1415 entry.message_count.to_string(),
1416 entry.name.clone().unwrap_or_else(|| entry.id.clone()),
1417 entry.path.display().to_string(),
1418 ]);
1419 }
1420 let row_refs: Vec<Vec<&str>> = rows
1421 .iter()
1422 .map(|row| row.iter().map(String::as_str).collect())
1423 .collect();
1424 console.render_table(&headers, &row_refs);
1425
1426 attempts += 1;
1427 if attempts > 3 {
1428 console.render_warning("No selection made. Starting a new session.");
1429 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1430 }
1431
1432 print!(
1433 "Enter selection (1-{}, blank to start new): ",
1434 entries.len()
1435 );
1436 let _ = std::io::stdout().flush();
1437
1438 let input = if let Some(override_input) = picker_input_override.take() {
1439 override_input
1440 } else {
1441 let mut input = String::new();
1442 std::io::stdin().read_line(&mut input)?;
1443 input
1444 };
1445 let input = input.trim();
1446 if input.is_empty() {
1447 console.render_info("Starting a new session.");
1448 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1449 }
1450
1451 match input.parse::<usize>() {
1452 Ok(selection) if selection > 0 && selection <= entries.len() => {
1453 let selected = &entries[selection - 1];
1454 match Self::open(selected.path.to_string_lossy().as_ref()).await {
1455 Ok(mut session) => {
1456 session.session_dir = Some(base_dir.clone());
1457 return Ok(session);
1458 }
1459 Err(err) => {
1460 tracing::warn!(
1461 path = %selected.path.display(),
1462 error = %err,
1463 "Failed to open selected session while resuming"
1464 );
1465 prune_session_index_path(
1466 &session_index,
1467 &selected.path,
1468 "Failed to prune unreadable selected session after picker open failure",
1469 );
1470 entries.remove(selection - 1);
1471
1472 if is_interactive {
1473 console.render_warning(
1474 "Selected session could not be opened. Pick another session.",
1475 );
1476 continue;
1477 }
1478
1479 console.render_warning(
1480 "Selected session could not be opened. Starting a new session.",
1481 );
1482 return Ok(Self::create_with_dir_and_store(
1483 Some(base_dir.clone()),
1484 store_kind,
1485 ));
1486 }
1487 }
1488 }
1489 _ => {
1490 console.render_warning("Invalid selection. Try again.");
1491 }
1492 }
1493 }
1494 }
1495
1496 pub fn in_memory() -> Self {
1498 Self {
1499 header: SessionHeader::new(),
1500 entries: Vec::new(),
1501 path: None,
1502 leaf_id: None,
1503 session_dir: None,
1504 store_kind: SessionStoreKind::Jsonl,
1505 entry_ids: HashSet::new(),
1506 is_linear: true,
1507 entry_index: HashMap::new(),
1508 cached_message_count: 0,
1509 cached_name: None,
1510 autosave_queue: AutosaveQueue::new(),
1511 autosave_durability: AutosaveDurabilityMode::from_env(),
1512 persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1513 header_dirty: false,
1514 appends_since_checkpoint: 0,
1515 v2_sidecar_root: None,
1516 v2_partial_hydration: false,
1517 v2_resume_mode: None,
1518 v2_sidecar_stale: false,
1519 v2_message_count_offset: 0,
1520 }
1521 }
1522
1523 pub fn create() -> Self {
1525 Self::create_with_dir(None)
1526 }
1527
1528 pub fn create_with_dir(session_dir: Option<PathBuf>) -> Self {
1530 Self::create_with_dir_and_store(session_dir, SessionStoreKind::Jsonl)
1531 }
1532
1533 pub fn create_with_dir_and_store(
1534 session_dir: Option<PathBuf>,
1535 store_kind: SessionStoreKind,
1536 ) -> Self {
1537 let header = SessionHeader::new();
1538 Self {
1539 header,
1540 entries: Vec::new(),
1541 path: None,
1542 leaf_id: None,
1543 session_dir,
1544 store_kind,
1545 entry_ids: HashSet::new(),
1546 is_linear: true,
1547 entry_index: HashMap::new(),
1548 cached_message_count: 0,
1549 cached_name: None,
1550 autosave_queue: AutosaveQueue::new(),
1551 autosave_durability: AutosaveDurabilityMode::from_env(),
1552 persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1553 header_dirty: false,
1554 appends_since_checkpoint: 0,
1555 v2_sidecar_root: None,
1556 v2_partial_hydration: false,
1557 v2_resume_mode: None,
1558 v2_sidecar_stale: false,
1559 v2_message_count_offset: 0,
1560 }
1561 }
1562
1563 pub async fn open(path: &str) -> Result<Self> {
1565 let (session, diagnostics) = Self::open_with_diagnostics(path).await?;
1566 for warning in diagnostics.warning_lines() {
1567 warn!("{warning}");
1568 }
1569 Ok(session)
1570 }
1571
1572 pub async fn open_with_diagnostics(path: &str) -> Result<(Self, SessionOpenDiagnostics)> {
1574 Self::open_path_with_diagnostics(PathBuf::from(path)).await
1575 }
1576
1577 pub async fn cold_start_trace_bundle(
1579 path: &Path,
1580 sessions_root: &Path,
1581 ) -> Result<SessionColdStartTraceBundle> {
1582 let total_start = Instant::now();
1583 let mut phases = Vec::with_capacity(4);
1584 let mut storage = session_cold_start_storage_trace(path);
1585
1586 let open_start = Instant::now();
1587 let (session, diagnostics) = Self::open_path_with_diagnostics(path.to_path_buf()).await?;
1588 let open_elapsed_us = elapsed_us_since(open_start);
1589 storage.opened_backend = session.opened_storage_backend_for_trace().to_string();
1590 phases.push(SessionColdStartPhaseTrace {
1591 name: "session_open".to_string(),
1592 elapsed_us: open_elapsed_us,
1593 status: "ok".to_string(),
1594 });
1595
1596 let index_start = Instant::now();
1597 let index_summary = SessionIndex::for_sessions_root(sessions_root).refresh_incremental()?;
1598 phases.push(SessionColdStartPhaseTrace {
1599 name: "session_index_refresh".to_string(),
1600 elapsed_us: elapsed_us_since(index_start),
1601 status: "ok".to_string(),
1602 });
1603 let index_refresh = SessionColdStartIndexRefreshTrace {
1604 scanned_files: index_summary.scanned_files,
1605 cache_hit_files: index_summary.reused_files,
1606 reused_files: index_summary.reused_files,
1607 refreshed_files: index_summary.refreshed_files,
1608 pruned_rows: index_summary.pruned_rows,
1609 failed_files: index_summary.failed_files,
1610 };
1611
1612 let compaction_start = Instant::now();
1613 let compaction_scan = session.cold_start_compaction_scan_trace();
1614 phases.push(SessionColdStartPhaseTrace {
1615 name: "compaction_scan".to_string(),
1616 elapsed_us: elapsed_us_since(compaction_start),
1617 status: "ok".to_string(),
1618 });
1619
1620 let first_render_start = Instant::now();
1621 let first_render = session.cold_start_first_render_trace();
1622 phases.push(SessionColdStartPhaseTrace {
1623 name: "first_render_ready".to_string(),
1624 elapsed_us: elapsed_us_since(first_render_start),
1625 status: "ok".to_string(),
1626 });
1627
1628 let replay_minimization =
1629 session.cold_start_replay_minimization_trace(&storage, &index_summary, &diagnostics);
1630
1631 let bundle = SessionColdStartTraceBundle {
1632 schema: SESSION_COLD_START_TRACE_SCHEMA.to_string(),
1633 session_path_hash: cold_start_hash_path(path),
1634 storage,
1635 input: SessionColdStartInputTrace {
1636 total_entries: session.entries.len(),
1637 total_messages: session.cached_message_count,
1638 },
1639 phases,
1640 index_refresh,
1641 open_diagnostics: SessionColdStartOpenDiagnosticsTrace {
1642 skipped_entries: diagnostics.skipped_entries.len(),
1643 orphaned_parent_links: diagnostics.orphaned_parent_links.len(),
1644 },
1645 replay_minimization,
1646 compaction_scan,
1647 first_render,
1648 bounds: SessionColdStartBoundsTrace {
1649 max_phase_count: 4,
1650 raw_path_included: false,
1651 raw_cwd_included: false,
1652 raw_message_content_included: false,
1653 },
1654 total_elapsed_us: elapsed_us_since(total_start),
1655 };
1656 bundle.emit_log();
1657 Ok(bundle)
1658 }
1659
1660 async fn open_path_with_diagnostics(path: PathBuf) -> Result<(Self, SessionOpenDiagnostics)> {
1661 if !path.exists() {
1662 return Err(crate::Error::SessionNotFound {
1663 path: path.display().to_string(),
1664 });
1665 }
1666
1667 if path
1668 .extension()
1669 .and_then(|ext| ext.to_str())
1670 .is_some_and(|ext| matches!(ext, "sqlite"))
1671 {
1672 #[cfg(feature = "sqlite-sessions")]
1673 {
1674 let session = Self::open_sqlite(&path).await?;
1675 return Ok((session, SessionOpenDiagnostics::default()));
1676 }
1677
1678 #[cfg(not(feature = "sqlite-sessions"))]
1679 {
1680 return Err(Error::session(
1681 "SQLite session files require building with `--features sqlite-sessions`",
1682 ));
1683 }
1684 }
1685
1686 if session_store_v2::has_v2_sidecar(&path) {
1688 let v2_root = session_store_v2::v2_sidecar_path(&path);
1689 let is_stale = is_v2_sidecar_stale(&path, &v2_root);
1690
1691 if is_stale {
1692 tracing::warn!(
1693 path = %path.display(),
1694 "V2 sidecar is stale (source JSONL newer); skipping V2 resume"
1695 );
1696 } else {
1697 match Self::open_v2_with_diagnostics(&path).await {
1698 Ok(result) => return Ok(result),
1699 Err(e) => {
1700 tracing::warn!(
1701 path = %path.display(),
1702 error = %e,
1703 "V2 sidecar resume failed, falling back to full JSONL parse"
1704 );
1705 }
1706 }
1707 }
1708 }
1709
1710 Self::open_jsonl_with_diagnostics(&path).await
1711 }
1712
1713 const fn opened_storage_backend_for_trace(&self) -> &'static str {
1714 match self.store_kind {
1715 SessionStoreKind::Jsonl => {
1716 if self.v2_sidecar_root.is_some() {
1717 "v2_sidecar"
1718 } else {
1719 "jsonl"
1720 }
1721 }
1722 #[cfg(feature = "sqlite-sessions")]
1723 SessionStoreKind::Sqlite => "sqlite",
1724 }
1725 }
1726
1727 fn cold_start_current_path_entries(&self) -> Vec<&SessionEntry> {
1728 if self.leaf_id.is_none() {
1729 return Vec::new();
1730 }
1731 if self.is_linear {
1732 return self.entries.iter().collect();
1733 }
1734 self.entries_for_current_path()
1735 }
1736
1737 fn cold_start_total_entries_and_branch_count(&self) -> (usize, usize) {
1738 let loaded_summary = self.branch_summary();
1739 let mut entry_count = loaded_summary.total_entries;
1740 let mut branch_count = loaded_summary.branch_point_count;
1741
1742 if let Some(v2_root) = self.v2_sidecar_root.as_ref() {
1743 if let Ok(store) = SessionStoreV2::create(v2_root, 64 * 1024 * 1024) {
1744 entry_count =
1745 entry_count.max(usize::try_from(store.entry_count()).unwrap_or(usize::MAX));
1746 if let Ok(Some(manifest)) = store.read_manifest() {
1747 branch_count = branch_count.max(
1748 usize::try_from(manifest.counters.branches_total).unwrap_or(usize::MAX),
1749 );
1750 entry_count = entry_count.max(
1751 usize::try_from(manifest.counters.entries_total).unwrap_or(usize::MAX),
1752 );
1753 }
1754 }
1755 }
1756
1757 (entry_count, branch_count)
1758 }
1759
1760 fn cold_start_replay_minimization_trace(
1761 &self,
1762 storage: &SessionColdStartStorageTrace,
1763 index_summary: &SessionIndexRefreshSummary,
1764 diagnostics: &SessionOpenDiagnostics,
1765 ) -> SessionReplayMinimizationTrace {
1766 let path_entries = self.cold_start_current_path_entries();
1767 let selected_depth = path_entries.len();
1768 let replayed_entries = path_entries
1769 .iter()
1770 .filter(|entry| {
1771 matches!(
1772 entry,
1773 SessionEntry::Message(_)
1774 | SessionEntry::BranchSummary(_)
1775 | SessionEntry::Compaction(_)
1776 )
1777 })
1778 .count();
1779 let (entry_count, branch_count) = self.cold_start_total_entries_and_branch_count();
1780 let skipped_sibling_entries = entry_count.saturating_sub(selected_depth);
1781 let deterministic_steps = selected_depth
1782 .saturating_add(index_summary.scanned_files)
1783 .saturating_add(diagnostics.skipped_entries.len())
1784 .saturating_add(diagnostics.orphaned_parent_links.len());
1785 let opened_backend = storage.opened_backend.as_str();
1786 let selected_backend = storage.selected_backend.as_str();
1787 let backend_changed = !matches!(
1788 (opened_backend, selected_backend),
1789 ("jsonl", "jsonl") | ("v2_sidecar", "v2_sidecar")
1790 );
1791
1792 let fallback_behavior = if !diagnostics.orphaned_parent_links.is_empty() {
1793 Some("orphaned_parent_links_detected".to_string())
1794 } else if !diagnostics.skipped_entries.is_empty() {
1795 Some("corrupt_jsonl_entries_skipped".to_string())
1796 } else if backend_changed {
1797 Some(format!(
1798 "{}_fallback_to_{}",
1799 storage.selected_backend, storage.opened_backend
1800 ))
1801 } else if let Some(reason) = storage.fallback_reason.as_ref() {
1802 Some(reason.clone())
1803 } else if matches!(opened_backend, "jsonl") && !storage.v2_sidecar_present {
1804 Some("jsonl_full_scan_without_sidecar".to_string())
1805 } else {
1806 None
1807 };
1808
1809 let verdict = if diagnostics.orphaned_parent_links.is_empty()
1810 && diagnostics.skipped_entries.is_empty()
1811 && skipped_sibling_entries > 0
1812 && fallback_behavior.is_none()
1813 {
1814 "bounded_selected_branch".to_string()
1815 } else if fallback_behavior.is_some() {
1816 "fallback_explicit".to_string()
1817 } else {
1818 "linear_or_single_branch".to_string()
1819 };
1820
1821 SessionReplayMinimizationTrace {
1822 schema: SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA.to_string(),
1823 branch_count,
1824 entry_count,
1825 selected_depth,
1826 scanned_files: index_summary.scanned_files,
1827 replayed_entries,
1828 skipped_sibling_entries,
1829 deterministic_steps,
1830 fallback_behavior,
1831 verdict,
1832 }
1833 }
1834
1835 fn cold_start_compaction_scan_trace(&self) -> SessionColdStartCompactionTrace {
1836 let path_entries = self.cold_start_current_path_entries();
1837 let mut compaction_entries = 0usize;
1838 let mut latest = None;
1839
1840 for (idx, entry) in path_entries.iter().enumerate() {
1841 if let SessionEntry::Compaction(compaction) = entry {
1842 compaction_entries = compaction_entries.saturating_add(1);
1843 latest = Some((idx, compaction.first_kept_entry_id.clone()));
1844 }
1845 }
1846
1847 let (latest_compaction_index_from_end, first_kept_entry_found) =
1848 if let Some((idx, first_kept_entry_id)) = latest {
1849 let found = path_entries.iter().any(|entry| {
1850 entry
1851 .base_id()
1852 .is_some_and(|entry_id| entry_id.eq(&first_kept_entry_id))
1853 });
1854 (
1855 Some(path_entries.len().saturating_sub(idx.saturating_add(1))),
1856 Some(found),
1857 )
1858 } else {
1859 (None, None)
1860 };
1861
1862 SessionColdStartCompactionTrace {
1863 scanned_entries: path_entries.len(),
1864 compaction_entries,
1865 latest_compaction_present: latest_compaction_index_from_end.is_some(),
1866 latest_compaction_index_from_end,
1867 first_kept_entry_found,
1868 }
1869 }
1870
1871 fn cold_start_first_render_trace(&self) -> SessionColdStartFirstRenderTrace {
1872 let path_entries = self.cold_start_current_path_entries();
1873 let mut trace = SessionColdStartFirstRenderTrace {
1874 current_path_entries: path_entries.len(),
1875 projected_messages: 0,
1876 user_messages: 0,
1877 assistant_messages: 0,
1878 tool_messages: 0,
1879 system_messages: 0,
1880 input_tokens: 0,
1881 output_tokens: 0,
1882 cache_read_tokens: 0,
1883 cache_write_tokens: 0,
1884 total_tokens: 0,
1885 ready: true,
1886 };
1887
1888 for entry in path_entries {
1889 let SessionEntry::Message(message_entry) = entry else {
1890 continue;
1891 };
1892
1893 match &message_entry.message {
1894 SessionMessage::User { .. } => {
1895 trace.projected_messages = trace.projected_messages.saturating_add(1);
1896 trace.user_messages = trace.user_messages.saturating_add(1);
1897 }
1898 SessionMessage::Assistant { message } => {
1899 trace.projected_messages = trace.projected_messages.saturating_add(1);
1900 trace.assistant_messages = trace.assistant_messages.saturating_add(1);
1901 trace.input_tokens = trace.input_tokens.saturating_add(message.usage.input);
1902 trace.output_tokens = trace.output_tokens.saturating_add(message.usage.output);
1903 trace.cache_read_tokens = trace
1904 .cache_read_tokens
1905 .saturating_add(message.usage.cache_read);
1906 trace.cache_write_tokens = trace
1907 .cache_write_tokens
1908 .saturating_add(message.usage.cache_write);
1909 trace.total_tokens = trace
1910 .total_tokens
1911 .saturating_add(message.usage.total_tokens);
1912 }
1913 SessionMessage::ToolResult { .. } | SessionMessage::BashExecution { .. } => {
1914 trace.projected_messages = trace.projected_messages.saturating_add(1);
1915 trace.tool_messages = trace.tool_messages.saturating_add(1);
1916 }
1917 SessionMessage::Custom { display: true, .. } => {
1918 trace.projected_messages = trace.projected_messages.saturating_add(1);
1919 trace.system_messages = trace.system_messages.saturating_add(1);
1920 }
1921 SessionMessage::Custom { display: false, .. }
1922 | SessionMessage::CompactionSummary { .. }
1923 | SessionMessage::BranchSummary { .. } => {}
1924 }
1925 }
1926
1927 trace
1928 }
1929
1930 pub fn open_from_v2(
1932 store: &SessionStoreV2,
1933 header: SessionHeader,
1934 mode: V2OpenMode,
1935 ) -> Result<(Self, SessionOpenDiagnostics)> {
1936 header
1937 .validate()
1938 .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
1939 let (header, normalized_header_dirty) = normalize_loaded_header(header);
1940 let frames = match mode {
1941 V2OpenMode::Full => store.read_all_entries()?,
1942 V2OpenMode::ActivePath => match store.head() {
1943 Some(head) => store.read_active_path(&head.entry_id)?,
1944 None => Vec::new(),
1945 },
1946 V2OpenMode::Tail(count) => store.read_tail_entries(count)?,
1947 };
1948
1949 let mut diagnostics = SessionOpenDiagnostics::default();
1950 let mut entries = Vec::with_capacity(frames.len());
1951 for frame in &frames {
1952 match session_store_v2::frame_to_session_entry(frame) {
1953 Ok(entry) => entries.push(entry),
1954 Err(e) => {
1955 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
1956 line_number: usize::try_from(frame.entry_seq).unwrap_or(0),
1957 error: e.to_string(),
1958 });
1959 }
1960 }
1961 }
1962
1963 let finalized = finalize_loaded_entries(&mut entries);
1964 for orphan in &finalized.orphans {
1965 diagnostics
1966 .orphaned_parent_links
1967 .push(SessionOpenOrphanedParentLink {
1968 entry_id: orphan.0.clone(),
1969 missing_parent_id: orphan.1.clone(),
1970 });
1971 }
1972
1973 let mut v2_message_count_offset = 0;
1974 if matches!(mode, V2OpenMode::Tail(_) | V2OpenMode::ActivePath) {
1975 if let Ok(Some(total)) = total_v2_message_count(store) {
1976 let loaded = finalized.message_count;
1977 v2_message_count_offset = total.saturating_sub(loaded);
1978 }
1979 }
1980
1981 let entry_count = entries.len();
1982 let natural_leaf_id = finalized.leaf_id.clone();
1983 let leaf_id =
1984 resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
1985 Ok((
1986 Self {
1987 header,
1988 entries,
1989 path: None,
1990 leaf_id: leaf_id.clone(),
1991 session_dir: None,
1992 store_kind: SessionStoreKind::Jsonl,
1993 entry_ids: finalized.entry_ids,
1994 is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
1995 entry_index: finalized.entry_index,
1996 cached_message_count: finalized
1997 .message_count
1998 .saturating_add(v2_message_count_offset),
1999 cached_name: finalized.name,
2000 autosave_queue: AutosaveQueue::new(),
2001 autosave_durability: AutosaveDurabilityMode::from_env(),
2002 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
2003 header_dirty: normalized_header_dirty,
2004 appends_since_checkpoint: 0,
2005 v2_sidecar_root: None,
2006 v2_partial_hydration: !matches!(mode, V2OpenMode::Full),
2007 v2_resume_mode: Some(mode),
2008 v2_sidecar_stale: false,
2009 v2_message_count_offset,
2010 },
2011 diagnostics,
2012 ))
2013 }
2014
2015 async fn open_v2_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
2017 let path_buf = path.to_path_buf();
2018 let (tx, mut rx) = oneshot::channel();
2019
2020 let handle = thread::spawn(move || {
2021 let res = crate::session::open_from_v2_store_blocking(path_buf);
2022 let cx = AgentCx::for_request();
2023 let _ = tx.send(cx.cx(), res);
2024 });
2025
2026 let cx = AgentCx::for_request();
2027 let recv_result = rx.recv(cx.cx()).await;
2028 finish_worker_result(handle, recv_result, "V2 open task cancelled")
2029 }
2030
2031 async fn open_jsonl_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
2032 let path_buf = path.to_path_buf();
2033 let (tx, mut rx) = oneshot::channel();
2034
2035 let handle = thread::spawn(move || {
2036 let res = open_jsonl_blocking(path_buf);
2037 let cx = AgentCx::for_request();
2038 let _ = tx.send(cx.cx(), res);
2039 });
2040
2041 let cx = AgentCx::for_request();
2042 let recv_result = rx.recv(cx.cx()).await;
2043 finish_worker_result(handle, recv_result, "Open task cancelled")
2044 }
2045
2046 #[cfg(feature = "sqlite-sessions")]
2047 async fn open_sqlite(path: &Path) -> Result<Self> {
2048 let (header, mut entries) = crate::session_sqlite::load_session(path).await?;
2049 let (header, normalized_header_dirty) = normalize_loaded_header(header);
2050 let finalized = finalize_loaded_entries(&mut entries);
2051 let entry_count = entries.len();
2052 let natural_leaf_id = finalized.leaf_id.clone();
2053 let leaf_id =
2054 resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
2055
2056 Ok(Self {
2057 header,
2058 entries,
2059 path: Some(path.to_path_buf()),
2060 leaf_id: leaf_id.clone(),
2061 session_dir: None,
2062 store_kind: SessionStoreKind::Sqlite,
2063 entry_ids: finalized.entry_ids,
2064 is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
2065 entry_index: finalized.entry_index,
2066 cached_message_count: finalized.message_count,
2067 cached_name: finalized.name,
2068 autosave_queue: AutosaveQueue::new(),
2069 autosave_durability: AutosaveDurabilityMode::from_env(),
2070 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
2071 header_dirty: normalized_header_dirty,
2072 appends_since_checkpoint: 0,
2073 v2_sidecar_root: None,
2074 v2_partial_hydration: false,
2075 v2_resume_mode: None,
2076 v2_sidecar_stale: false,
2077 v2_message_count_offset: 0,
2078 })
2079 }
2080
2081 pub async fn continue_recent_in_dir(
2083 override_dir: Option<&Path>,
2084 config: &Config,
2085 ) -> Result<Self> {
2086 let store_kind = SessionStoreKind::from_config(config);
2087 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
2088 let cwd = std::env::current_dir()?;
2089 let cwd_display = cwd.display().to_string();
2090 let encoded_cwd = encode_cwd(&cwd);
2091 let project_session_dir = base_dir.join(&encoded_cwd);
2092 let project_session_dir_missing = indexed_session_path_is_missing(&project_session_dir);
2093
2094 let base_dir_clone = base_dir.clone();
2096 let cwd_display_clone = cwd_display.clone();
2097 let (tx, mut rx) = oneshot::channel();
2098
2099 let handle = thread::spawn(move || {
2100 let index = SessionIndex::for_sessions_root(&base_dir_clone);
2101 let mut indexed_sessions = index
2102 .list_sessions(Some(&cwd_display_clone))
2103 .unwrap_or_default();
2104
2105 if indexed_sessions.is_empty() && index.reindex_all().is_ok() {
2106 indexed_sessions = index
2107 .list_sessions(Some(&cwd_display_clone))
2108 .unwrap_or_default();
2109 }
2110 let cx = AgentCx::for_request();
2111 let _ = tx.send(cx.cx(), Ok(indexed_sessions));
2112 });
2113
2114 let cx = AgentCx::for_request();
2115 let recv_result = rx.recv(cx.cx()).await;
2116 let indexed_meta =
2117 finish_worker_result(handle, recv_result, "Recent session index task cancelled")
2118 .unwrap_or_default();
2119
2120 let index = SessionIndex::for_sessions_root(&base_dir);
2121 let (indexed_sessions, missing_paths) = split_indexed_session_entries(indexed_meta);
2122 for path in &missing_paths {
2123 prune_session_index_path(
2124 &index,
2125 path,
2126 "Failed to prune missing session from index during recent-session refresh",
2127 );
2128 }
2129
2130 if project_session_dir_missing {
2131 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
2132 }
2133
2134 let scanned = scan_sessions_on_disk(&project_session_dir, indexed_sessions.clone()).await?;
2135
2136 let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
2137 for entry in indexed_sessions {
2138 by_path.insert(entry.path.clone(), entry);
2139 }
2140 for path in &scanned.failed_paths {
2141 prune_session_index_path(
2142 &index,
2143 path,
2144 "Failed to prune unreadable session from index during recent-session refresh",
2145 );
2146 by_path.remove(path);
2147 }
2148 refresh_session_index_entries(
2149 &index,
2150 &scanned.refreshed_entries,
2151 "Failed to refresh session metadata in index during recent-session refresh",
2152 );
2153 merge_scanned_session_entries(&mut by_path, scanned.entries);
2154
2155 let mut candidates = by_path.into_values().collect::<Vec<_>>();
2156 candidates.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
2157
2158 for entry in &candidates {
2159 match Self::open(entry.path.to_string_lossy().as_ref()).await {
2160 Ok(mut session) => {
2161 session.session_dir = Some(base_dir.clone());
2162 return Ok(session);
2163 }
2164 Err(err) => {
2165 tracing::warn!(
2166 path = %entry.path.display(),
2167 error = %err,
2168 "Skipping unreadable session candidate while continuing"
2169 );
2170 prune_session_index_path(
2171 &index,
2172 &entry.path,
2173 "Failed to prune unreadable session after resume candidate open failure",
2174 );
2175 }
2176 }
2177 }
2178
2179 Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind))
2180 }
2181
2182 pub async fn save(&mut self) -> Result<()> {
2184 let ticket = self
2185 .autosave_queue
2186 .begin_flush(AutosaveFlushTrigger::Manual);
2187 let result = self.save_inner().await;
2188 if let Some(ticket) = ticket {
2189 self.autosave_queue.finish_flush(ticket, result.is_ok());
2190 }
2191 result
2192 }
2193
2194 pub async fn flush_autosave(&mut self, trigger: AutosaveFlushTrigger) -> Result<()> {
2200 let Some(ticket) = self.autosave_queue.begin_flush(trigger) else {
2201 return Ok(());
2202 };
2203 let result = self.save_inner().await;
2204 self.autosave_queue.finish_flush(ticket, result.is_ok());
2205 result
2206 }
2207
2208 pub async fn flush_autosave_on_shutdown(&mut self) -> Result<()> {
2210 if !self.autosave_durability.should_flush_on_shutdown() {
2211 return Ok(());
2212 }
2213 let result = self.flush_autosave(AutosaveFlushTrigger::Shutdown).await;
2214 if result.is_err() && self.autosave_durability.best_effort_on_shutdown() {
2215 if let Err(err) = &result {
2216 tracing::warn!(error = %err, "best-effort autosave flush failed during shutdown");
2217 }
2218 return Ok(());
2219 }
2220 result
2221 }
2222
2223 pub const fn autosave_metrics(&self) -> AutosaveQueueMetrics {
2225 self.autosave_queue.metrics()
2226 }
2227
2228 pub const fn autosave_durability_mode(&self) -> AutosaveDurabilityMode {
2229 self.autosave_durability
2230 }
2231
2232 pub const fn set_autosave_durability_mode(&mut self, mode: AutosaveDurabilityMode) {
2233 self.autosave_durability = mode;
2234 }
2235
2236 #[cfg(test)]
2237 fn set_autosave_queue_limit_for_test(&mut self, max_pending_mutations: usize) {
2238 self.autosave_queue = AutosaveQueue::with_limit(max_pending_mutations);
2239 }
2240
2241 #[cfg(test)]
2242 const fn set_autosave_durability_for_test(&mut self, mode: AutosaveDurabilityMode) {
2243 self.autosave_durability = mode;
2244 }
2245
2246 fn ensure_full_v2_hydration_before_save(&mut self) -> Result<()> {
2252 if !self.v2_partial_hydration {
2253 return Ok(());
2254 }
2255
2256 let Some(v2_root) = self.v2_sidecar_root.clone() else {
2257 tracing::warn!(
2258 "session marked as partially hydrated from V2 but sidecar root is unavailable; disabling partial flag"
2259 );
2260 self.v2_partial_hydration = false;
2261 return Ok(());
2262 };
2263
2264 let pending_start = self
2265 .persisted_entry_count
2266 .load(Ordering::SeqCst)
2267 .min(self.entries.len());
2268 let previous_mode = self.v2_resume_mode;
2269
2270 let use_jsonl_rehydration = self
2271 .path
2272 .as_ref()
2273 .is_some_and(|path| self.v2_sidecar_stale || is_v2_sidecar_stale(path, &v2_root));
2274 let (fully_hydrated, diagnostics, rehydration_source) = if use_jsonl_rehydration {
2275 let path = self.path.clone().ok_or_else(|| {
2276 Error::session("missing JSONL path while rehydrating stale V2 session")
2277 })?;
2278 let (session, diagnostics) = open_jsonl_blocking(path)?;
2279 (session, diagnostics, "jsonl")
2280 } else {
2281 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
2282 let (session, diagnostics) =
2283 Self::open_from_v2(&store, self.header.clone(), V2OpenMode::Full)?;
2284 (session, diagnostics, "v2")
2285 };
2286 if !diagnostics.skipped_entries.is_empty() || !diagnostics.orphaned_parent_links.is_empty()
2287 {
2288 tracing::error!(
2289 skipped_entries = diagnostics.skipped_entries.len(),
2290 orphaned_parent_links = diagnostics.orphaned_parent_links.len(),
2291 rehydration_source,
2292 "full V2 rehydration before save failed integrity check; aborting save to prevent data loss"
2293 );
2294 return Err(Error::session(format!(
2295 "V2 rehydration failed with {} skipped entries and {} orphaned links",
2296 diagnostics.skipped_entries.len(),
2297 diagnostics.orphaned_parent_links.len()
2298 )));
2299 }
2300
2301 let pending_entries = if pending_start >= self.entries.len() {
2305 Vec::new()
2306 } else {
2307 self.entries.split_off(pending_start)
2308 };
2309
2310 let persisted_entry_count = fully_hydrated.entries.len();
2311 let mut merged_entries = fully_hydrated.entries;
2312 merged_entries.extend(pending_entries);
2313
2314 let finalized = finalize_loaded_entries(&mut merged_entries);
2315 self.entries = merged_entries;
2316 self.leaf_id = finalized.leaf_id;
2317 self.entry_ids = finalized.entry_ids;
2318 self.is_linear = finalized.is_linear;
2319 self.entry_index = finalized.entry_index;
2320 self.cached_message_count = finalized.message_count;
2321 self.cached_name = finalized.name;
2322 self.persisted_entry_count
2323 .store(persisted_entry_count, Ordering::SeqCst);
2324 self.v2_partial_hydration = false;
2325 self.v2_resume_mode = Some(V2OpenMode::Full);
2326 self.v2_sidecar_stale = false;
2327 self.v2_message_count_offset = 0;
2328
2329 tracing::debug!(
2330 previous_mode = ?previous_mode,
2331 rehydration_source,
2332 persisted_entry_count,
2333 pending_entries = self.entries.len().saturating_sub(persisted_entry_count),
2334 "fully rehydrated V2 session before save"
2335 );
2336
2337 Ok(())
2338 }
2339
2340 fn should_full_rewrite(&self) -> bool {
2342 let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
2343
2344 if persisted_count == 0 {
2346 return true;
2347 }
2348 if self
2351 .path
2352 .as_ref()
2353 .is_some_and(|path| path.try_exists().is_ok_and(|exists| !exists))
2354 {
2355 return true;
2356 }
2357 if self.header_dirty {
2359 return true;
2360 }
2361 if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
2363 return true;
2364 }
2365 if persisted_count > self.entries.len() {
2367 return true;
2368 }
2369 false
2370 }
2371
2372 #[allow(clippy::too_many_lines)]
2374 async fn save_inner(&mut self) -> Result<()> {
2375 self.ensure_entry_ids();
2376
2377 let store_kind = match self
2378 .path
2379 .as_ref()
2380 .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
2381 {
2382 Some("jsonl") => SessionStoreKind::Jsonl,
2383 Some("sqlite") => {
2384 #[cfg(feature = "sqlite-sessions")]
2385 {
2386 SessionStoreKind::Sqlite
2387 }
2388
2389 #[cfg(not(feature = "sqlite-sessions"))]
2390 {
2391 return Err(Error::session(
2392 "SQLite session files require building with `--features sqlite-sessions`",
2393 ));
2394 }
2395 }
2396 _ => self.store_kind,
2397 };
2398
2399 if self.path.is_none() {
2400 let base_dir = self
2402 .session_dir
2403 .clone()
2404 .unwrap_or_else(Config::sessions_dir);
2405 let cwd = if self.header.cwd.trim().is_empty() {
2406 std::env::current_dir()?
2407 } else {
2408 let configured_cwd = PathBuf::from(self.header.cwd.trim());
2409 if configured_cwd.is_absolute() {
2410 configured_cwd
2411 } else {
2412 std::env::current_dir()?.join(configured_cwd)
2413 }
2414 };
2415 let encoded_cwd = encode_cwd(&cwd);
2416 let project_session_dir = base_dir.join(&encoded_cwd);
2417
2418 asupersync::fs::create_dir_all(&project_session_dir).await?;
2419
2420 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ");
2421 let short_id = {
2423 let prefix: String = self
2424 .header
2425 .id
2426 .chars()
2427 .take(8)
2428 .map(|ch| {
2429 if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') {
2430 ch
2431 } else {
2432 '_'
2433 }
2434 })
2435 .collect();
2436 if prefix.trim_matches('_').is_empty() {
2437 "session".to_string()
2438 } else {
2439 prefix
2440 }
2441 };
2442 let filename = format!("{}_{}.{}", timestamp, short_id, store_kind.extension());
2443 self.path = Some(project_session_dir.join(filename));
2444 }
2445
2446 if self.header.id.trim().is_empty() {
2449 self.header.id = uuid::Uuid::new_v4().to_string();
2450 self.header_dirty = true;
2451 }
2452 let desired_leaf_override = self.persisted_leaf_override();
2453 if !self.header.current_leaf.eq(&desired_leaf_override) {
2454 self.header.current_leaf = desired_leaf_override;
2455 self.header_dirty = true;
2456 }
2457 self.header
2458 .validate()
2459 .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
2460
2461 let session_dir_clone = self.session_dir.clone();
2462 let path = self.path.clone().ok_or_else(|| {
2463 Error::session("Session path not set - cannot save session".to_string())
2464 })?;
2465 let path_clone = path.clone();
2466
2467 match store_kind {
2468 SessionStoreKind::Jsonl => {
2469 let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
2470
2471 if self.should_full_rewrite() {
2472 if self.v2_partial_hydration {
2473 self.ensure_full_v2_hydration_before_save()?;
2474 }
2475 let header_snapshot = self.header.clone();
2478 let entries_to_save = self.entries.clone();
2479 let persisted_entry_count = self.persisted_entry_count.load(Ordering::SeqCst);
2480 let header_dirty = self.header_dirty;
2481 let path_for_task = path_clone.clone();
2482 let sessions_root_for_task = sessions_root.clone();
2483 let (saved_header, saved_entries) =
2484 asupersync::runtime::spawn_blocking(move || {
2485 save_jsonl_full_rewrite_blocking(
2486 &path_for_task,
2487 &sessions_root_for_task,
2488 &header_snapshot,
2489 &entries_to_save,
2490 persisted_entry_count,
2491 header_dirty,
2492 )
2493 })
2494 .await?;
2495
2496 let previous_leaf = self.leaf_id.clone();
2497 self.header = saved_header;
2498 self.entries = saved_entries;
2499 let finalized = finalize_loaded_entries(&mut self.entries);
2500 self.entry_ids = finalized.entry_ids;
2501 self.entry_index = finalized.entry_index;
2502 self.cached_message_count = finalized
2503 .message_count
2504 .saturating_add(self.v2_message_count_offset);
2505 self.cached_name = finalized.name;
2506 self.leaf_id = previous_leaf
2507 .filter(|id| self.entry_index.contains_key(id))
2508 .or_else(|| finalized.leaf_id.clone());
2509 self.is_linear = finalized.is_linear && self.leaf_id.eq(&finalized.leaf_id);
2510 self.persisted_entry_count
2511 .store(self.entries.len(), Ordering::SeqCst);
2512 self.header_dirty = false;
2513 self.appends_since_checkpoint = 0;
2514 self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
2515 } else {
2516 let message_count = self.cached_message_count;
2517 let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2519 if new_start < self.entries.len() {
2520 let session_name = self.cached_name.clone();
2521 let new_entries = &self.entries[new_start..];
2523 let estimated_entry_bytes = asupersync::fs::metadata(&path_clone)
2526 .await
2527 .ok()
2528 .and_then(|meta| usize::try_from(meta.len()).ok())
2529 .map_or(512, |file_bytes| {
2530 let avg = file_bytes / new_start.max(1);
2531 avg.clamp(512, 256 * 1024)
2532 });
2533 let mut serialized_buf = Vec::with_capacity(
2534 new_entries
2535 .len()
2536 .saturating_mul(estimated_entry_bytes.saturating_add(1)),
2537 );
2538 for entry in new_entries {
2539 serde_json::to_writer(&mut serialized_buf, entry)?;
2540 serialized_buf.push(b'\n');
2541 }
2542 let new_count = self.entries.len();
2543
2544 let header_snapshot = self.header.clone();
2545 let path_for_task = path_clone.clone();
2546 let sessions_root_for_task = sessions_root.clone();
2547 asupersync::runtime::spawn_blocking(move || {
2548 append_jsonl_entries_blocking(
2549 &path_for_task,
2550 &sessions_root_for_task,
2551 &header_snapshot,
2552 &serialized_buf,
2553 message_count,
2554 session_name,
2555 )
2556 })
2557 .await?;
2558
2559 self.persisted_entry_count
2560 .store(new_count, Ordering::SeqCst);
2561 self.appends_since_checkpoint += 1;
2562 self.v2_sidecar_stale = self.v2_sidecar_root.is_some();
2563 }
2564 }
2566 }
2567 #[cfg(feature = "sqlite-sessions")]
2568 SessionStoreKind::Sqlite => {
2569 let message_count = self.cached_message_count;
2570 let session_name = self.cached_name.clone();
2571
2572 if self.should_full_rewrite() {
2573 crate::session_sqlite::save_session(&path_clone, &self.header, &self.entries)
2575 .await?;
2576 self.persisted_entry_count
2577 .store(self.entries.len(), Ordering::SeqCst);
2578 self.header_dirty = false;
2579 self.appends_since_checkpoint = 0;
2580 } else {
2581 let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
2583 if new_start < self.entries.len() {
2584 crate::session_sqlite::append_entries(
2585 &path_clone,
2586 &self.entries[new_start..],
2587 new_start,
2588 message_count,
2589 session_name.as_deref(),
2590 )
2591 .await?;
2592 self.persisted_entry_count
2593 .store(self.entries.len(), Ordering::SeqCst);
2594 self.appends_since_checkpoint += 1;
2595 }
2596 }
2598
2599 let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
2600 enqueue_session_index_snapshot_update(
2601 &sessions_root,
2602 &path_clone,
2603 &self.header,
2604 message_count,
2605 session_name,
2606 );
2607 }
2608 }
2609 Ok(())
2610 }
2611
2612 const fn enqueue_autosave_mutation(&mut self, kind: AutosaveMutationKind) {
2613 self.autosave_queue.enqueue_mutation(kind);
2614 }
2615
2616 fn latest_model_change_for_current_path(&self) -> Option<(String, String)> {
2617 for entry in self.entries_for_current_path().iter().rev() {
2618 if let SessionEntry::ModelChange(change) = entry {
2619 return Some((change.provider.clone(), change.model_id.clone()));
2620 }
2621 }
2622 None
2623 }
2624
2625 fn latest_thinking_level_for_current_path(&self) -> Option<String> {
2626 for entry in self.entries_for_current_path().iter().rev() {
2627 if let SessionEntry::ThinkingLevelChange(change) = entry {
2628 return Some(change.thinking_level.clone());
2629 }
2630 }
2631 None
2632 }
2633
2634 pub fn effective_model_for_current_path(&self) -> Option<(String, String)> {
2635 if let Some(model) = self.latest_model_change_for_current_path() {
2637 return Some(model);
2638 }
2639
2640 if self.has_any_model_change() {
2643 return self
2644 .header
2645 .fallback_provider
2646 .clone()
2647 .zip(self.header.fallback_model_id.clone());
2648 }
2649
2650 self.header
2651 .provider
2652 .clone()
2653 .zip(self.header.model_id.clone())
2654 }
2655
2656 pub fn effective_thinking_level_for_current_path(&self) -> Option<String> {
2657 if let Some(level) = self.latest_thinking_level_for_current_path() {
2659 return Some(level);
2660 }
2661
2662 if self.has_any_thinking_level_change() {
2665 return self.header.fallback_thinking_level.clone();
2666 }
2667
2668 self.header.thinking_level.clone()
2669 }
2670
2671 fn has_any_model_change(&self) -> bool {
2672 self.entries
2673 .iter()
2674 .any(|entry| matches!(entry, SessionEntry::ModelChange(_)))
2675 }
2676
2677 fn has_any_thinking_level_change(&self) -> bool {
2678 self.entries
2679 .iter()
2680 .any(|entry| matches!(entry, SessionEntry::ThinkingLevelChange(_)))
2681 }
2682
2683 fn persisted_leaf_override(&self) -> Option<String> {
2684 if self.entries.is_empty() {
2685 return None;
2686 }
2687
2688 match (
2689 self.leaf_id.as_deref(),
2690 self.entries
2691 .last()
2692 .and_then(SessionEntry::base_id)
2693 .map(String::as_str),
2694 ) {
2695 (None, _) => Some(ROOT_LEAF_OVERRIDE_SENTINEL.to_string()),
2696 (Some(current), Some(natural_tip)) if current.eq(natural_tip) => None,
2697 (Some(current), _) => Some(current.to_string()),
2698 }
2699 }
2700
2701 fn sync_navigation_state_to_header(&mut self) {
2702 let mut changed = false;
2703
2704 let desired_leaf_override = self.persisted_leaf_override();
2705 if !self.header.current_leaf.eq(&desired_leaf_override) {
2706 self.header.current_leaf = desired_leaf_override;
2707 changed = true;
2708 }
2709
2710 if let Some((provider, model_id)) = self.effective_model_for_current_path() {
2711 if !self
2712 .header
2713 .provider
2714 .as_deref()
2715 .is_some_and(|current| current.eq(provider.as_str()))
2716 || !self
2717 .header
2718 .model_id
2719 .as_deref()
2720 .is_some_and(|current| current.eq(model_id.as_str()))
2721 {
2722 self.header.provider = Some(provider);
2723 self.header.model_id = Some(model_id);
2724 changed = true;
2725 }
2726 } else if self.has_any_model_change()
2727 && (self.header.provider.is_some() || self.header.model_id.is_some())
2728 {
2729 self.header.provider = None;
2730 self.header.model_id = None;
2731 changed = true;
2732 }
2733
2734 if let Some(thinking_level) = self.effective_thinking_level_for_current_path() {
2735 if !self
2736 .header
2737 .thinking_level
2738 .as_deref()
2739 .is_some_and(|current| current.eq(thinking_level.as_str()))
2740 {
2741 self.header.thinking_level = Some(thinking_level);
2742 changed = true;
2743 }
2744 } else if self.has_any_thinking_level_change() && self.header.thinking_level.is_some() {
2745 self.header.thinking_level = None;
2746 changed = true;
2747 }
2748
2749 if changed {
2750 self.header_dirty = true;
2751 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2752 }
2753 }
2754
2755 fn clear_persisted_leaf_override_after_append(&mut self) {
2756 let desired_leaf_override = self.persisted_leaf_override();
2757 if !self.header.current_leaf.eq(&desired_leaf_override) {
2758 self.header.current_leaf = desired_leaf_override;
2759 self.header_dirty = true;
2760 }
2761 }
2762
2763 pub fn append_message(&mut self, message: SessionMessage) -> String {
2765 let id = self.next_entry_id();
2766 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2767 let entry = SessionEntry::Message(MessageEntry { base, message });
2768 self.leaf_id = Some(id.clone());
2769 self.entries.push(entry);
2770 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2771 self.entry_ids.insert(id.clone());
2772 self.cached_message_count += 1;
2773 self.clear_persisted_leaf_override_after_append();
2774 self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2775 id
2776 }
2777
2778 pub fn append_model_message(&mut self, message: Message) -> String {
2780 self.append_message(SessionMessage::from(message))
2781 }
2782
2783 pub fn append_model_change(&mut self, provider: String, model_id: String) -> String {
2784 let id = self.next_entry_id();
2785 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2786 let entry = SessionEntry::ModelChange(ModelChangeEntry {
2787 base,
2788 provider,
2789 model_id,
2790 });
2791 self.leaf_id = Some(id.clone());
2792 self.entries.push(entry);
2793 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2794 self.entry_ids.insert(id.clone());
2795 self.clear_persisted_leaf_override_after_append();
2796 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2797 id
2798 }
2799
2800 pub fn append_thinking_level_change(&mut self, thinking_level: String) -> String {
2801 let id = self.next_entry_id();
2802 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2803 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
2804 base,
2805 thinking_level,
2806 });
2807 self.leaf_id = Some(id.clone());
2808 self.entries.push(entry);
2809 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2810 self.entry_ids.insert(id.clone());
2811 self.clear_persisted_leaf_override_after_append();
2812 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2813 id
2814 }
2815
2816 pub fn append_session_info(&mut self, name: Option<String>) -> String {
2817 let id = self.next_entry_id();
2818 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2819 if name.is_some() {
2820 self.cached_name.clone_from(&name);
2821 }
2822 let entry = SessionEntry::SessionInfo(SessionInfoEntry { base, name });
2823 self.leaf_id = Some(id.clone());
2824 self.entries.push(entry);
2825 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2826 self.entry_ids.insert(id.clone());
2827 self.clear_persisted_leaf_override_after_append();
2828 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2829 id
2830 }
2831
2832 pub fn append_custom_entry(
2834 &mut self,
2835 custom_type: String,
2836 data: Option<serde_json::Value>,
2837 ) -> String {
2838 let id = self.next_entry_id();
2839 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2840 let entry = SessionEntry::Custom(CustomEntry {
2841 base,
2842 custom_type,
2843 data,
2844 });
2845 self.leaf_id = Some(id.clone());
2846 self.entries.push(entry);
2847 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2848 self.entry_ids.insert(id.clone());
2849 self.clear_persisted_leaf_override_after_append();
2850 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2851 id
2852 }
2853
2854 pub fn append_bash_execution(
2855 &mut self,
2856 command: String,
2857 output: String,
2858 exit_code: i32,
2859 cancelled: bool,
2860 truncated: bool,
2861 full_output_path: Option<String>,
2862 ) -> String {
2863 let id = self.next_entry_id();
2864 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2865 let entry = SessionEntry::Message(MessageEntry {
2866 base,
2867 message: SessionMessage::BashExecution {
2868 command,
2869 output,
2870 exit_code,
2871 cancelled: Some(cancelled),
2872 truncated: Some(truncated),
2873 full_output_path,
2874 timestamp: Some(chrono::Utc::now().timestamp_millis()),
2875 extra: HashMap::new(),
2876 },
2877 });
2878 self.leaf_id = Some(id.clone());
2879 self.entries.push(entry);
2880 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2881 self.entry_ids.insert(id.clone());
2882 self.cached_message_count += 1;
2883 self.clear_persisted_leaf_override_after_append();
2884 self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
2885 id
2886 }
2887
2888 pub fn get_name(&self) -> Option<String> {
2890 self.cached_name.clone()
2891 }
2892
2893 pub fn set_name(&mut self, name: &str) -> String {
2895 self.append_session_info(Some(name.to_string()))
2896 }
2897
2898 pub fn append_compaction(
2899 &mut self,
2900 summary: String,
2901 first_kept_entry_id: String,
2902 tokens_before: u64,
2903 details: Option<Value>,
2904 from_hook: Option<bool>,
2905 ) -> String {
2906 let id = self.next_entry_id();
2907 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2908 let entry = SessionEntry::Compaction(CompactionEntry {
2909 base,
2910 summary,
2911 first_kept_entry_id,
2912 tokens_before,
2913 details,
2914 from_hook,
2915 });
2916 self.leaf_id = Some(id.clone());
2917 self.entries.push(entry);
2918 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2919 self.entry_ids.insert(id.clone());
2920 self.clear_persisted_leaf_override_after_append();
2921 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2922 id
2923 }
2924
2925 pub fn append_branch_summary(
2926 &mut self,
2927 from_id: String,
2928 summary: String,
2929 details: Option<Value>,
2930 from_hook: Option<bool>,
2931 ) -> String {
2932 let id = self.next_entry_id();
2933 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2934 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
2935 base,
2936 from_id,
2937 summary,
2938 details,
2939 from_hook,
2940 });
2941 self.leaf_id = Some(id.clone());
2942 self.entries.push(entry);
2943 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2944 self.entry_ids.insert(id.clone());
2945 self.clear_persisted_leaf_override_after_append();
2946 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2947 id
2948 }
2949
2950 pub fn ensure_entry_ids(&mut self) {
2951 self.rebuild_all_caches();
2954 }
2955
2956 fn rebuild_all_caches(&mut self) {
2961 let finalized = finalize_loaded_entries(&mut self.entries);
2962 self.entry_ids = finalized.entry_ids;
2963 self.entry_index = finalized.entry_index;
2964 self.cached_message_count = finalized
2965 .message_count
2966 .saturating_add(self.v2_message_count_offset);
2967 self.cached_name = finalized.name;
2968 self.is_linear = finalized.is_linear && self.leaf_id.eq(&finalized.leaf_id);
2973 }
2974
2975 pub fn to_messages(&self) -> Vec<Message> {
2977 let mut messages = Vec::new();
2978 for entry in &self.entries {
2979 if let SessionEntry::Message(msg_entry) = entry {
2980 if let Some(message) = session_message_to_model(&msg_entry.message) {
2981 messages.push(message);
2982 }
2983 }
2984 }
2985 messages
2986 }
2987
2988 pub fn to_html(&self) -> String {
2994 render_session_html(&self.header, &self.entries)
2995 }
2996
2997 pub fn set_model_header(
2999 &mut self,
3000 provider: Option<String>,
3001 model_id: Option<String>,
3002 thinking_level: Option<String>,
3003 ) {
3004 let changed = provider.is_some() || model_id.is_some() || thinking_level.is_some();
3005 if provider.is_some() {
3006 self.header.provider = provider;
3007 }
3008 if model_id.is_some() {
3009 self.header.model_id = model_id;
3010 }
3011 if thinking_level.is_some() {
3012 self.header.thinking_level = thinking_level;
3013 }
3014 if changed {
3015 self.header_dirty = true;
3016 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
3017 }
3018 }
3019
3020 pub fn set_branched_from(&mut self, path: Option<String>) {
3021 self.header.parent_session = path;
3022 self.header_dirty = true;
3023 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
3024 }
3025
3026 pub fn export_snapshot(&self) -> ExportSnapshot {
3032 ExportSnapshot {
3033 header: self.header.clone(),
3034 entries: self.entries.clone(),
3035 path: self.path.clone(),
3036 }
3037 }
3038
3039 pub fn plan_fork_from_user_message(&self, entry_id: &str) -> Result<ForkPlan> {
3044 let entry = self
3045 .get_entry(entry_id)
3046 .ok_or_else(|| Error::session(format!("Fork target not found: {entry_id}")))?;
3047
3048 let SessionEntry::Message(message_entry) = entry else {
3049 return Err(Error::session(format!(
3050 "Fork target is not a message entry: {entry_id}"
3051 )));
3052 };
3053
3054 let SessionMessage::User { content, .. } = &message_entry.message else {
3055 return Err(Error::session(format!(
3056 "Fork target is not a user message: {entry_id}"
3057 )));
3058 };
3059
3060 let selected_text = user_content_to_text(content);
3061 let leaf_id = message_entry.base.parent_id.clone();
3062
3063 let entries = if let Some(ref leaf_id) = leaf_id {
3064 if self.is_linear {
3065 let idx = self.entry_index.get(leaf_id).copied().ok_or_else(|| {
3066 Error::session(format!("Failed to build fork: missing entry {leaf_id}"))
3067 })?;
3068 self.entries[..=idx].to_vec()
3069 } else {
3070 let path_ids = self.get_path_to_entry(leaf_id);
3071 let mut entries = Vec::new();
3072 for path_id in path_ids {
3073 let entry = self.get_entry(&path_id).ok_or_else(|| {
3074 Error::session(format!("Failed to build fork: missing entry {path_id}"))
3075 })?;
3076 entries.push(entry.clone());
3077 }
3078 entries
3079 }
3080 } else {
3081 Vec::new()
3082 };
3083
3084 Ok(ForkPlan {
3085 entries,
3086 leaf_id,
3087 selected_text,
3088 })
3089 }
3090
3091 fn next_entry_id(&self) -> String {
3092 let use_entry_id_cache = session_entry_id_cache_enabled();
3093
3094 if use_entry_id_cache {
3095 generate_entry_id(&self.entry_ids)
3098 } else {
3099 let existing = entry_id_set(&self.entries);
3102 generate_entry_id(&existing)
3103 }
3104 }
3105
3106 fn build_children_map(&self) -> HashMap<Option<String>, Vec<String>> {
3112 let mut children: HashMap<Option<String>, Vec<String>> =
3113 HashMap::with_capacity(self.entries.len());
3114 for entry in &self.entries {
3115 if let Some(id) = entry.base_id() {
3116 children
3117 .entry(entry.base().parent_id.clone())
3118 .or_default()
3119 .push(id.clone());
3120 }
3121 }
3122 children
3123 }
3124
3125 pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
3128 if self.is_linear {
3130 if let Some(&idx) = self.entry_index.get(entry_id) {
3131 let mut path = Vec::with_capacity(idx + 1);
3132 for entry in &self.entries[..=idx] {
3133 if let Some(id) = entry.base_id() {
3134 path.push(id.clone());
3135 }
3136 }
3137 return path;
3138 }
3139 }
3140
3141 let mut path = Vec::new();
3142 let mut visited = std::collections::HashSet::with_capacity(self.entries.len().min(128));
3143 let mut current = Some(entry_id.to_string());
3144
3145 while let Some(id) = current {
3146 if !visited.insert(id.clone()) {
3147 tracing::warn!(
3148 "Cycle detected in session tree while building ancestor path at entry: {id}"
3149 );
3150 break;
3151 }
3152 path.push(id.clone());
3153 current = self
3154 .get_entry(&id)
3155 .and_then(|entry| entry.base().parent_id.clone());
3156 }
3157
3158 path.reverse();
3159 path
3160 }
3161
3162 pub fn get_children(&self, entry_id: Option<&str>) -> Vec<String> {
3164 self.entries
3165 .iter()
3166 .filter_map(|entry| {
3167 let id = entry.base_id()?;
3168 if entry.base().parent_id.as_deref().eq(&entry_id) {
3169 Some(id.clone())
3170 } else {
3171 None
3172 }
3173 })
3174 .collect()
3175 }
3176
3177 pub fn list_leaves(&self) -> Vec<String> {
3179 let mut has_children: HashSet<&str> = HashSet::with_capacity(self.entries.len());
3180 for entry in &self.entries {
3181 if let Some(parent_id) = entry.base().parent_id.as_deref() {
3182 has_children.insert(parent_id);
3183 }
3184 }
3185
3186 self.entries
3187 .iter()
3188 .filter_map(|e| {
3189 let id = e.base_id()?;
3190 if has_children.contains(id.as_str()) {
3191 None
3192 } else {
3193 Some(id.clone())
3194 }
3195 })
3196 .collect()
3197 }
3198
3199 pub fn navigate_to(&mut self, entry_id: &str) -> bool {
3202 let exists = self.entry_index.contains_key(entry_id);
3204 if exists {
3205 let is_tip = self
3207 .entries
3208 .last()
3209 .and_then(|e| e.base_id())
3210 .is_some_and(|id| id.eq(entry_id));
3211 if !is_tip {
3212 self.is_linear = false;
3213 }
3214 self.leaf_id = Some(entry_id.to_string());
3215 self.sync_header_to_current_path_metadata();
3216 true
3217 } else {
3218 false
3219 }
3220 }
3221
3222 pub fn leaf_id(&self) -> Option<&str> {
3224 self.leaf_id.as_deref()
3225 }
3226
3227 pub fn init_from_fork_plan(&mut self, plan: ForkPlan) {
3232 self.entries = plan.entries;
3233 self.leaf_id = plan.leaf_id;
3234 self.rebuild_all_caches();
3235 self.sync_navigation_state_to_header();
3236 }
3237
3238 pub fn _test_set_leaf_id(&mut self, id: Option<String>) {
3240 self.leaf_id = id;
3241 self.rebuild_all_caches();
3242 self.sync_navigation_state_to_header();
3243 }
3244
3245 fn sync_header_to_current_path_metadata(&mut self) {
3246 self.sync_navigation_state_to_header();
3247 }
3248
3249 pub fn revert_last_user_message(&mut self) -> bool {
3252 let mut current_id = self.leaf_id.clone();
3253 let mut reverted_any = false;
3254
3255 while let Some(id) = current_id {
3256 if let Some(entry) = self.get_entry(&id) {
3257 let parent_id = entry.base().parent_id.clone();
3258 let is_user = if let SessionEntry::Message(msg_entry) = entry {
3259 matches!(msg_entry.message, SessionMessage::User { .. })
3260 } else {
3261 false
3262 };
3263
3264 self.leaf_id.clone_from(&parent_id);
3265 self.is_linear = false;
3266 reverted_any = true;
3267
3268 if is_user {
3269 break;
3271 }
3272
3273 current_id = parent_id;
3274 } else {
3275 break;
3276 }
3277 }
3278 if reverted_any {
3279 self.sync_navigation_state_to_header();
3280 }
3281 reverted_any
3282 }
3283
3284 pub fn reset_leaf(&mut self) {
3290 self.leaf_id = None;
3291 self.is_linear = false;
3292 self.sync_navigation_state_to_header();
3293 }
3294
3295 pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
3299 self.navigate_to(entry_id)
3300 }
3301
3302 pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
3304 self.entry_index
3305 .get(entry_id)
3306 .and_then(|&idx| self.entries.get(idx))
3307 }
3308
3309 pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
3311 self.entry_index
3312 .get(entry_id)
3313 .copied()
3314 .and_then(|idx| self.entries.get_mut(idx))
3315 }
3316
3317 pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
3323 let Some(leaf_id) = &self.leaf_id else {
3324 return Vec::new();
3325 };
3326
3327 if self.is_linear {
3329 return self.entries.iter().collect();
3330 }
3331
3332 let mut path_indices = Vec::with_capacity(16);
3333 let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3334 let mut current = Some(leaf_id.clone());
3335
3336 while let Some(id) = current.as_ref() {
3337 if !visited.insert(id.clone()) {
3338 tracing::warn!(
3339 "Cycle detected in session tree while collecting current path entries at: {id}"
3340 );
3341 break;
3342 }
3343 let Some(&idx) = self.entry_index.get(id.as_str()) else {
3344 break;
3345 };
3346 let Some(entry) = self.entries.get(idx) else {
3347 break;
3348 };
3349 path_indices.push(idx);
3350 current.clone_from(&entry.base().parent_id);
3351 }
3352
3353 path_indices.reverse();
3354 path_indices
3355 .into_iter()
3356 .filter_map(|idx| self.entries.get(idx))
3357 .collect()
3358 }
3359
3360 pub fn to_messages_for_current_path(&self) -> Vec<Message> {
3363 if self.leaf_id.is_none() {
3364 return Vec::new();
3365 }
3366
3367 if self.is_linear {
3368 return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
3369 }
3370
3371 let path_entries = self.entries_for_current_path();
3372 Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
3373 }
3374
3375 fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
3376 match entry {
3377 SessionEntry::Message(msg_entry) => {
3378 if let Some(message) = session_message_to_model(&msg_entry.message) {
3379 messages.push(message);
3380 }
3381 }
3382 SessionEntry::BranchSummary(summary) => {
3383 let summary_message = SessionMessage::BranchSummary {
3384 summary: summary.summary.clone(),
3385 from_id: summary.from_id.clone(),
3386 };
3387 if let Some(message) = session_message_to_model(&summary_message) {
3388 messages.push(message);
3389 }
3390 }
3391 _ => {}
3392 }
3393 }
3394
3395 fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
3396 where
3397 F: Fn(usize) -> &'a SessionEntry,
3398 {
3399 let mut last_compaction = None;
3400 for idx in (0..path_len).rev() {
3401 if let SessionEntry::Compaction(compaction) = entry_at(idx) {
3402 last_compaction = Some((idx, compaction));
3403 break;
3404 }
3405 }
3406
3407 if let Some((compaction_idx, compaction)) = last_compaction {
3408 let mut messages = Vec::with_capacity(path_len);
3409 let summary_message = SessionMessage::CompactionSummary {
3410 summary: compaction.summary.clone(),
3411 tokens_before: compaction.tokens_before,
3412 };
3413 if let Some(message) = session_message_to_model(&summary_message) {
3414 messages.push(message);
3415 }
3416
3417 let has_kept_entry = (0..path_len).any(|idx| {
3418 entry_at(idx)
3419 .base_id()
3420 .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3421 });
3422
3423 let mut keep = false;
3424 let mut past_compaction = false;
3425 for idx in 0..path_len {
3426 let entry = entry_at(idx);
3427 if idx.eq(&compaction_idx) {
3428 past_compaction = true;
3429 }
3430 if !keep {
3431 if has_kept_entry {
3432 if entry
3433 .base_id()
3434 .is_some_and(|id| id.eq(&compaction.first_kept_entry_id))
3435 {
3436 keep = true;
3437 } else {
3438 continue;
3439 }
3440 } else if past_compaction {
3441 tracing::warn!(
3442 first_kept_entry_id = %compaction.first_kept_entry_id,
3443 "Compaction references missing entry; including all post-compaction entries"
3444 );
3445 keep = true;
3446 } else {
3447 continue;
3448 }
3449 }
3450 Self::append_model_message_for_entry(&mut messages, entry);
3451 }
3452
3453 return messages;
3454 }
3455
3456 let mut messages = Vec::with_capacity(path_len);
3457 for idx in 0..path_len {
3458 Self::append_model_message_for_entry(&mut messages, entry_at(idx));
3459 }
3460 messages
3461 }
3462
3463 pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
3471 let children_map = self.build_children_map();
3472 let leaf_id = self.leaf_id.as_ref()?;
3473 let path = self.get_path_to_entry(leaf_id);
3474 if path.is_empty() {
3475 return None;
3476 }
3477
3478 for (idx, entry_id) in path.iter().enumerate().rev() {
3483 let parent_of_entry = self
3484 .get_entry(entry_id)
3485 .and_then(|e| e.base().parent_id.clone());
3486
3487 let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
3488 continue;
3489 };
3490
3491 if siblings_at_parent.len() > 1 {
3492 let mut branches = Vec::new();
3494 let current_branch_ids: HashSet<&str> =
3495 path[idx..].iter().map(String::as_str).collect();
3496 for sibling_root in siblings_at_parent {
3497 let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
3498 let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
3499 let is_current = current_branch_ids.contains(sibling_root.as_str());
3500 branches.push(SiblingBranch {
3501 root_id: sibling_root.clone(),
3502 leaf_id: leaf,
3503 preview,
3504 message_count: msg_count,
3505 is_current,
3506 });
3507 }
3508 return Some((parent_of_entry, branches));
3509 }
3510 }
3511
3512 None
3513 }
3514
3515 fn deepest_leaf_from(
3517 children_map: &HashMap<Option<String>, Vec<String>>,
3518 start_id: &str,
3519 ) -> String {
3520 let mut current = start_id.to_string();
3521 let mut visited = HashSet::new();
3522 loop {
3523 if !visited.insert(current.clone()) {
3524 tracing::warn!("Cycle detected in session tree at entry: {current}");
3525 return current;
3526 }
3527 let children = children_map.get(&Some(current.clone()));
3528 match children.and_then(|c| c.first()) {
3529 Some(child) => current.clone_from(child),
3530 None => return current,
3531 }
3532 }
3533 }
3534
3535 fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
3538 let mut visited = HashSet::with_capacity(self.entries.len().min(128));
3539 let mut current = Some(leaf_id.to_string());
3540 let mut preview = None;
3541 let mut count = 0usize;
3542
3543 while let Some(id) = current.as_ref() {
3544 if !visited.insert(id.clone()) {
3545 tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
3546 break;
3547 }
3548 let Some(entry) = self.get_entry(id.as_str()) else {
3549 break;
3550 };
3551 if matches!(entry, SessionEntry::Message(_)) {
3552 count = count.saturating_add(1);
3553 }
3554 if let SessionEntry::Message(msg) = entry {
3555 if let SessionMessage::User { content, .. } = &msg.message {
3556 let text = user_content_to_text(content);
3557 let trimmed = text.trim();
3558 if !trimmed.is_empty() {
3559 preview = Some(if trimmed.chars().count() > 60 {
3560 let truncated: String = trimmed.chars().take(57).collect();
3561 format!("{truncated}...")
3562 } else {
3563 trimmed.to_string()
3564 });
3565 }
3566 }
3567 }
3568 current.clone_from(&entry.base().parent_id);
3569 }
3570
3571 (preview.unwrap_or_else(|| String::from("(empty)")), count)
3572 }
3573
3574 pub fn branch_summary(&self) -> BranchInfo {
3576 let leaves = self.list_leaves();
3577 let children_map = self.build_children_map();
3578
3579 let branch_points: Vec<String> = self
3581 .entries
3582 .iter()
3583 .filter_map(|e| {
3584 let id = e.base_id()?;
3585 let children = children_map.get(&Some(id.clone()))?;
3586 if children.len() > 1 {
3587 Some(id.clone())
3588 } else {
3589 None
3590 }
3591 })
3592 .collect();
3593
3594 BranchInfo {
3595 total_entries: self.entries.len(),
3596 leaf_count: leaves.len(),
3597 branch_point_count: branch_points.len(),
3598 current_leaf: self.leaf_id.clone(),
3599 leaves,
3600 branch_points,
3601 }
3602 }
3603
3604 pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
3606 self.get_entry(target_id)?;
3608
3609 let id = self.next_entry_id();
3610 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
3611 let entry = SessionEntry::Label(LabelEntry {
3612 base,
3613 target_id: target_id.to_string(),
3614 label,
3615 });
3616 self.leaf_id = Some(id.clone());
3617 self.entries.push(entry);
3618 self.entry_index.insert(id.clone(), self.entries.len() - 1);
3619 self.entry_ids.insert(id.clone());
3620 self.clear_persisted_leaf_override_after_append();
3621 self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
3622 Some(id)
3623 }
3624}
3625
3626#[derive(Debug, Clone)]
3628pub struct BranchInfo {
3629 pub total_entries: usize,
3630 pub leaf_count: usize,
3631 pub branch_point_count: usize,
3632 pub current_leaf: Option<String>,
3633 pub leaves: Vec<String>,
3634 pub branch_points: Vec<String>,
3635}
3636
3637#[derive(Debug, Clone)]
3639pub struct SiblingBranch {
3640 pub root_id: String,
3642 pub leaf_id: String,
3644 pub preview: String,
3646 pub message_count: usize,
3648 pub is_current: bool,
3650}
3651
3652#[derive(Debug, Clone)]
3653struct SessionPickEntry {
3654 path: PathBuf,
3655 id: String,
3656 cwd: String,
3657 timestamp: String,
3658 message_count: u64,
3659 name: Option<String>,
3660 last_modified_ms: i64,
3661 size_bytes: u64,
3662}
3663
3664impl SessionPickEntry {
3665 fn from_meta(meta: crate::session_index::SessionMeta) -> Self {
3666 Self {
3667 path: PathBuf::from(meta.path),
3668 id: meta.id,
3669 cwd: meta.cwd,
3670 timestamp: meta.timestamp,
3671 message_count: meta.message_count,
3672 name: meta.name,
3673 last_modified_ms: meta.last_modified_ms,
3674 size_bytes: meta.size_bytes,
3675 }
3676 }
3677
3678 fn to_meta(&self) -> crate::session_index::SessionMeta {
3679 crate::session_index::SessionMeta {
3680 path: self.path.display().to_string(),
3681 id: self.id.clone(),
3682 cwd: self.cwd.clone(),
3683 timestamp: self.timestamp.clone(),
3684 message_count: self.message_count,
3685 last_modified_ms: self.last_modified_ms,
3686 size_bytes: self.size_bytes,
3687 name: self.name.clone(),
3688 }
3689 }
3690}
3691
3692fn indexed_session_path_is_missing(path: &Path) -> bool {
3693 match path.try_exists() {
3694 Ok(exists) => !exists,
3695 Err(err) => {
3696 tracing::warn!(
3697 path = %path.display(),
3698 error = %err,
3699 "Failed to determine whether indexed session path exists; deferring prune"
3700 );
3701 false
3702 }
3703 }
3704}
3705
3706fn split_indexed_session_entries(
3707 metas: Vec<crate::session_index::SessionMeta>,
3708) -> (Vec<SessionPickEntry>, Vec<PathBuf>) {
3709 let mut entries = Vec::new();
3710 let mut missing_paths = Vec::new();
3711
3712 for meta in metas {
3713 let path = PathBuf::from(&meta.path);
3714 if indexed_session_path_is_missing(&path) {
3715 missing_paths.push(path);
3716 continue;
3717 }
3718
3719 entries.push(SessionPickEntry::from_meta(meta));
3720 }
3721
3722 (entries, missing_paths)
3723}
3724
3725fn prune_session_index_path(index: &SessionIndex, path: &Path, reason: &'static str) {
3726 if let Err(err) = index.delete_session_path(path) {
3727 tracing::warn!(
3728 path = %path.display(),
3729 error = %err,
3730 reason,
3731 "Failed to prune session from index"
3732 );
3733 }
3734}
3735
3736fn can_reuse_known_entry(known_entry: &SessionPickEntry, disk_ms: i64, disk_size: u64) -> bool {
3737 (known_entry.last_modified_ms, known_entry.size_bytes)
3738 .cmp(&(disk_ms, disk_size))
3739 .is_eq()
3740}
3741
3742struct ScanSessionsResult {
3743 entries: Vec<SessionPickEntry>,
3744 refreshed_entries: Vec<SessionPickEntry>,
3745 failed_paths: Vec<PathBuf>,
3746}
3747
3748fn refresh_session_index_entries(
3749 index: &SessionIndex,
3750 entries: &[SessionPickEntry],
3751 reason: &'static str,
3752) {
3753 for entry in entries {
3754 if let Err(err) = index.upsert_session_meta(entry.to_meta()) {
3755 tracing::warn!(
3756 path = %entry.path.display(),
3757 error = %err,
3758 reason,
3759 "Failed to refresh session metadata in index"
3760 );
3761 }
3762 }
3763}
3764
3765fn merge_scanned_session_entries(
3766 by_path: &mut HashMap<PathBuf, SessionPickEntry>,
3767 entries: Vec<SessionPickEntry>,
3768) {
3769 for entry in entries {
3770 by_path.insert(entry.path.clone(), entry);
3774 }
3775}
3776
3777async fn scan_sessions_on_disk(
3778 project_session_dir: &Path,
3779 known: Vec<SessionPickEntry>,
3780) -> Result<ScanSessionsResult> {
3781 let path_buf = project_session_dir.to_path_buf();
3782 let (tx, mut rx) = oneshot::channel();
3783
3784 let handle = thread::Builder::new()
3785 .name("session-scan".to_string())
3786 .spawn(move || {
3787 let res = (|| -> Result<ScanSessionsResult> {
3788 let mut entries = Vec::new();
3789 let mut refreshed_entries = Vec::new();
3790 let mut failed_paths = Vec::new();
3791 let dir_entries = std::fs::read_dir(&path_buf)
3792 .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
3793
3794 let known_map: HashMap<PathBuf, SessionPickEntry> =
3795 known.into_iter().map(|e| (e.path.clone(), e)).collect();
3796
3797 for entry in dir_entries {
3798 let entry =
3799 entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
3800 let path = entry.path();
3801 if is_session_file_path(&path) {
3802 if let Ok((disk_ms, disk_size)) = session_file_stats(&path) {
3805 if let Some(known_entry) = known_map.get(&path) {
3806 if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
3807 entries.push(known_entry.clone());
3808 continue;
3809 }
3810 }
3811 }
3812
3813 match load_session_meta(&path) {
3814 Ok(meta) => {
3815 refreshed_entries.push(meta.clone());
3816 entries.push(meta);
3817 }
3818 Err(_) => failed_paths.push(path),
3819 }
3820 }
3821 }
3822 Ok(ScanSessionsResult {
3823 entries,
3824 refreshed_entries,
3825 failed_paths,
3826 })
3827 })();
3828 let cx = AgentCx::for_request();
3829 let _ = tx.send(cx.cx(), res);
3830 })
3831 .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
3832
3833 let cx = AgentCx::for_request();
3834 let recv_result = rx.recv(cx.cx()).await;
3835 finish_worker_result(handle, recv_result, "Scan task cancelled")
3836}
3837
3838fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
3839 match path.extension().and_then(|ext| ext.to_str()) {
3840 Some("jsonl") => load_session_meta_jsonl(path),
3841 #[cfg(feature = "sqlite-sessions")]
3842 Some("sqlite") => load_session_meta_sqlite(path),
3843 _ => Err(Error::session(format!(
3844 "Unsupported session file extension: {}",
3845 path.display()
3846 ))),
3847 }
3848}
3849
3850#[derive(Deserialize)]
3851struct PartialEntry {
3852 #[serde(default)]
3853 r#type: String,
3854 #[serde(default)]
3855 name: Option<String>,
3856}
3857
3858fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
3859 let file = std::fs::File::open(path)
3860 .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
3861 let mut reader = BufReader::new(file);
3862
3863 let Some(header_line) = read_capped_utf8_line(&mut reader)
3864 .map_err(|e| Error::session(format!("Failed to read header: {e}")))?
3865 else {
3866 return Err(Error::session("Empty session file"));
3867 };
3868
3869 let header: SessionHeader =
3870 serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
3871 header
3872 .validate()
3873 .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3874
3875 let mut message_count = 0u64;
3876 let mut name = None;
3877 loop {
3878 let Some(line_content) = read_capped_utf8_line(&mut reader)
3879 .map_err(|e| Error::session(format!("Failed to read session entry: {e}")))?
3880 else {
3881 break;
3882 };
3883 if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
3884 match entry.r#type.as_str() {
3885 "message" => message_count += 1,
3886 "session_info" if entry.name.is_some() => {
3887 name = entry.name;
3888 }
3889 _ => {}
3890 }
3891 }
3892 }
3893
3894 let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3895
3896 Ok(SessionPickEntry {
3897 path: path.to_path_buf(),
3898 id: header.id,
3899 cwd: header.cwd,
3900 timestamp: header.timestamp,
3901 message_count,
3902 name,
3903 last_modified_ms,
3904 size_bytes,
3905 })
3906}
3907
3908#[cfg(feature = "sqlite-sessions")]
3909fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
3910 let meta = futures::executor::block_on(async {
3911 crate::session_sqlite::load_session_meta(path).await
3912 })?;
3913 let header = meta.header;
3914 header
3915 .validate()
3916 .map_err(|reason| Error::session(format!("Invalid session header: {reason}")))?;
3917
3918 let (last_modified_ms, size_bytes) = session_file_stats(path)?;
3919
3920 Ok(SessionPickEntry {
3921 path: path.to_path_buf(),
3922 id: header.id,
3923 cwd: header.cwd,
3924 timestamp: header.timestamp,
3925 message_count: meta.message_count,
3926 name: meta.name,
3927 last_modified_ms,
3928 size_bytes,
3929 })
3930}
3931
3932#[derive(Debug, Clone, Serialize, Deserialize)]
3938#[serde(rename_all = "camelCase")]
3939pub struct SessionHeader {
3940 pub r#type: String,
3941 #[serde(skip_serializing_if = "Option::is_none")]
3942 pub version: Option<u8>,
3943 pub id: String,
3944 pub timestamp: String,
3945 pub cwd: String,
3946 #[serde(skip_serializing_if = "Option::is_none")]
3947 pub provider: Option<String>,
3948 #[serde(skip_serializing_if = "Option::is_none")]
3949 pub model_id: Option<String>,
3950 #[serde(skip_serializing_if = "Option::is_none")]
3951 pub thinking_level: Option<String>,
3952 #[serde(skip_serializing_if = "Option::is_none")]
3953 pub fallback_provider: Option<String>,
3954 #[serde(skip_serializing_if = "Option::is_none")]
3955 pub fallback_model_id: Option<String>,
3956 #[serde(skip_serializing_if = "Option::is_none")]
3957 pub fallback_thinking_level: Option<String>,
3958 #[serde(skip_serializing_if = "Option::is_none", rename = "leafId")]
3959 pub current_leaf: Option<String>,
3960 #[serde(
3961 skip_serializing_if = "Option::is_none",
3962 rename = "branchedFrom",
3963 alias = "parentSession"
3964 )]
3965 pub parent_session: Option<String>,
3966}
3967
3968impl SessionHeader {
3969 pub fn new() -> Self {
3970 let now = chrono::Utc::now();
3971 Self {
3972 r#type: "session".to_string(),
3973 version: Some(SESSION_VERSION),
3974 id: uuid::Uuid::new_v4().to_string(),
3975 timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3976 cwd: std::env::current_dir()
3977 .map(|p| p.display().to_string())
3978 .unwrap_or_default(),
3979 provider: None,
3980 model_id: None,
3981 thinking_level: None,
3982 fallback_provider: None,
3983 fallback_model_id: None,
3984 fallback_thinking_level: None,
3985 current_leaf: None,
3986 parent_session: None,
3987 }
3988 }
3989
3990 fn branch_fallback_model_fields(&self) -> (Option<String>, Option<String>) {
3991 (
3992 self.fallback_provider
3993 .clone()
3994 .or_else(|| self.provider.clone()),
3995 self.fallback_model_id
3996 .clone()
3997 .or_else(|| self.model_id.clone()),
3998 )
3999 }
4000
4001 fn materialize_branch_fallbacks(&mut self) -> bool {
4002 let set_provider = self.fallback_provider.is_none() && self.provider.is_some();
4006 let set_model_id = self.fallback_model_id.is_none() && self.model_id.is_some();
4007 let set_thinking = self.fallback_thinking_level.is_none() && self.thinking_level.is_some();
4008
4009 if set_provider {
4010 self.fallback_provider = self.provider.clone();
4011 }
4012 if set_model_id {
4013 self.fallback_model_id = self.model_id.clone();
4014 }
4015 if set_thinking {
4016 self.fallback_thinking_level = self.thinking_level.clone();
4017 }
4018
4019 set_provider || set_model_id || set_thinking
4020 }
4021
4022 pub fn validate(&self) -> std::result::Result<(), String> {
4023 if !self.r#type.eq("session") {
4024 return Err(format!("type must be `session`, got `{}`", self.r#type));
4025 }
4026 if !self.version.eq(&Some(SESSION_VERSION)) {
4027 return Err(format!(
4028 "version must be {SESSION_VERSION}, got {}",
4029 self.version
4030 .map_or_else(|| "none".to_string(), |value| value.to_string())
4031 ));
4032 }
4033 if self.id.trim().is_empty() {
4034 return Err("id must be non-empty".to_string());
4035 }
4036 if self.timestamp.trim().is_empty() {
4037 return Err("timestamp must be non-empty".to_string());
4038 }
4039 if self.cwd.trim().is_empty() {
4040 return Err("cwd must be non-empty".to_string());
4041 }
4042 Ok(())
4043 }
4044
4045 pub fn is_valid(&self) -> bool {
4046 self.validate().is_ok()
4047 }
4048}
4049
4050impl Default for SessionHeader {
4051 fn default() -> Self {
4052 Self::new()
4053 }
4054}
4055
4056#[derive(Debug, Clone, Serialize, Deserialize)]
4062#[serde(tag = "type", rename_all = "snake_case")]
4063pub enum SessionEntry {
4064 Message(MessageEntry),
4065 ModelChange(ModelChangeEntry),
4066 ThinkingLevelChange(ThinkingLevelChangeEntry),
4067 Compaction(CompactionEntry),
4068 BranchSummary(BranchSummaryEntry),
4069 Label(LabelEntry),
4070 SessionInfo(SessionInfoEntry),
4071 Custom(CustomEntry),
4072}
4073
4074impl SessionEntry {
4075 pub const fn base(&self) -> &EntryBase {
4076 match self {
4077 Self::Message(e) => &e.base,
4078 Self::ModelChange(e) => &e.base,
4079 Self::ThinkingLevelChange(e) => &e.base,
4080 Self::Compaction(e) => &e.base,
4081 Self::BranchSummary(e) => &e.base,
4082 Self::Label(e) => &e.base,
4083 Self::SessionInfo(e) => &e.base,
4084 Self::Custom(e) => &e.base,
4085 }
4086 }
4087
4088 pub const fn base_mut(&mut self) -> &mut EntryBase {
4089 match self {
4090 Self::Message(e) => &mut e.base,
4091 Self::ModelChange(e) => &mut e.base,
4092 Self::ThinkingLevelChange(e) => &mut e.base,
4093 Self::Compaction(e) => &mut e.base,
4094 Self::BranchSummary(e) => &mut e.base,
4095 Self::Label(e) => &mut e.base,
4096 Self::SessionInfo(e) => &mut e.base,
4097 Self::Custom(e) => &mut e.base,
4098 }
4099 }
4100
4101 pub const fn base_id(&self) -> Option<&String> {
4102 self.base().id.as_ref()
4103 }
4104}
4105
4106#[derive(Debug, Clone, Serialize, Deserialize)]
4108#[serde(rename_all = "camelCase")]
4109pub struct EntryBase {
4110 #[serde(skip_serializing_if = "Option::is_none")]
4111 pub id: Option<String>,
4112 #[serde(skip_serializing_if = "Option::is_none")]
4113 pub parent_id: Option<String>,
4114 pub timestamp: String,
4115}
4116
4117impl EntryBase {
4118 pub fn new(parent_id: Option<String>, id: String) -> Self {
4119 Self {
4120 id: Some(id),
4121 parent_id,
4122 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4123 }
4124 }
4125}
4126
4127#[derive(Debug, Clone, Serialize, Deserialize)]
4129#[serde(rename_all = "camelCase")]
4130pub struct MessageEntry {
4131 #[serde(flatten)]
4132 pub base: EntryBase,
4133 pub message: SessionMessage,
4134}
4135
4136#[derive(Debug, Clone, Serialize, Deserialize)]
4138#[serde(
4139 tag = "role",
4140 rename_all = "camelCase",
4141 rename_all_fields = "camelCase"
4142)]
4143pub enum SessionMessage {
4144 User {
4145 content: UserContent,
4146 #[serde(skip_serializing_if = "Option::is_none")]
4147 timestamp: Option<i64>,
4148 },
4149 Assistant {
4150 #[serde(flatten)]
4151 message: AssistantMessage,
4152 },
4153 ToolResult {
4154 tool_call_id: String,
4155 tool_name: String,
4156 content: Vec<ContentBlock>,
4157 #[serde(skip_serializing_if = "Option::is_none")]
4158 details: Option<Value>,
4159 #[serde(default)]
4160 is_error: bool,
4161 #[serde(skip_serializing_if = "Option::is_none")]
4162 timestamp: Option<i64>,
4163 },
4164 Custom {
4165 custom_type: String,
4166 content: String,
4167 #[serde(default)]
4168 display: bool,
4169 #[serde(skip_serializing_if = "Option::is_none")]
4170 details: Option<Value>,
4171 #[serde(skip_serializing_if = "Option::is_none")]
4172 timestamp: Option<i64>,
4173 },
4174 BashExecution {
4175 command: String,
4176 output: String,
4177 exit_code: i32,
4178 #[serde(skip_serializing_if = "Option::is_none")]
4179 cancelled: Option<bool>,
4180 #[serde(skip_serializing_if = "Option::is_none")]
4181 truncated: Option<bool>,
4182 #[serde(skip_serializing_if = "Option::is_none")]
4183 full_output_path: Option<String>,
4184 #[serde(skip_serializing_if = "Option::is_none")]
4185 timestamp: Option<i64>,
4186 #[serde(flatten)]
4187 extra: HashMap<String, Value>,
4188 },
4189 BranchSummary {
4190 summary: String,
4191 from_id: String,
4192 },
4193 CompactionSummary {
4194 summary: String,
4195 tokens_before: u64,
4196 },
4197}
4198
4199impl From<Message> for SessionMessage {
4200 fn from(message: Message) -> Self {
4201 match message {
4202 Message::User(user) => Self::User {
4203 content: user.content,
4204 timestamp: Some(user.timestamp),
4205 },
4206 Message::Assistant(assistant) => Self::Assistant {
4207 message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
4208 },
4209 Message::ToolResult(result) => {
4210 let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
4211 Self::ToolResult {
4212 tool_call_id: result.tool_call_id,
4213 tool_name: result.tool_name,
4214 content: result.content,
4215 details: result.details,
4216 is_error: result.is_error,
4217 timestamp: Some(result.timestamp),
4218 }
4219 }
4220 Message::Custom(custom) => Self::Custom {
4221 custom_type: custom.custom_type,
4222 content: custom.content,
4223 display: custom.display,
4224 details: custom.details,
4225 timestamp: Some(custom.timestamp),
4226 },
4227 }
4228 }
4229}
4230
4231#[derive(Debug, Clone, Serialize, Deserialize)]
4233#[serde(rename_all = "camelCase")]
4234pub struct ModelChangeEntry {
4235 #[serde(flatten)]
4236 pub base: EntryBase,
4237 pub provider: String,
4238 pub model_id: String,
4239}
4240
4241#[derive(Debug, Clone, Serialize, Deserialize)]
4243#[serde(rename_all = "camelCase")]
4244pub struct ThinkingLevelChangeEntry {
4245 #[serde(flatten)]
4246 pub base: EntryBase,
4247 pub thinking_level: String,
4248}
4249
4250#[derive(Debug, Clone, Serialize, Deserialize)]
4252#[serde(rename_all = "camelCase")]
4253pub struct CompactionEntry {
4254 #[serde(flatten)]
4255 pub base: EntryBase,
4256 pub summary: String,
4257 pub first_kept_entry_id: String,
4258 pub tokens_before: u64,
4259 #[serde(skip_serializing_if = "Option::is_none")]
4260 pub details: Option<serde_json::Value>,
4261 #[serde(skip_serializing_if = "Option::is_none")]
4262 pub from_hook: Option<bool>,
4263}
4264
4265#[derive(Debug, Clone, Serialize, Deserialize)]
4267#[serde(rename_all = "camelCase")]
4268pub struct BranchSummaryEntry {
4269 #[serde(flatten)]
4270 pub base: EntryBase,
4271 pub from_id: String,
4272 pub summary: String,
4273 #[serde(skip_serializing_if = "Option::is_none")]
4274 pub details: Option<serde_json::Value>,
4275 #[serde(skip_serializing_if = "Option::is_none")]
4276 pub from_hook: Option<bool>,
4277}
4278
4279#[derive(Debug, Clone, Serialize, Deserialize)]
4281#[serde(rename_all = "camelCase")]
4282pub struct LabelEntry {
4283 #[serde(flatten)]
4284 pub base: EntryBase,
4285 pub target_id: String,
4286 #[serde(skip_serializing_if = "Option::is_none")]
4287 pub label: Option<String>,
4288}
4289
4290#[derive(Debug, Clone, Serialize, Deserialize)]
4292#[serde(rename_all = "camelCase")]
4293pub struct SessionInfoEntry {
4294 #[serde(flatten)]
4295 pub base: EntryBase,
4296 #[serde(skip_serializing_if = "Option::is_none")]
4297 pub name: Option<String>,
4298}
4299
4300#[derive(Debug, Clone, Serialize, Deserialize)]
4302#[serde(rename_all = "camelCase")]
4303pub struct CustomEntry {
4304 #[serde(flatten)]
4305 pub base: EntryBase,
4306 pub custom_type: String,
4307 #[serde(skip_serializing_if = "Option::is_none")]
4308 pub data: Option<serde_json::Value>,
4309}
4310
4311pub fn encode_cwd(path: &std::path::Path) -> String {
4317 let s = path.display().to_string();
4318 let s = s.trim_start_matches(['/', '\\']);
4319 let s = s.replace(['/', '\\', ':'], "-");
4320 format!("--{s}--")
4321}
4322
4323fn infer_session_root_from_path(path: &Path) -> Option<PathBuf> {
4324 let parent = path.parent()?.to_path_buf();
4325 if parent
4326 .file_name()
4327 .and_then(|name| name.to_str())
4328 .is_some_and(|name| name.starts_with("--") && name.ends_with("--") && name.len() > 4)
4329 {
4330 return parent.parent().map(PathBuf::from).or(Some(parent));
4331 }
4332 Some(parent)
4333}
4334
4335pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
4336 match message {
4337 SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
4338 content: content.clone(),
4339 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4340 })),
4341 SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
4342 SessionMessage::ToolResult {
4343 tool_call_id,
4344 tool_name,
4345 content,
4346 details,
4347 is_error,
4348 timestamp,
4349 } => Some(Message::tool_result(ToolResultMessage {
4350 tool_call_id: tool_call_id.clone(),
4351 tool_name: tool_name.clone(),
4352 content: content.clone(),
4353 details: details.clone(),
4354 is_error: *is_error,
4355 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4356 })),
4357 SessionMessage::Custom {
4358 custom_type,
4359 content,
4360 display,
4361 details,
4362 timestamp,
4363 } => Some(Message::Custom(crate::model::CustomMessage {
4364 content: content.clone(),
4365 custom_type: custom_type.clone(),
4366 display: *display,
4367 details: details.clone(),
4368 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4369 })),
4370 SessionMessage::BashExecution {
4371 command,
4372 output,
4373 exit_code,
4374 cancelled,
4375 truncated,
4376 full_output_path,
4377 timestamp,
4378 extra,
4379 } => {
4380 if extra
4381 .get("excludeFromContext")
4382 .and_then(Value::as_bool)
4383 .is_some_and(|v| v)
4384 {
4385 return None;
4386 }
4387 let text = bash_execution_to_text(
4388 command,
4389 output,
4390 *exit_code,
4391 cancelled.unwrap_or(false),
4392 truncated.unwrap_or(false),
4393 full_output_path.as_deref(),
4394 );
4395 Some(Message::User(UserMessage {
4396 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
4397 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
4398 }))
4399 }
4400 SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
4401 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4402 "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
4403 )))]),
4404 timestamp: chrono::Utc::now().timestamp_millis(),
4405 })),
4406 SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
4407 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
4408 "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
4409 )))]),
4410 timestamp: chrono::Utc::now().timestamp_millis(),
4411 })),
4412 }
4413}
4414
4415const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
4416const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
4417
4418const BRANCH_SUMMARY_PREFIX: &str =
4419 "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
4420const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
4421
4422pub(crate) fn bash_execution_to_text(
4423 command: &str,
4424 output: &str,
4425 exit_code: i32,
4426 cancelled: bool,
4427 truncated: bool,
4428 full_output_path: Option<&str>,
4429) -> String {
4430 let mut text = format!("Ran `{command}`\n");
4431 if output.is_empty() {
4432 text.push_str("(no output)");
4433 } else {
4434 text.push_str("```\n");
4435 text.push_str(output);
4436 if !output.ends_with('\n') {
4437 text.push('\n');
4438 }
4439 text.push_str("```");
4440 }
4441
4442 if cancelled {
4443 text.push_str("\n\n(command cancelled)");
4444 } else if exit_code != 0 {
4445 let _ = write!(text, "\n\nCommand exited with code {exit_code}");
4446 }
4447
4448 if truncated {
4449 if let Some(path) = full_output_path {
4450 let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
4451 } else {
4452 text.push_str("\n\n[Output truncated]");
4453 }
4454 }
4455
4456 text
4457}
4458
4459#[allow(clippy::too_many_lines)]
4464fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
4465 let mut html = String::new();
4466 html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
4467 html.push_str("<title>Pi Session</title>");
4468 html.push_str("<style>");
4469 html.push_str(
4470 "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
4471 h1{margin:0 0 8px 0;}
4472 .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
4473 .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
4474 .msg.user{border-left:4px solid #4fc3f7;}
4475 .msg.assistant{border-left:4px solid #81c784;}
4476 .msg.tool{border-left:4px solid #ffb74d;}
4477 .msg.system{border-left:4px solid #ef9a9a;}
4478 .role{font-weight:600;margin-bottom:8px;}
4479 pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
4480 .thinking summary{cursor:pointer;}
4481 img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
4482 .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
4483 ",
4484 );
4485 html.push_str("</style></head><body>");
4486
4487 let _ = write!(
4488 html,
4489 "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
4490 escape_html(&header.id),
4491 escape_html(&header.timestamp),
4492 escape_html(&header.cwd)
4493 );
4494
4495 for entry in entries {
4496 match entry {
4497 SessionEntry::Message(message) => {
4498 html.push_str(&render_session_message(&message.message));
4499 }
4500 SessionEntry::ModelChange(change) => {
4501 let _ = write!(
4502 html,
4503 "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
4504 escape_html(&change.provider),
4505 escape_html(&change.model_id)
4506 );
4507 }
4508 SessionEntry::ThinkingLevelChange(change) => {
4509 let _ = write!(
4510 html,
4511 "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
4512 escape_html(&change.thinking_level)
4513 );
4514 }
4515 SessionEntry::Compaction(compaction) => {
4516 let _ = write!(
4517 html,
4518 "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4519 escape_html(&compaction.summary)
4520 );
4521 }
4522 SessionEntry::BranchSummary(summary) => {
4523 let _ = write!(
4524 html,
4525 "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4526 escape_html(&summary.summary)
4527 );
4528 }
4529 SessionEntry::SessionInfo(info) => {
4530 if let Some(name) = &info.name {
4531 let _ = write!(
4532 html,
4533 "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
4534 escape_html(name)
4535 );
4536 }
4537 }
4538 SessionEntry::Custom(custom) => {
4539 let _ = write!(
4540 html,
4541 "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
4542 escape_html(&custom.custom_type)
4543 );
4544 }
4545 SessionEntry::Label(_) => {}
4546 }
4547 }
4548
4549 html.push_str("</body></html>");
4550 html
4551}
4552
4553fn render_session_message(message: &SessionMessage) -> String {
4554 match message {
4555 SessionMessage::User { content, .. } => {
4556 let mut html = String::new();
4557 html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
4558 html.push_str(&render_user_content(content));
4559 html.push_str("</div>");
4560 html
4561 }
4562 SessionMessage::Assistant { message } => {
4563 let mut html = String::new();
4564 html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
4565 html.push_str(&render_blocks(&message.content));
4566 html.push_str("</div>");
4567 html
4568 }
4569 SessionMessage::ToolResult {
4570 tool_name,
4571 content,
4572 is_error,
4573 details,
4574 ..
4575 } => {
4576 let mut html = String::new();
4577 let role = if *is_error { "Tool Error" } else { "Tool" };
4578 let _ = write!(
4579 html,
4580 "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
4581 role,
4582 escape_html(tool_name)
4583 );
4584 html.push_str(&render_blocks(content));
4585 if let Some(details) = details {
4586 let details_str =
4587 serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
4588 let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
4589 }
4590 html.push_str("</div>");
4591 html
4592 }
4593 SessionMessage::Custom {
4594 custom_type,
4595 content,
4596 ..
4597 } => {
4598 let mut html = String::new();
4599 let _ = write!(
4600 html,
4601 "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
4602 escape_html(custom_type),
4603 escape_html(content)
4604 );
4605 html
4606 }
4607 SessionMessage::BashExecution {
4608 command,
4609 output,
4610 exit_code,
4611 ..
4612 } => {
4613 let mut html = String::new();
4614 let _ = write!(
4615 html,
4616 "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
4617 escape_html(command),
4618 escape_html(output)
4619 );
4620 html
4621 }
4622 SessionMessage::BranchSummary { summary, .. } => {
4623 format!(
4624 "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
4625 escape_html(summary)
4626 )
4627 }
4628 SessionMessage::CompactionSummary { summary, .. } => {
4629 format!(
4630 "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
4631 escape_html(summary)
4632 )
4633 }
4634 }
4635}
4636
4637fn render_user_content(content: &UserContent) -> String {
4638 match content {
4639 UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
4640 UserContent::Blocks(blocks) => render_blocks(blocks),
4641 }
4642}
4643
4644fn render_blocks(blocks: &[ContentBlock]) -> String {
4645 let mut html = String::new();
4646 for block in blocks {
4647 match block {
4648 ContentBlock::Text(text) => {
4649 let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
4650 }
4651 ContentBlock::Thinking(thinking) => {
4652 let _ = write!(
4653 html,
4654 "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
4655 escape_html(&thinking.thinking)
4656 );
4657 }
4658 ContentBlock::Image(image) => {
4659 let _ = write!(
4660 html,
4661 "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
4662 escape_html(&image.mime_type),
4663 escape_html(&image.data)
4664 );
4665 }
4666 ContentBlock::ToolCall(tool_call) => {
4667 let args = serde_json::to_string_pretty(&tool_call.arguments)
4668 .unwrap_or_else(|_| tool_call.arguments.to_string());
4669 let _ = write!(
4670 html,
4671 "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
4672 escape_html(&tool_call.name),
4673 escape_html(&args)
4674 );
4675 }
4676 ContentBlock::RedactedThinking(_) => {
4677 html.push_str(
4681 "<details class=\"thinking\"><summary>Thinking (redacted)</summary></details>",
4682 );
4683 }
4684 }
4685 }
4686 html
4687}
4688
4689fn escape_html(input: &str) -> String {
4690 let mut escaped = String::with_capacity(input.len());
4691 for ch in input.chars() {
4692 match ch {
4693 '&' => escaped.push_str("&"),
4694 '<' => escaped.push_str("<"),
4695 '>' => escaped.push_str(">"),
4696 '"' => escaped.push_str("""),
4697 '\'' => escaped.push_str("'"),
4698 _ => escaped.push(ch),
4699 }
4700 }
4701 escaped
4702}
4703
4704fn user_content_to_text(content: &UserContent) -> String {
4705 match content {
4706 UserContent::Text(text) => text.clone(),
4707 UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
4708 }
4709}
4710
4711fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
4712 let mut output = String::new();
4713 for block in blocks {
4714 match block {
4715 ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
4716 ContentBlock::Image(image) => {
4717 push_line(&mut output, &format!("[image: {}]", image.mime_type));
4718 }
4719 ContentBlock::Thinking(thinking_block) => {
4720 push_line(&mut output, &thinking_block.thinking);
4721 }
4722 ContentBlock::ToolCall(call) => {
4723 push_line(&mut output, &format!("[tool call: {}]", call.name));
4724 }
4725 ContentBlock::RedactedThinking(_) => {
4726 push_line(&mut output, "[thinking: redacted]");
4727 }
4728 }
4729 }
4730 output
4731}
4732
4733fn push_line(out: &mut String, line: &str) {
4734 if !out.is_empty() {
4735 out.push('\n');
4736 }
4737 out.push_str(line);
4738}
4739
4740fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
4741 entries
4742 .iter()
4743 .filter_map(|e| e.base_id().cloned())
4744 .collect()
4745}
4746
4747const PARALLEL_THRESHOLD: usize = 512;
4749const JSONL_PARSE_BATCH_SIZE: usize = 8192;
4751
4752#[allow(clippy::too_many_lines)]
4757fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4758 let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
4759 let mut reader = std::io::BufReader::new(file);
4760
4761 let Some(header_line) =
4762 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4763 else {
4764 return Err(crate::Error::session("Empty session file"));
4765 };
4766 if header_line.trim().is_empty() {
4767 return Err(crate::Error::session("Empty session file"));
4768 }
4769
4770 let header: SessionHeader = serde_json::from_str(&header_line)
4772 .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
4773 header
4774 .validate()
4775 .map_err(|reason| crate::Error::session(format!("Invalid session header: {reason}")))?;
4776 let (header, normalized_header_dirty) = normalize_loaded_header(header);
4777
4778 let mut entries = Vec::new();
4779 let mut diagnostics = SessionOpenDiagnostics::default();
4780
4781 let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
4784
4785 let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
4786 let mut current_line_num = 2; loop {
4789 line_batch.clear();
4790 let mut batch_eof = false;
4791
4792 for _ in 0..JSONL_PARSE_BATCH_SIZE {
4793 match read_capped_utf8_line(&mut reader) {
4794 Ok(None) => {
4795 batch_eof = true;
4796 break;
4797 }
4798 Ok(Some(line)) => {
4799 if !line.trim().is_empty() {
4800 line_batch.push((current_line_num, line));
4801 }
4802 }
4803 Err(e) => {
4804 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4805 line_number: current_line_num,
4806 error: format!("IO error reading line: {e}"),
4807 });
4808 }
4809 }
4810 current_line_num += 1;
4811 }
4812
4813 if line_batch.is_empty() {
4814 if batch_eof {
4815 break;
4816 }
4817 continue;
4818 }
4819
4820 if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
4821 let chunk_size = (line_batch.len() / num_threads).max(64);
4822
4823 let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
4824 std::thread::scope(|s| {
4825 line_batch
4826 .chunks(chunk_size)
4827 .map(|chunk| {
4828 s.spawn(move || {
4829 let mut ok = Vec::with_capacity(chunk.len());
4830 let mut skip = Vec::new();
4831 for (line_num, line) in chunk {
4832 match serde_json::from_str::<SessionEntry>(line) {
4833 Ok(entry) => ok.push(entry),
4834 Err(e) => {
4835 skip.push(SessionOpenSkippedEntry {
4836 line_number: *line_num,
4837 error: e.to_string(),
4838 });
4839 }
4840 }
4841 }
4842 (ok, skip)
4843 })
4844 })
4845 .collect::<Vec<_>>()
4846 .into_iter()
4847 .map(|h| {
4848 h.join().map_err(|panic_payload| {
4849 let panic_message =
4850 panic_payload.downcast_ref::<String>().map_or_else(
4851 || {
4852 panic_payload.downcast_ref::<&str>().map_or_else(
4853 || "unknown panic payload".to_string(),
4854 |message| (*message).to_string(),
4855 )
4856 },
4857 std::clone::Clone::clone,
4858 );
4859 Error::session(format!(
4860 "parallel session parse worker panicked: {panic_message}"
4861 ))
4862 })
4863 })
4864 .collect()
4865 });
4866 let chunk_results = chunk_results?;
4867
4868 for (chunk_entries, chunk_skipped) in chunk_results {
4869 entries.extend(chunk_entries);
4870 diagnostics.skipped_entries.extend(chunk_skipped);
4871 }
4872 } else {
4873 for (line_num, line) in &line_batch {
4875 match serde_json::from_str::<SessionEntry>(line) {
4876 Ok(entry) => entries.push(entry),
4877 Err(e) => {
4878 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
4879 line_number: *line_num,
4880 error: e.to_string(),
4881 });
4882 }
4883 }
4884 }
4885 }
4886
4887 if batch_eof {
4888 break;
4889 }
4890 }
4891
4892 let finalized = finalize_loaded_entries(&mut entries);
4894 for orphan in &finalized.orphans {
4895 diagnostics
4896 .orphaned_parent_links
4897 .push(SessionOpenOrphanedParentLink {
4898 entry_id: orphan.0.clone(),
4899 missing_parent_id: orphan.1.clone(),
4900 });
4901 }
4902
4903 let entry_count = entries.len();
4904 let natural_leaf_id = finalized.leaf_id.clone();
4905 let leaf_id = resolve_loaded_leaf_id(&header, natural_leaf_id.clone(), &finalized.entry_index);
4906
4907 Ok((
4908 Session {
4909 header,
4910 entries,
4911 path: Some(path_buf),
4912 leaf_id: leaf_id.clone(),
4913 session_dir: None,
4914 store_kind: SessionStoreKind::Jsonl,
4915 entry_ids: finalized.entry_ids,
4916 is_linear: finalized.is_linear && leaf_id.eq(&natural_leaf_id),
4917 entry_index: finalized.entry_index,
4918 cached_message_count: finalized.message_count,
4919 cached_name: finalized.name,
4920 autosave_queue: AutosaveQueue::new(),
4921 autosave_durability: AutosaveDurabilityMode::from_env(),
4922 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
4923 header_dirty: normalized_header_dirty,
4924 appends_since_checkpoint: 0,
4925 v2_sidecar_root: None,
4926 v2_partial_hydration: false,
4927 v2_resume_mode: None,
4928 v2_sidecar_stale: false,
4929 v2_message_count_offset: 0,
4930 },
4931 diagnostics,
4932 ))
4933}
4934
4935#[allow(clippy::too_many_lines)]
4941fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
4942 let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4944 let mut reader = BufReader::new(file);
4945 let Some(header_line) =
4946 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
4947 else {
4948 return Err(crate::Error::session("Empty JSONL session file"));
4949 };
4950 let header: SessionHeader = serde_json::from_str(header_line.trim())
4951 .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
4952 header.validate().map_err(|reason| {
4953 crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
4954 })?;
4955
4956 let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
4958 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
4959
4960 let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
4964 let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
4965 if let Some(raw) = mode_override_raw.as_deref() {
4966 if parse_v2_open_mode(raw).is_none() {
4967 tracing::warn!(
4968 value = %raw,
4969 "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
4970 );
4971 }
4972 }
4973 if let Some(raw) = threshold_override_raw.as_deref() {
4974 if raw.trim().parse::<u64>().is_err() {
4975 tracing::warn!(
4976 value = %raw,
4977 "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
4978 );
4979 }
4980 }
4981
4982 let entry_count = store.entry_count();
4983 let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
4984 entry_count,
4985 mode_override_raw.as_deref(),
4986 threshold_override_raw.as_deref(),
4987 );
4988 let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
4989 && entry_count > 0
4990 && store.head().is_none()
4991 {
4992 tracing::warn!(
4993 entry_count,
4994 "active-path hydration selected but store has no head; falling back to full hydration"
4995 );
4996 V2OpenMode::Full
4997 } else {
4998 selected_mode
4999 };
5000 tracing::debug!(
5001 entry_count,
5002 lazy_threshold,
5003 selection_reason,
5004 ?mode,
5005 "selected V2 resume hydration mode"
5006 );
5007
5008 let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
5010 session.path = Some(jsonl_path);
5011 session.v2_sidecar_root = Some(v2_root);
5012 session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
5013 session.v2_resume_mode = Some(mode);
5014 Ok((session, diagnostics))
5015}
5016
5017pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
5023 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5024 if !v2_root.exists() {
5025 return build_v2_sidecar_from_jsonl_into(jsonl_path, &v2_root);
5026 }
5027
5028 let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5029 let _staged_store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5030 Ok(store) => store,
5031 Err(err) => {
5032 let _ = cleanup_sidecar_root(&staging_root);
5033 return Err(err);
5034 }
5035 };
5036
5037 let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5038 if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5039 let _ = cleanup_sidecar_root(&staging_root);
5040 return Err(crate::Error::Io(Box::new(err)));
5041 }
5042
5043 if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5044 let _ = std::fs::rename(&backup_root, &v2_root);
5045 let _ = cleanup_sidecar_root(&staging_root);
5046 return Err(crate::Error::Io(Box::new(err)));
5047 }
5048
5049 if let Err(err) = cleanup_sidecar_root(&backup_root) {
5050 tracing::warn!(
5051 path = %backup_root.display(),
5052 error = %err,
5053 "create_v2_sidecar_from_jsonl left backup sidecar after successful swap"
5054 );
5055 }
5056
5057 SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)
5058}
5059
5060fn build_v2_sidecar_from_jsonl_into(jsonl_path: &Path, v2_root: &Path) -> Result<SessionStoreV2> {
5061 let build_result = (|| -> Result<SessionStoreV2> {
5062 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5063 let mut reader = std::io::BufReader::new(file);
5064
5065 let header_line = read_capped_utf8_line(&mut reader)
5066 .map_err(|e| crate::Error::Io(Box::new(e)))?
5067 .filter(|l| !l.trim().is_empty())
5068 .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
5069
5070 let header: SessionHeader = serde_json::from_str(header_line.trim())
5071 .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5072 header.validate().map_err(|reason| {
5073 crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5074 })?;
5075
5076 if v2_root.exists() {
5077 std::fs::remove_dir_all(v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5078 }
5079 let mut store = SessionStoreV2::create(v2_root, 64 * 1024 * 1024)?;
5080
5081 loop {
5082 let Some(line) =
5083 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5084 else {
5085 break;
5086 };
5087 if line.trim().is_empty() {
5088 continue;
5089 }
5090 let entry: SessionEntry = serde_json::from_str(&line)
5091 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5092 let (entry_id, parent_entry_id, entry_type, payload) =
5093 session_store_v2::session_entry_to_frame_args(&entry)?;
5094 store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5095 }
5096
5097 store.write_manifest(header.id, "jsonl")?;
5098
5099 Ok(store)
5100 })();
5101
5102 if build_result.is_err() && v2_root.exists() {
5103 let _ = std::fs::remove_dir_all(v2_root);
5104 }
5105
5106 build_result
5107}
5108
5109fn unique_sidecar_aux_path(v2_root: &Path, suffix: &str) -> PathBuf {
5110 let file_name = v2_root
5111 .file_name()
5112 .and_then(|name| name.to_str())
5113 .unwrap_or("session.v2");
5114 v2_root.with_file_name(format!(
5115 "{file_name}.{suffix}.{}",
5116 uuid::Uuid::new_v4().simple()
5117 ))
5118}
5119
5120fn cleanup_sidecar_root(path: &Path) -> Result<()> {
5121 if path.exists() {
5122 std::fs::remove_dir_all(path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5123 }
5124 Ok(())
5125}
5126
5127pub fn migrate_jsonl_to_v2(
5133 jsonl_path: &Path,
5134 correlation_id: &str,
5135) -> Result<session_store_v2::MigrationEvent> {
5136 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5137 let staging_root = unique_sidecar_aux_path(&v2_root, "staging");
5138 let store = match build_v2_sidecar_from_jsonl_into(jsonl_path, &staging_root) {
5139 Ok(store) => store,
5140 Err(err) => {
5141 let _ = cleanup_sidecar_root(&staging_root);
5142 return Err(err);
5143 }
5144 };
5145
5146 let verification = match verify_v2_against_jsonl(jsonl_path, &store) {
5148 Ok(verification) => verification,
5149 Err(err) => {
5150 let _ = cleanup_sidecar_root(&staging_root);
5151 return Err(err);
5152 }
5153 };
5154
5155 if !(verification.entry_count_match
5156 && verification.hash_chain_match
5157 && verification.index_consistent)
5158 {
5159 cleanup_sidecar_root(&staging_root)?;
5161 return Err(crate::Error::session(format!(
5162 "V2 migration verification failed: count={} hash={} index={}",
5163 verification.entry_count_match,
5164 verification.hash_chain_match,
5165 verification.index_consistent,
5166 )));
5167 }
5168
5169 let event = session_store_v2::MigrationEvent {
5170 schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5171 migration_id: uuid::Uuid::new_v4().to_string(),
5172 phase: "forward".to_string(),
5173 at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5174 source_path: jsonl_path.display().to_string(),
5175 target_path: session_store_v2::v2_sidecar_path(jsonl_path)
5176 .display()
5177 .to_string(),
5178 source_format: "jsonl_v3".to_string(),
5179 target_format: "native_v2".to_string(),
5180 verification,
5181 outcome: "ok".to_string(),
5182 error_class: None,
5183 correlation_id: correlation_id.to_string(),
5184 };
5185 if let Err(err) = store.append_migration_event(event.clone()) {
5186 let _ = cleanup_sidecar_root(&staging_root);
5187 return Err(err);
5188 }
5189
5190 let backup_root = if v2_root.exists() {
5191 let backup_root = unique_sidecar_aux_path(&v2_root, "backup");
5192 if let Err(err) = std::fs::rename(&v2_root, &backup_root) {
5193 let _ = cleanup_sidecar_root(&staging_root);
5194 return Err(crate::Error::Io(Box::new(err)));
5195 }
5196 Some(backup_root)
5197 } else {
5198 None
5199 };
5200
5201 if let Err(err) = std::fs::rename(&staging_root, &v2_root) {
5202 if let Some(backup_root) = backup_root.as_ref() {
5203 let _ = std::fs::rename(backup_root, &v2_root);
5204 }
5205 let _ = cleanup_sidecar_root(&staging_root);
5206 return Err(crate::Error::Io(Box::new(err)));
5207 }
5208
5209 if let Some(backup_root) = backup_root {
5210 if let Err(err) = cleanup_sidecar_root(&backup_root) {
5211 tracing::warn!(
5212 path = %backup_root.display(),
5213 error = %err,
5214 "V2 migration left backup sidecar after successful swap"
5215 );
5216 }
5217 }
5218
5219 Ok(event)
5220}
5221
5222pub fn verify_v2_against_jsonl(
5227 jsonl_path: &Path,
5228 store: &SessionStoreV2,
5229) -> Result<session_store_v2::MigrationVerification> {
5230 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5232 let mut reader = std::io::BufReader::new(file);
5233
5234 let Some(header_line) =
5235 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5236 else {
5237 return Err(crate::Error::session("Empty JSONL session file"));
5238 };
5239 if header_line.trim().is_empty() {
5240 return Err(crate::Error::session("Empty JSONL session file"));
5241 }
5242
5243 let header: SessionHeader = serde_json::from_str(header_line.trim())
5244 .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5245 header.validate().map_err(|reason| {
5246 crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5247 })?;
5248
5249 let mut jsonl_ids: Vec<String> = Vec::new();
5250 let mut jsonl_chain_hash = V2_CHAIN_HASH_GENESIS.to_string();
5251
5252 loop {
5253 let Some(line) =
5254 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5255 else {
5256 break;
5257 };
5258 if line.trim().is_empty() {
5259 continue;
5260 }
5261 let entry: SessionEntry = serde_json::from_str(&line)
5262 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5263 let id = entry
5264 .base_id()
5265 .cloned()
5266 .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
5267 jsonl_ids.push(id);
5268 jsonl_chain_hash = session_entry_chain_hash_step(&jsonl_chain_hash, &entry)?;
5269 }
5270
5271 let frames = store.read_all_entries()?;
5273 let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
5274
5275 let entry_count_match = jsonl_ids.len().eq(&v2_ids.len()) && jsonl_ids.eq(&v2_ids);
5276
5277 let index_consistent = store.validate_integrity().is_ok();
5279
5280 let hash_chain_match = jsonl_chain_hash.eq(store.chain_hash());
5281
5282 Ok(session_store_v2::MigrationVerification {
5283 entry_count_match,
5284 hash_chain_match,
5285 index_consistent,
5286 })
5287}
5288
5289fn is_v2_sidecar_stale(jsonl_path: &Path, v2_root: &Path) -> bool {
5290 let Some(jsonl_meta) = std::fs::metadata(jsonl_path).ok() else {
5291 return true;
5292 };
5293
5294 let v2_index = v2_root.join("index").join("offsets.jsonl");
5295 let v2_manifest = v2_root.join("manifest.json");
5296 let Some(v2_meta) = std::fs::metadata(&v2_index)
5297 .or_else(|_| std::fs::metadata(&v2_manifest))
5298 .ok()
5299 else {
5300 return true;
5301 };
5302
5303 let Some(jsonl_mtime) = jsonl_meta.modified().ok() else {
5304 return true;
5305 };
5306 let Some(v2_mtime) = v2_meta.modified().ok() else {
5307 return true;
5308 };
5309
5310 jsonl_mtime > v2_mtime
5311}
5312
5313fn session_entry_chain_hash_step(prev_chain: &str, entry: &SessionEntry) -> Result<String> {
5314 let (_, _, _, payload) = session_store_v2::session_entry_to_frame_args(entry)?;
5315 let payload_sha256 = format!("{:x}", Sha256::digest(serde_json::to_vec(&payload)?));
5316 let mut hasher = Sha256::new();
5317 hasher.update(prev_chain.as_bytes());
5318 hasher.update(payload_sha256.as_bytes());
5319 Ok(format!("{:x}", hasher.finalize()))
5320}
5321
5322pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
5327 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5328 if !v2_root.exists() {
5329 return Ok(());
5330 }
5331
5332 if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5334 let event = session_store_v2::MigrationEvent {
5335 schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
5336 migration_id: uuid::Uuid::new_v4().to_string(),
5337 phase: "rollback_to_jsonl".to_string(),
5338 at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
5339 source_path: v2_root.display().to_string(),
5340 target_path: jsonl_path.display().to_string(),
5341 source_format: "native_v2".to_string(),
5342 target_format: "jsonl_v3".to_string(),
5343 verification: session_store_v2::MigrationVerification {
5344 entry_count_match: true,
5345 hash_chain_match: true,
5346 index_consistent: true,
5347 },
5348 outcome: "ok".to_string(),
5349 error_class: None,
5350 correlation_id: correlation_id.to_string(),
5351 };
5352 let _ = store.append_migration_event(event);
5353 }
5354
5355 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5356 Ok(())
5357}
5358
5359#[derive(Debug, Clone, PartialEq, Eq)]
5361pub enum MigrationState {
5362 Unmigrated,
5364 Migrated,
5366 Corrupt { error: String },
5368 Partial,
5370}
5371
5372pub fn migration_status(jsonl_path: &Path) -> MigrationState {
5374 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5375 if !v2_root.exists() {
5376 return MigrationState::Unmigrated;
5377 }
5378
5379 let segments_dir = v2_root.join("segments");
5380 if !segments_dir.exists() {
5381 return MigrationState::Partial;
5382 }
5383
5384 let index_path = v2_root.join("index").join("offsets.jsonl");
5385 if !index_path.exists() {
5386 match jsonl_has_entry_lines(jsonl_path) {
5387 Ok(true) => return MigrationState::Partial,
5388 Ok(false) => {}
5389 Err(e) => {
5390 return MigrationState::Corrupt {
5391 error: e.to_string(),
5392 };
5393 }
5394 }
5395 }
5396
5397 let inspector = match SessionStoreV2::open_for_inspection(&v2_root, 64 * 1024 * 1024) {
5398 Ok(store) => store,
5399 Err(e) => {
5400 return MigrationState::Corrupt {
5401 error: e.to_string(),
5402 };
5403 }
5404 };
5405
5406 match inspector.read_index() {
5407 Ok(_) => match inspector.validate_integrity() {
5408 Ok(()) => MigrationState::Migrated,
5409 Err(e) => MigrationState::Corrupt {
5410 error: e.to_string(),
5411 },
5412 },
5413 Err(e) if migration_status_can_rebuild_index(&e) => {
5414 match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
5415 Ok(store) => match verify_v2_against_jsonl(jsonl_path, &store) {
5416 Ok(verification)
5417 if verification.entry_count_match
5418 && verification.hash_chain_match
5419 && verification.index_consistent =>
5420 {
5421 MigrationState::Migrated
5422 }
5423 Ok(verification) => MigrationState::Corrupt {
5424 error: format!(
5425 "migration verification failed after index rebuild: count={} hash={} index={}",
5426 verification.entry_count_match,
5427 verification.hash_chain_match,
5428 verification.index_consistent,
5429 ),
5430 },
5431 Err(err) => MigrationState::Corrupt {
5432 error: err.to_string(),
5433 },
5434 },
5435 Err(err) => MigrationState::Corrupt {
5436 error: err.to_string(),
5437 },
5438 }
5439 }
5440 Err(e) => MigrationState::Corrupt {
5441 error: e.to_string(),
5442 },
5443 }
5444}
5445
5446fn migration_status_can_rebuild_index(error: &Error) -> bool {
5447 match error {
5448 Error::Json(_) => true,
5449 Error::Io(err) => matches!(
5450 err.kind(),
5451 std::io::ErrorKind::UnexpectedEof | std::io::ErrorKind::InvalidData
5452 ),
5453 _ => false,
5454 }
5455}
5456
5457pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
5463 let tmp_dir =
5464 tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
5465 let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
5466
5467 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5469 let mut reader = std::io::BufReader::new(file);
5470
5471 let Some(header_line) =
5472 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5473 else {
5474 return Err(crate::Error::session("Empty JSONL session file"));
5475 };
5476
5477 let header: SessionHeader = serde_json::from_str(header_line.trim_end())
5478 .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
5479 header.validate().map_err(|reason| {
5480 crate::Error::session(format!("Invalid session header in JSONL: {reason}"))
5481 })?;
5482
5483 let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
5484
5485 loop {
5486 let Some(line) =
5487 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5488 else {
5489 break;
5490 };
5491 if line.trim().is_empty() {
5492 continue;
5493 }
5494 let entry: SessionEntry = serde_json::from_str(line.trim_end())
5495 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
5496 let (entry_id, parent_entry_id, entry_type, payload) =
5497 session_store_v2::session_entry_to_frame_args(&entry)?;
5498 store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
5499 }
5500
5501 verify_v2_against_jsonl(jsonl_path, &store)
5503 }
5505
5506pub fn recover_partial_migration(
5511 jsonl_path: &Path,
5512 correlation_id: &str,
5513 re_migrate: bool,
5514) -> Result<MigrationState> {
5515 let status = migration_status(jsonl_path);
5516 match &status {
5517 MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
5518 MigrationState::Partial | MigrationState::Corrupt { .. } => {
5519 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
5521 if v2_root.exists() {
5522 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
5523 }
5524
5525 if re_migrate {
5526 migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
5527 Ok(MigrationState::Migrated)
5528 } else {
5529 Ok(MigrationState::Unmigrated)
5530 }
5531 }
5532 }
5533}
5534
5535fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
5536 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
5537 let mut reader = std::io::BufReader::new(file);
5538
5539 let Some(_line) =
5540 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5541 else {
5542 return Err(crate::Error::session("Empty JSONL session file"));
5543 };
5544
5545 loop {
5546 let Some(line) =
5547 read_capped_utf8_line(&mut reader).map_err(|e| crate::Error::Io(Box::new(e)))?
5548 else {
5549 return Ok(false);
5550 };
5551 if !line.trim().is_empty() {
5552 return Ok(true);
5553 }
5554 }
5555}
5556
5557struct LoadFinalization {
5563 leaf_id: Option<String>,
5564 entry_ids: HashSet<String>,
5565 entry_index: HashMap<String, usize>,
5566 message_count: u64,
5567 name: Option<String>,
5568 is_linear: bool,
5569 orphans: Vec<(String, String)>,
5570}
5571
5572fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
5580 let mut entry_ids: HashSet<String> = entries
5582 .iter()
5583 .filter_map(|e| e.base_id().cloned())
5584 .collect();
5585 for entry in entries.iter_mut() {
5586 if entry.base().id.is_none() {
5587 let id = generate_entry_id(&entry_ids);
5588 entry.base_mut().id = Some(id.clone());
5589 entry_ids.insert(id);
5590 }
5591 }
5592
5593 let mut entry_index = HashMap::with_capacity(entries.len());
5595 let mut message_count = 0u64;
5596 let mut name: Option<String> = None;
5597 let mut leaf_id: Option<String> = None;
5598 let mut orphans = Vec::new();
5599 let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
5601 let mut has_branching = false;
5602 let mut root_count = 0u32;
5603
5604 for (idx, entry) in entries.iter().enumerate() {
5605 let Some(id) = entry.base_id() else {
5606 continue;
5607 };
5608 entry_index.insert(id.clone(), idx);
5609 leaf_id = Some(id.clone());
5610
5611 if let Some(parent_id) = entry.base().parent_id.as_ref() {
5613 if !entry_ids.contains(parent_id) {
5614 orphans.push((id.clone(), parent_id.clone()));
5615 }
5616 } else {
5617 root_count += 1;
5618 }
5619
5620 if !has_branching {
5622 let parent_key = entry.base().parent_id.as_deref();
5623 let count = parent_id_child_count.entry(parent_key).or_insert(0);
5624 *count += 1;
5625 if *count > 1 {
5626 has_branching = true;
5627 }
5628 }
5629
5630 match entry {
5632 SessionEntry::Message(_) => message_count += 1,
5633 SessionEntry::SessionInfo(info) if info.name.is_some() => {
5634 name.clone_from(&info.name);
5635 }
5636 _ => {}
5637 }
5638 }
5639
5640 let is_linear = !has_branching && root_count <= 1 && orphans.is_empty();
5644
5645 LoadFinalization {
5646 leaf_id,
5647 entry_ids,
5648 entry_index,
5649 message_count,
5650 name,
5651 is_linear,
5652 orphans,
5653 }
5654}
5655
5656fn parse_env_bool(value: &str) -> bool {
5657 matches!(
5658 value.trim().to_ascii_lowercase().as_str(),
5659 "1" | "true" | "yes" | "on"
5660 )
5661}
5662
5663fn session_entry_id_cache_enabled() -> bool {
5664 static ENABLED: OnceLock<bool> = OnceLock::new();
5665 *ENABLED.get_or_init(|| {
5666 std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
5667 })
5668}
5669
5670#[cfg(test)]
5671fn ensure_entry_ids(entries: &mut [SessionEntry]) {
5672 let mut existing = entry_id_set(entries);
5673 for entry in entries.iter_mut() {
5674 if entry.base().id.is_none() {
5675 let id = generate_entry_id(&existing);
5676 entry.base_mut().id = Some(id.clone());
5677 existing.insert(id);
5678 }
5679 }
5680}
5681
5682fn generate_entry_id(existing: &HashSet<String>) -> String {
5684 for _ in 0..100 {
5685 let uuid = uuid::Uuid::new_v4();
5686 let id = uuid.simple().to_string()[..8].to_string();
5687 if !existing.contains(&id) {
5688 return id;
5689 }
5690 }
5691 uuid::Uuid::new_v4().to_string()
5692}
5693
5694#[cfg(test)]
5695type SetNameDeadlineProbe = Option<(String, std::sync::mpsc::Sender<Option<asupersync::Time>>)>;
5696
5697#[cfg(test)]
5698fn set_name_deadline_probe() -> &'static std::sync::Mutex<SetNameDeadlineProbe> {
5699 static PROBE: std::sync::OnceLock<std::sync::Mutex<SetNameDeadlineProbe>> =
5700 std::sync::OnceLock::new();
5701 PROBE.get_or_init(|| std::sync::Mutex::new(None))
5702}
5703
5704#[cfg(test)]
5705fn emit_set_name_deadline_probe(session_id: &str, deadline: Option<asupersync::Time>) {
5706 let probe = set_name_deadline_probe();
5707 let guard = probe.lock().expect("lock set_name deadline probe");
5708 if let Some((target_session_id, tx)) = guard.as_ref() {
5709 if target_session_id.eq(session_id) {
5710 let _ = tx.send(deadline);
5711 }
5712 }
5713}
5714
5715#[cfg(test)]
5716mod tests {
5717 use super::*;
5718 use crate::model::{Cost, StopReason, Usage};
5719 use asupersync::runtime::RuntimeBuilder;
5720 use asupersync::sync::Mutex as AsyncMutex;
5721 use clap::Parser;
5722 use std::env;
5723 use std::future::Future;
5724 use std::path::{Path, PathBuf};
5725 use std::sync::{Mutex as StdMutex, OnceLock};
5726 use std::time::Duration;
5727
5728 macro_rules! test_fail {
5729 ($message:literal $(,)?) => {
5730 std::panic::panic_any($message)
5731 };
5732 ($fmt:literal, $($arg:tt)+) => {
5733 std::panic::panic_any(format!($fmt, $($arg)+))
5734 };
5735 }
5736
5737 fn make_test_message(text: &str) -> SessionMessage {
5738 SessionMessage::User {
5739 content: UserContent::Text(text.to_string()),
5740 timestamp: Some(0),
5741 }
5742 }
5743
5744 fn make_test_assistant_message(text: &str, total_tokens: u64) -> SessionMessage {
5745 SessionMessage::Assistant {
5746 message: AssistantMessage {
5747 content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5748 api: "test-api".to_string(),
5749 provider: "test-provider".to_string(),
5750 model: "test-model".to_string(),
5751 usage: Usage {
5752 input: total_tokens / 2,
5753 output: total_tokens.saturating_sub(total_tokens / 2),
5754 total_tokens,
5755 ..Usage::default()
5756 },
5757 stop_reason: StopReason::Stop,
5758 error_message: None,
5759 timestamp: 0,
5760 },
5761 }
5762 }
5763
5764 fn make_test_tool_call_message(tool_call_id: &str) -> SessionMessage {
5765 SessionMessage::Assistant {
5766 message: AssistantMessage {
5767 content: vec![ContentBlock::ToolCall(crate::model::ToolCall {
5768 id: tool_call_id.to_string(),
5769 name: "read".to_string(),
5770 arguments: serde_json::json!({ "path": "src/session.rs" }),
5771 thought_signature: None,
5772 })],
5773 api: "test-api".to_string(),
5774 provider: "test-provider".to_string(),
5775 model: "test-model".to_string(),
5776 usage: Usage {
5777 input: 24,
5778 output: 16,
5779 total_tokens: 40,
5780 ..Usage::default()
5781 },
5782 stop_reason: StopReason::ToolUse,
5783 error_message: None,
5784 timestamp: 0,
5785 },
5786 }
5787 }
5788
5789 fn make_test_tool_result_message(tool_call_id: &str) -> SessionMessage {
5790 SessionMessage::ToolResult {
5791 tool_call_id: tool_call_id.to_string(),
5792 tool_name: "read".to_string(),
5793 content: vec![ContentBlock::Text(TextContent::new(
5794 "read output for replay harness".to_string(),
5795 ))],
5796 details: Some(serde_json::json!({
5797 "bytes": 31,
5798 "truncated": false,
5799 })),
5800 is_error: false,
5801 timestamp: Some(0),
5802 }
5803 }
5804
5805 fn make_test_aborted_assistant_message(text: &str) -> SessionMessage {
5806 SessionMessage::Assistant {
5807 message: AssistantMessage {
5808 content: vec![ContentBlock::Text(TextContent::new(text.to_string()))],
5809 api: "test-api".to_string(),
5810 provider: "test-provider".to_string(),
5811 model: "test-model".to_string(),
5812 usage: Usage {
5813 input: 10,
5814 output: 6,
5815 total_tokens: 16,
5816 ..Usage::default()
5817 },
5818 stop_reason: StopReason::Aborted,
5819 error_message: Some("interrupted by local abort".to_string()),
5820 timestamp: 0,
5821 },
5822 }
5823 }
5824
5825 fn run_async<T>(future: impl Future<Output = T>) -> T {
5826 let runtime = RuntimeBuilder::current_thread()
5827 .build()
5828 .expect("build runtime");
5829 runtime.block_on(future)
5830 }
5831
5832 fn tempdir_under_tmpdir(prefix: &str) -> tempfile::TempDir {
5833 let tmp_root = env::var_os("TMPDIR").map_or_else(env::temp_dir, PathBuf::from);
5834 std::fs::create_dir_all(&tmp_root).expect("create TMPDIR root");
5835 tempfile::Builder::new()
5836 .prefix(prefix)
5837 .tempdir_in(&tmp_root)
5838 .expect("create tempdir under TMPDIR")
5839 }
5840
5841 fn current_dir_lock() -> std::sync::MutexGuard<'static, ()> {
5842 static LOCK: OnceLock<StdMutex<()>> = OnceLock::new();
5843 LOCK.get_or_init(|| StdMutex::new(())).lock().expect("lock")
5844 }
5845
5846 struct CurrentDirGuard {
5847 previous: PathBuf,
5848 }
5849
5850 impl CurrentDirGuard {
5851 fn new(path: &Path) -> Self {
5852 let previous = env::current_dir().expect("current dir");
5853 env::set_current_dir(path).expect("set current dir");
5854 Self { previous }
5855 }
5856 }
5857
5858 impl Drop for CurrentDirGuard {
5859 fn drop(&mut self) {
5860 let _ = env::set_current_dir(&self.previous);
5861 }
5862 }
5863
5864 #[test]
5865 fn v2_open_mode_parser_supports_expected_values() {
5866 assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
5867 assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
5868 assert_eq!(
5869 parse_v2_open_mode("active_path"),
5870 Some(V2OpenMode::ActivePath)
5871 );
5872 assert_eq!(
5873 parse_v2_open_mode("active-path"),
5874 Some(V2OpenMode::ActivePath)
5875 );
5876 assert_eq!(
5877 parse_v2_open_mode("tail"),
5878 Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
5879 );
5880 assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
5881 assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
5882 assert_eq!(parse_v2_open_mode("bad-mode"), None);
5883 assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
5884 }
5885
5886 #[test]
5887 fn v2_open_mode_selection_prefers_env_override_then_threshold() {
5888 let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
5889 assert_eq!(mode, V2OpenMode::Full);
5890 assert_eq!(reason, "env_override");
5891 assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5892
5893 let (mode, reason, threshold) =
5894 select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
5895 assert_eq!(
5896 mode,
5897 V2OpenMode::ActivePath,
5898 "invalid threshold falls back to default threshold"
5899 );
5900 assert_eq!(reason, "entry_count_above_lazy_threshold");
5901 assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
5902
5903 let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
5904 assert_eq!(mode, V2OpenMode::ActivePath);
5905 assert_eq!(reason, "entry_count_above_lazy_threshold");
5906 assert_eq!(threshold, 500);
5907
5908 let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
5909 assert_eq!(mode, V2OpenMode::Full);
5910 assert_eq!(reason, "default_full");
5911 assert_eq!(threshold, 500);
5912 }
5913
5914 #[test]
5915 fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
5916 let temp_dir = tempfile::tempdir().unwrap();
5917 let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
5918
5919 let mut seed = Session::create();
5923 seed.path = Some(path.clone());
5924 let _id_root = seed.append_message(make_test_message("root"));
5925 let id_a = seed.append_message(make_test_message("a"));
5926 let id_b = seed.append_message(make_test_message("main-branch"));
5927 assert!(seed.create_branch_from(&id_a));
5928 let id_c = seed.append_message(make_test_message("side-branch"));
5929 run_async(async { seed.save().await }).unwrap();
5930
5931 create_v2_sidecar_from_jsonl(&path).unwrap();
5933 let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5934 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5935 let (mut loaded, _) =
5936 Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5937 loaded.path = Some(path.clone());
5938 loaded.v2_sidecar_root = Some(v2_root);
5939 loaded.v2_partial_hydration = true;
5940 loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
5941
5942 let active_ids: Vec<String> = loaded
5943 .entries
5944 .iter()
5945 .filter_map(|entry| entry.base().id.clone())
5946 .collect();
5947 assert!(
5948 !active_ids.contains(&id_b),
5949 "active path intentionally excludes non-leaf sibling branch"
5950 );
5951 assert!(active_ids.contains(&id_c));
5952 assert_eq!(
5953 loaded.cached_message_count, seed.cached_message_count,
5954 "active-path resume should retain total message count metadata"
5955 );
5956 assert!(
5957 loaded.v2_message_count_offset > 0,
5958 "active-path resume should track hidden messages outside the active path"
5959 );
5960
5961 loaded.set_model_header(Some("provider-updated".to_string()), None, None);
5963 run_async(async { loaded.save().await }).unwrap();
5964
5965 let (reopened, _) =
5966 run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
5967 let reopened_ids: Vec<String> = reopened
5968 .entries
5969 .iter()
5970 .filter_map(|entry| entry.base().id.clone())
5971 .collect();
5972 assert!(
5973 reopened_ids.contains(&id_b),
5974 "non-active branch entry must survive full rewrite after lazy hydration"
5975 );
5976 assert!(reopened_ids.contains(&id_c));
5977 assert_eq!(reopened_ids.len(), 4);
5978 }
5979
5980 #[test]
5981 fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
5982 let temp_dir = tempfile::tempdir().unwrap();
5983 let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
5984
5985 let mut seed = Session::create();
5986 seed.path = Some(path.clone());
5987 let _id_root = seed.append_message(make_test_message("root"));
5988 let id_a = seed.append_message(make_test_message("a"));
5989 let id_b = seed.append_message(make_test_message("main-branch"));
5990 assert!(seed.create_branch_from(&id_a));
5991 let _id_c = seed.append_message(make_test_message("side-branch"));
5992 run_async(async { seed.save().await }).unwrap();
5993
5994 create_v2_sidecar_from_jsonl(&path).unwrap();
5995 let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
5996 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
5997 let (mut loaded, _) =
5998 Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
5999 loaded.path = Some(path.clone());
6000 loaded.v2_sidecar_root = Some(v2_root);
6001 loaded.v2_partial_hydration = true;
6002 loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6003
6004 let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
6005 loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6006 run_async(async { loaded.save().await }).unwrap();
6007
6008 let (reopened, _) =
6009 run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6010 let reopened_ids: Vec<String> = reopened
6011 .entries
6012 .iter()
6013 .filter_map(|entry| entry.base().id.clone())
6014 .collect();
6015 assert!(
6016 reopened_ids.contains(&id_b),
6017 "non-active branch entry must survive rehydration+save"
6018 );
6019 assert!(
6020 reopened_ids.contains(&new_id),
6021 "pending entry appended on partial session must be preserved"
6022 );
6023 assert_eq!(reopened_ids.len(), 5);
6024 }
6025
6026 #[test]
6027 fn v2_partial_hydration_full_rewrite_uses_newer_jsonl_when_sidecar_is_stale() {
6028 let temp_dir = tempfile::tempdir().unwrap();
6029 let path = temp_dir.path().join("lazy_hydration_stale_sidecar.jsonl");
6030
6031 let mut seed = Session::create();
6032 seed.path = Some(path.clone());
6033 let _id_root = seed.append_message(make_test_message("root"));
6034 let id_a = seed.append_message(make_test_message("a"));
6035 let id_b = seed.append_message(make_test_message("main-branch"));
6036 assert!(seed.create_branch_from(&id_a));
6037 let _id_c = seed.append_message(make_test_message("side-branch"));
6038 run_async(async { seed.save().await }).unwrap();
6039
6040 create_v2_sidecar_from_jsonl(&path).unwrap();
6041 let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
6042 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
6043 let (mut loaded, _) =
6044 Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
6045 loaded.path = Some(path.clone());
6046 loaded.v2_sidecar_root = Some(v2_root.clone());
6047 loaded.v2_partial_hydration = true;
6048 loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
6049
6050 std::thread::sleep(std::time::Duration::from_secs(1));
6051 let new_id = loaded.append_message(make_test_message("saved-before-full-rewrite"));
6052 run_async(async { loaded.save().await }).unwrap();
6053 assert!(
6054 is_v2_sidecar_stale(&path, &v2_root),
6055 "incremental JSONL save should make sidecar stale"
6056 );
6057
6058 loaded.set_model_header(Some("provider-updated".to_string()), None, None);
6059 run_async(async { loaded.save().await }).unwrap();
6060
6061 let (reopened, _) =
6062 run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
6063 let reopened_ids: Vec<String> = reopened
6064 .entries
6065 .iter()
6066 .filter_map(|entry| entry.base().id.clone())
6067 .collect();
6068 assert!(
6069 reopened_ids.contains(&id_b),
6070 "non-active branch entry must survive full rewrite after stale sidecar"
6071 );
6072 assert!(
6073 reopened_ids.contains(&new_id),
6074 "entry already saved to JSONL must not be dropped during rehydrate"
6075 );
6076 assert_eq!(reopened_ids.len(), 5);
6077 }
6078
6079 #[test]
6080 fn verify_v2_against_jsonl_detects_payload_mismatch_with_matching_ids() {
6081 let temp_dir = tempfile::tempdir().unwrap();
6082 let path = temp_dir.path().join("verify_v2_payload_mismatch.jsonl");
6083
6084 let mut session = Session::create();
6085 session.path = Some(path.clone());
6086 session.append_message(make_test_message("alpha"));
6087 session.append_message(make_test_message("beta"));
6088 run_async(async { session.save().await }).unwrap();
6089
6090 let contents = std::fs::read_to_string(&path).unwrap();
6091 let mut lines = contents.lines();
6092 let _header_line = lines.next().expect("header");
6093 let mut tampered_entries: Vec<SessionEntry> = lines
6094 .filter(|line| !line.trim().is_empty())
6095 .map(|line| serde_json::from_str(line).expect("parse session entry"))
6096 .collect();
6097
6098 let SessionEntry::Message(message_entry) = tampered_entries
6099 .first_mut()
6100 .expect("first tampered entry should exist")
6101 else {
6102 test_fail!("expected message entry");
6103 };
6104 let SessionMessage::User {
6105 content: UserContent::Text(text),
6106 ..
6107 } = &mut message_entry.message
6108 else {
6109 test_fail!("expected user text message");
6110 };
6111 *text = "alpha-tampered".to_string();
6112
6113 let tampered_root = temp_dir.path().join("verify_v2_payload_mismatch.v2");
6114 let mut tampered_store = SessionStoreV2::create(&tampered_root, 64 * 1024 * 1024).unwrap();
6115 for entry in &tampered_entries {
6116 let (entry_id, parent_entry_id, entry_type, payload) =
6117 session_store_v2::session_entry_to_frame_args(entry).unwrap();
6118 tampered_store
6119 .append_entry(entry_id, parent_entry_id, entry_type, payload)
6120 .unwrap();
6121 }
6122
6123 let verification = verify_v2_against_jsonl(&path, &tampered_store).unwrap();
6124 assert!(verification.entry_count_match);
6125 assert!(verification.index_consistent);
6126 assert!(
6127 !verification.hash_chain_match,
6128 "payload divergence must fail migration verification even when entry ids match"
6129 );
6130 }
6131
6132 #[test]
6133 fn test_session_handle_mutations_defer_persistence_side_effects() {
6134 let temp_dir = tempfile::tempdir().expect("temp dir");
6135 let mut session = Session::create();
6136 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6137 session.path = Some(temp_dir.path().to_path_buf());
6139 let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6140
6141 run_async(async { handle.set_name("deferred-save".to_string()).await })
6142 .expect("set_name should not trigger immediate save");
6143 run_async(async { handle.append_message(make_test_message("hello")).await })
6144 .expect("append_message should not trigger immediate save");
6145 run_async(async {
6146 handle
6147 .append_custom_entry(
6148 "marker".to_string(),
6149 Some(serde_json::json!({ "value": 42 })),
6150 )
6151 .await
6152 })
6153 .expect("append_custom_entry should not trigger immediate save");
6154 run_async(async {
6155 handle
6156 .set_model("prov".to_string(), "model".to_string())
6157 .await
6158 })
6159 .expect("set_model should not trigger immediate save");
6160 run_async(async { handle.set_thinking_level("high".to_string()).await })
6161 .expect("set_thinking_level should not trigger immediate save");
6162
6163 let branch = run_async(async { handle.get_branch().await });
6164 let message_id = branch
6165 .iter()
6166 .find_map(|entry| {
6167 if entry
6168 .get("type")
6169 .and_then(Value::as_str)
6170 .is_some_and(|entry_type| entry_type.eq("message"))
6171 {
6172 entry
6173 .get("id")
6174 .and_then(Value::as_str)
6175 .map(ToString::to_string)
6176 } else {
6177 None
6178 }
6179 })
6180 .expect("message entry id in branch");
6181 run_async(async {
6182 handle
6183 .set_label(message_id, Some("hot-path".to_string()))
6184 .await
6185 })
6186 .expect("set_label should not trigger immediate save");
6187
6188 let state = run_async(async { handle.get_state().await });
6189 assert_eq!(
6190 state.get("sessionName").and_then(Value::as_str),
6191 Some("deferred-save")
6192 );
6193 assert_eq!(
6194 state.get("thinkingLevel").and_then(Value::as_str),
6195 Some("high")
6196 );
6197 assert_eq!(
6198 state.get("durabilityMode").and_then(Value::as_str),
6199 Some("throughput")
6200 );
6201 assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
6202 assert_eq!(
6203 state
6204 .get("model")
6205 .and_then(|model| model.get("provider"))
6206 .and_then(Value::as_str),
6207 Some("prov")
6208 );
6209 assert_eq!(
6210 state
6211 .get("model")
6212 .and_then(|model| model.get("id"))
6213 .and_then(Value::as_str),
6214 Some("model")
6215 );
6216
6217 let (provider, model_id) = run_async(async { handle.get_model().await });
6218 assert_eq!(provider.as_deref(), Some("prov"));
6219 assert_eq!(model_id.as_deref(), Some("model"));
6220 }
6221
6222 #[test]
6223 fn session_handle_set_name_inherits_cancelled_context_when_lock_is_held() {
6224 let runtime = RuntimeBuilder::current_thread()
6225 .build()
6226 .expect("build runtime");
6227
6228 runtime.block_on(async {
6229 let session = Arc::new(AsyncMutex::new(Session::in_memory()));
6230 let handle = SessionHandle(Arc::clone(&session));
6231
6232 let hold_cx = AgentCx::for_request();
6233 let held_guard = session.lock(hold_cx.cx()).await.expect("lock session");
6234
6235 let ambient_cx = asupersync::Cx::for_testing();
6236 ambient_cx.set_cancel_requested(true);
6237 let _current = asupersync::Cx::set_current(Some(ambient_cx));
6238 let inner = asupersync::time::timeout(
6239 asupersync::time::wall_now(),
6240 Duration::from_millis(100),
6241 handle.set_name("cancelled-name".to_string()),
6242 )
6243 .await;
6244 let outcome = inner.expect("cancelled helper should finish before timeout");
6245 let err = outcome.expect_err("lock acquisition should honor inherited cancellation");
6246 assert!(
6247 err.to_string().contains("Failed to lock session"),
6248 "unexpected error: {err}"
6249 );
6250
6251 drop(held_guard);
6252
6253 let state = SessionHandle(Arc::clone(&session)).get_state().await;
6254 assert!(
6255 state.get("sessionName").is_none_or(Value::is_null),
6256 "cancelled mutation should not update the session name: {state:?}"
6257 );
6258 });
6259 }
6260
6261 #[test]
6262 fn session_handle_set_name_inherits_deadline() {
6263 let runtime = RuntimeBuilder::current_thread()
6264 .build()
6265 .expect("build runtime");
6266
6267 runtime.block_on(async {
6268 struct ProbeReset;
6269 impl Drop for ProbeReset {
6270 fn drop(&mut self) {
6271 let mut probe = set_name_deadline_probe()
6272 .lock()
6273 .expect("lock set_name deadline probe");
6274 *probe = None;
6275 }
6276 }
6277
6278 let session_state = Session::in_memory();
6279 let probe_session_id = session_state.header.id.clone();
6280 let session = Arc::new(AsyncMutex::new(session_state));
6281 let handle = SessionHandle(Arc::clone(&session));
6282
6283 let (probe_tx, probe_rx) = std::sync::mpsc::channel();
6284 {
6285 let mut probe = set_name_deadline_probe()
6286 .lock()
6287 .expect("lock set_name deadline probe");
6288 assert!(probe.is_none(), "set_name deadline probe already installed");
6289 *probe = Some((probe_session_id, probe_tx));
6290 }
6291 let _probe_reset = ProbeReset;
6292
6293 let expected_deadline = asupersync::time::wall_now() + Duration::from_secs(30);
6294 let ambient_cx = AgentCx::for_request_with_budget(asupersync::Budget {
6295 deadline: Some(expected_deadline),
6296 ..asupersync::Budget::INFINITE
6297 });
6298 let _current = asupersync::Cx::set_current(Some(ambient_cx.cx().clone()));
6299 handle
6300 .set_name("deadline-name".to_string())
6301 .await
6302 .expect("set_name should succeed with inherited deadline");
6303
6304 let recorded = probe_rx
6305 .recv_timeout(Duration::from_secs(1))
6306 .expect("set_name deadline probe");
6307 assert_eq!(recorded, Some(expected_deadline));
6308
6309 let state = SessionHandle(Arc::clone(&session)).get_state().await;
6310 assert_eq!(
6311 state.get("sessionName").and_then(Value::as_str),
6312 Some("deadline-name")
6313 );
6314 });
6315 }
6316
6317 #[test]
6318 fn test_session_handle_set_model_and_thinking_level_dedupe_history() {
6319 let handle = SessionHandle(Arc::new(AsyncMutex::new(Session::in_memory())));
6320
6321 run_async(async {
6322 handle
6323 .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6324 .await
6325 })
6326 .expect("set model");
6327 run_async(async {
6328 handle
6329 .set_model("anthropic".to_string(), "claude-sonnet-4-5".to_string())
6330 .await
6331 })
6332 .expect("repeat model");
6333 run_async(async { handle.set_thinking_level("high".to_string()).await })
6334 .expect("set thinking");
6335 run_async(async { handle.set_thinking_level("high".to_string()).await })
6336 .expect("repeat thinking");
6337
6338 let branch = run_async(async { handle.get_branch().await });
6339 let model_changes = branch
6340 .iter()
6341 .filter(|entry| {
6342 entry
6343 .get("type")
6344 .and_then(Value::as_str)
6345 .is_some_and(|entry_type| entry_type.eq("model_change"))
6346 })
6347 .count();
6348 let thinking_changes = branch
6349 .iter()
6350 .filter(|entry| {
6351 entry
6352 .get("type")
6353 .and_then(Value::as_str)
6354 .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6355 })
6356 .count();
6357 assert_eq!(model_changes, 1);
6358 assert_eq!(thinking_changes, 1);
6359 }
6360
6361 #[test]
6362 fn test_session_handle_preserves_alias_equivalent_model_state() {
6363 let mut session = Session::in_memory();
6364 session.append_model_change("google".to_string(), "gemini-2.5-pro".to_string());
6365 session.set_model_header(
6366 Some("google".to_string()),
6367 Some("gemini-2.5-pro".to_string()),
6368 None,
6369 );
6370 let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6371
6372 run_async(async {
6373 handle
6374 .set_model("gemini".to_string(), "GEMINI-2.5-PRO".to_string())
6375 .await
6376 })
6377 .expect("alias-equivalent model should dedupe");
6378
6379 let branch = run_async(async { handle.get_branch().await });
6380 let model_changes: Vec<_> = branch
6381 .iter()
6382 .filter_map(|entry| {
6383 if entry
6384 .get("type")
6385 .and_then(Value::as_str)
6386 .is_some_and(|entry_type| entry_type.eq("model_change"))
6387 {
6388 Some((
6389 entry.get("provider").and_then(Value::as_str),
6390 entry.get("modelId").and_then(Value::as_str),
6391 ))
6392 } else {
6393 None
6394 }
6395 })
6396 .collect();
6397 assert_eq!(
6398 model_changes,
6399 vec![(Some("google"), Some("gemini-2.5-pro"))],
6400 "alias-equivalent set_model should not append duplicate history"
6401 );
6402
6403 let (provider, model_id) = run_async(async { handle.get_model().await });
6404 assert_eq!(provider.as_deref(), Some("google"));
6405 assert_eq!(model_id.as_deref(), Some("gemini-2.5-pro"));
6406
6407 let state = run_async(async { handle.get_state().await });
6408 assert_eq!(state["model"]["provider"], "google");
6409 assert_eq!(state["model"]["id"], "gemini-2.5-pro");
6410 }
6411
6412 #[test]
6413 fn session_handle_reports_branch_local_model_and_thinking_state() {
6414 let mut session = Session::in_memory();
6415 let root_id = session.append_message(make_test_message("root"));
6416
6417 session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6418 let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6419 session.set_model_header(
6420 Some("openai".to_string()),
6421 Some("gpt-4o".to_string()),
6422 Some("low".to_string()),
6423 );
6424
6425 assert!(session.create_branch_from(&root_id));
6426 session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6427 session.append_thinking_level_change("high".to_string());
6428 session.set_model_header(
6429 Some("anthropic".to_string()),
6430 Some("claude-sonnet-4-5".to_string()),
6431 Some("high".to_string()),
6432 );
6433
6434 assert!(session.navigate_to(&branch_a_thinking));
6435
6436 let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6437 let state = run_async(async { handle.get_state().await });
6438 let (provider, model_id) = run_async(async { handle.get_model().await });
6439 let thinking_level = run_async(async { handle.get_thinking_level().await });
6440
6441 assert_eq!(provider.as_deref(), Some("openai"));
6442 assert_eq!(model_id.as_deref(), Some("gpt-4o"));
6443 assert_eq!(thinking_level.as_deref(), Some("low"));
6444 assert_eq!(
6445 state
6446 .get("model")
6447 .and_then(|model| model.get("provider"))
6448 .and_then(Value::as_str),
6449 Some("openai")
6450 );
6451 assert_eq!(
6452 state
6453 .get("model")
6454 .and_then(|model| model.get("id"))
6455 .and_then(Value::as_str),
6456 Some("gpt-4o")
6457 );
6458 assert_eq!(
6459 state.get("thinkingLevel").and_then(Value::as_str),
6460 Some("low")
6461 );
6462 }
6463
6464 #[test]
6465 fn session_handle_set_model_and_thinking_level_dedupe_on_switched_branch() {
6466 let mut session = Session::in_memory();
6467 let root_id = session.append_message(make_test_message("root"));
6468
6469 session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6470 let branch_a_thinking = session.append_thinking_level_change("low".to_string());
6471 session.set_model_header(
6472 Some("openai".to_string()),
6473 Some("gpt-4o".to_string()),
6474 Some("low".to_string()),
6475 );
6476
6477 assert!(session.create_branch_from(&root_id));
6478 session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
6479 session.append_thinking_level_change("high".to_string());
6480 session.set_model_header(
6481 Some("anthropic".to_string()),
6482 Some("claude-sonnet-4-5".to_string()),
6483 Some("high".to_string()),
6484 );
6485
6486 assert!(session.navigate_to(&branch_a_thinking));
6487
6488 let handle = SessionHandle(Arc::new(AsyncMutex::new(session)));
6489
6490 run_async(async {
6491 handle
6492 .set_model("openai".to_string(), "gpt-4o".to_string())
6493 .await
6494 })
6495 .expect("same-branch model should dedupe");
6496 run_async(async { handle.set_thinking_level("low".to_string()).await })
6497 .expect("same-branch thinking should dedupe");
6498
6499 let branch = run_async(async { handle.get_branch().await });
6500 let model_changes = branch
6501 .iter()
6502 .filter(|entry| {
6503 entry
6504 .get("type")
6505 .and_then(Value::as_str)
6506 .is_some_and(|entry_type| entry_type.eq("model_change"))
6507 })
6508 .count();
6509 let thinking_changes = branch
6510 .iter()
6511 .filter(|entry| {
6512 entry
6513 .get("type")
6514 .and_then(Value::as_str)
6515 .is_some_and(|entry_type| entry_type.eq("thinking_level_change"))
6516 })
6517 .count();
6518
6519 assert_eq!(model_changes, 1, "expected one branch-local model_change");
6520 assert_eq!(
6521 thinking_changes, 1,
6522 "expected one branch-local thinking_level_change"
6523 );
6524 }
6525
6526 #[test]
6527 fn test_autosave_queue_coalesces_mutations_per_flush() {
6528 let temp_dir = tempfile::tempdir().expect("temp dir");
6529 let mut session = Session::create();
6530 session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
6531
6532 session.append_message(make_test_message("one"));
6533 session.append_custom_entry("marker".to_string(), None);
6534 session.append_message(make_test_message("two"));
6535
6536 let before = session.autosave_metrics();
6537 assert_eq!(before.pending_mutations, 3);
6538 assert!(before.coalesced_mutations >= 2);
6539 assert_eq!(before.flush_succeeded, 0);
6540
6541 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
6542 .expect("periodic flush");
6543
6544 let after = session.autosave_metrics();
6545 assert_eq!(after.pending_mutations, 0);
6546 assert_eq!(after.flush_started, 1);
6547 assert_eq!(after.flush_succeeded, 1);
6548 assert_eq!(after.last_flush_batch_size, 3);
6549 assert_eq!(
6550 after.last_flush_trigger,
6551 Some(AutosaveFlushTrigger::Periodic)
6552 );
6553 }
6554
6555 #[test]
6556 fn test_autosave_queue_backpressure_is_bounded() {
6557 let mut session = Session::create();
6558 session.set_autosave_queue_limit_for_test(2);
6559
6560 for i in 0..5 {
6561 session.append_message(make_test_message(&format!("message-{i}")));
6562 }
6563
6564 let metrics = session.autosave_metrics();
6565 assert_eq!(metrics.max_pending_mutations, 2);
6566 assert_eq!(metrics.pending_mutations, 2);
6567 assert_eq!(metrics.backpressure_events, 3);
6568 assert!(metrics.coalesced_mutations >= 4);
6569 }
6570
6571 #[test]
6572 fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
6573 let temp_dir = tempfile::tempdir().expect("temp dir");
6574
6575 let mut strict = Session::create();
6576 strict.path = Some(temp_dir.path().to_path_buf());
6578 strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
6579 strict.append_message(make_test_message("strict"));
6580
6581 run_async(async { strict.flush_autosave_on_shutdown().await })
6582 .expect_err("strict mode should propagate shutdown flush failure");
6583 let strict_metrics = strict.autosave_metrics();
6584 assert_eq!(strict_metrics.flush_failed, 1);
6585 assert!(strict_metrics.pending_mutations > 0);
6586
6587 let mut throughput = Session::create();
6588 throughput.path = Some(temp_dir.path().to_path_buf());
6589 throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
6590 throughput.append_message(make_test_message("throughput"));
6591
6592 run_async(async { throughput.flush_autosave_on_shutdown().await })
6593 .expect("throughput mode skips shutdown flush");
6594 let throughput_metrics = throughput.autosave_metrics();
6595 assert_eq!(throughput_metrics.flush_started, 0);
6596 assert_eq!(throughput_metrics.pending_mutations, 1);
6597 }
6598
6599 #[test]
6600 fn test_session_new_prefers_cli_durability_mode_over_config() {
6601 let cli =
6602 crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
6603 let config: Config =
6604 serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6605 let session =
6606 run_async(async { Session::new(&cli, &config).await }).expect("create session");
6607 assert_eq!(
6608 session.autosave_durability_mode(),
6609 AutosaveDurabilityMode::Strict
6610 );
6611 }
6612
6613 #[test]
6614 fn test_session_new_uses_config_durability_mode_when_cli_unset() {
6615 let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
6616 let config: Config =
6617 serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
6618 let session =
6619 run_async(async { Session::new(&cli, &config).await }).expect("create session");
6620 assert_eq!(
6621 session.autosave_durability_mode(),
6622 AutosaveDurabilityMode::Throughput
6623 );
6624 }
6625
6626 #[test]
6627 fn test_resolve_autosave_durability_mode_precedence() {
6628 assert_eq!(
6629 resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
6630 AutosaveDurabilityMode::Strict
6631 );
6632 assert_eq!(
6633 resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
6634 AutosaveDurabilityMode::Throughput
6635 );
6636 assert_eq!(
6637 resolve_autosave_durability_mode(None, None, Some("strict")),
6638 AutosaveDurabilityMode::Strict
6639 );
6640 assert_eq!(
6641 resolve_autosave_durability_mode(None, None, None),
6642 AutosaveDurabilityMode::Balanced
6643 );
6644 }
6645
6646 #[test]
6647 fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
6648 assert_eq!(
6649 resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
6650 AutosaveDurabilityMode::Throughput
6651 );
6652 assert_eq!(
6653 resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
6654 AutosaveDurabilityMode::Strict
6655 );
6656 assert_eq!(
6657 resolve_autosave_durability_mode(None, None, Some("bad")),
6658 AutosaveDurabilityMode::Balanced
6659 );
6660 }
6661
6662 #[test]
6663 fn test_get_share_viewer_url_matches_legacy() {
6664 assert_eq!(
6665 build_share_viewer_url(None, "gist-123"),
6666 "https://buildwithpi.ai/session/#gist-123"
6667 );
6668 assert_eq!(
6669 build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
6670 "https://example.com/session/#gist-123"
6671 );
6672 assert_eq!(
6673 build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
6674 "https://example.com/session#gist-123"
6675 );
6676 assert_eq!(
6679 build_share_viewer_url(Some(""), "gist-123"),
6680 "https://buildwithpi.ai/session/#gist-123"
6681 );
6682 }
6683
6684 #[test]
6685 fn test_session_linear_history() {
6686 let mut session = Session::in_memory();
6687
6688 let id1 = session.append_message(make_test_message("Hello"));
6689 let id2 = session.append_message(make_test_message("World"));
6690 let id3 = session.append_message(make_test_message("Test"));
6691
6692 assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
6694
6695 let path = session.get_path_to_entry(&id3);
6697 assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
6698
6699 let leaves = session.list_leaves();
6701 assert_eq!(leaves.len(), 1);
6702 assert_eq!(leaves[0], id3);
6703 }
6704
6705 #[test]
6706 fn test_session_branching() {
6707 let mut session = Session::in_memory();
6708
6709 let id_a = session.append_message(make_test_message("A"));
6711 let id_b = session.append_message(make_test_message("B"));
6712 let id_c = session.append_message(make_test_message("C"));
6713
6714 assert!(session.create_branch_from(&id_b));
6716 let id_d = session.append_message(make_test_message("D"));
6717
6718 let leaves = session.list_leaves();
6720 assert_eq!(leaves.len(), 2);
6721 assert!(leaves.contains(&id_c));
6722 assert!(leaves.contains(&id_d));
6723
6724 let path_to_d = session.get_path_to_entry(&id_d);
6726 assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
6727
6728 let path_to_c = session.get_path_to_entry(&id_c);
6730 assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6731 }
6732
6733 #[test]
6734 fn test_session_navigation() {
6735 let mut session = Session::in_memory();
6736
6737 let id1 = session.append_message(make_test_message("First"));
6738 let id2 = session.append_message(make_test_message("Second"));
6739
6740 assert!(session.navigate_to(&id1));
6742 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6743
6744 assert!(!session.navigate_to("nonexistent"));
6746 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
6748
6749 assert!(session.navigate_to(&id2));
6751 assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
6752 }
6753
6754 #[test]
6755 fn test_navigation_syncs_header_to_current_branch_metadata() {
6756 let mut session = Session::in_memory();
6757
6758 let root_id = session.append_message(make_test_message("root"));
6759 let openai_id = session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
6760 let high_id = session.append_thinking_level_change("high".to_string());
6761 let _tip_a = session.append_message(make_test_message("branch-a"));
6762
6763 assert!(session.create_branch_from(&root_id));
6764 session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6765 let minimal_id = session.append_thinking_level_change("minimal".to_string());
6766 let _tip_b = session.append_message(make_test_message("branch-b"));
6767
6768 assert!(session.navigate_to(&high_id));
6769 assert_eq!(session.header.provider.as_deref(), Some("openai"));
6770 assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6771 assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6772
6773 assert!(session.navigate_to(&minimal_id));
6774 assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6775 assert_eq!(session.header.model_id.as_deref(), Some("claude-sonnet-4"));
6776 assert_eq!(session.header.thinking_level.as_deref(), Some("minimal"));
6777
6778 assert!(session.navigate_to(&openai_id));
6779 assert_eq!(session.header.provider.as_deref(), Some("openai"));
6780 assert_eq!(session.header.model_id.as_deref(), Some("gpt-5.4"));
6781 }
6782
6783 #[test]
6784 fn test_navigation_clears_stale_header_metadata_when_target_branch_has_no_override() {
6785 let mut session = Session::in_memory();
6786
6787 let root_id = session.append_message(make_test_message("root"));
6788 let branch_a_tip = session.append_message(make_test_message("branch-a"));
6789
6790 assert!(session.create_branch_from(&root_id));
6791 session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6792 session.append_thinking_level_change("high".to_string());
6793 session.set_model_header(
6794 Some("anthropic".to_string()),
6795 Some("claude-sonnet-4".to_string()),
6796 Some("high".to_string()),
6797 );
6798
6799 assert!(session.navigate_to(&branch_a_tip));
6800 assert!(session.header.provider.is_none());
6801 assert!(session.header.model_id.is_none());
6802 assert!(session.header.thinking_level.is_none());
6803 }
6804
6805 #[test]
6806 fn test_open_materializes_header_fallback_for_historyless_branch_navigation() {
6807 let temp = tempfile::tempdir().expect("temp dir");
6808 let path = temp.path().join("legacy-historyless-branch.jsonl");
6809
6810 let mut legacy = Session::in_memory();
6811 legacy.header.provider = Some("openai".to_string());
6812 legacy.header.model_id = Some("gpt-5.4".to_string());
6813 legacy.header.thinking_level = Some("low".to_string());
6814
6815 let root_id = legacy.append_message(make_test_message("root"));
6816 let branch_b_tip = legacy.append_message(make_test_message("branch-b"));
6817
6818 assert!(legacy.create_branch_from(&root_id));
6819 legacy.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
6820 legacy.append_thinking_level_change("high".to_string());
6821 let branch_a_tip = legacy.append_message(make_test_message("branch-a"));
6822
6823 legacy.header.current_leaf = Some(branch_b_tip.clone());
6824
6825 let mut jsonl = serde_json::to_string(&legacy.header).expect("serialize legacy header");
6826 jsonl.push('\n');
6827 for entry in &legacy.entries {
6828 jsonl.push_str(&serde_json::to_string(entry).expect("serialize session entry"));
6829 jsonl.push('\n');
6830 }
6831 std::fs::write(&path, jsonl).expect("write legacy session");
6832
6833 let mut loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
6834 .expect("open legacy session");
6835
6836 assert_eq!(loaded.leaf_id.as_deref(), Some(branch_b_tip.as_str()));
6837 assert_eq!(loaded.header.fallback_provider.as_deref(), Some("openai"));
6838 assert_eq!(loaded.header.fallback_model_id.as_deref(), Some("gpt-5.4"));
6839 assert_eq!(
6840 loaded.header.fallback_thinking_level.as_deref(),
6841 Some("low")
6842 );
6843
6844 assert!(loaded.navigate_to(&branch_a_tip));
6845 assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
6846 assert_eq!(loaded.header.model_id.as_deref(), Some("claude-sonnet-4"));
6847 assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
6848
6849 assert!(loaded.navigate_to(&branch_b_tip));
6850 assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
6851 assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
6852 assert_eq!(loaded.header.thinking_level.as_deref(), Some("low"));
6853 }
6854
6855 #[test]
6856 fn test_session_get_children() {
6857 let mut session = Session::in_memory();
6858
6859 let id_a = session.append_message(make_test_message("A"));
6862 let id_b = session.append_message(make_test_message("B"));
6863 let _id_c = session.append_message(make_test_message("C"));
6864
6865 session.create_branch_from(&id_a);
6867 let id_d = session.append_message(make_test_message("D"));
6868
6869 let children_a = session.get_children(Some(&id_a));
6871 assert_eq!(children_a.len(), 2);
6872 assert!(children_a.contains(&id_b));
6873 assert!(children_a.contains(&id_d));
6874
6875 let root_children = session.get_children(None);
6877 assert_eq!(root_children.len(), 1);
6878 assert_eq!(root_children[0], id_a);
6879 }
6880
6881 #[test]
6882 fn test_branch_summary() {
6883 let mut session = Session::in_memory();
6884
6885 let id_a = session.append_message(make_test_message("A"));
6887 let id_b = session.append_message(make_test_message("B"));
6888
6889 let info = session.branch_summary();
6890 assert_eq!(info.total_entries, 2);
6891 assert_eq!(info.leaf_count, 1);
6892 assert_eq!(info.branch_point_count, 0);
6893
6894 session.create_branch_from(&id_a);
6896 let _id_c = session.append_message(make_test_message("C"));
6897
6898 let info = session.branch_summary();
6899 assert_eq!(info.total_entries, 3);
6900 assert_eq!(info.leaf_count, 2);
6901 assert_eq!(info.branch_point_count, 1);
6902 assert!(info.branch_points.contains(&id_a));
6903 assert!(info.leaves.contains(&id_b));
6904 }
6905
6906 fn build_branch_heavy_session(
6907 path: &Path,
6908 fork_count: usize,
6909 side_branch_len: usize,
6910 ) -> (Session, String) {
6911 let mut session = Session::create();
6912 session.path = Some(path.to_path_buf());
6913 let mut selected_tip = session.append_message(make_test_message("root"));
6914
6915 for fork_idx in 0..fork_count {
6916 assert!(
6917 session.navigate_to(&selected_tip),
6918 "navigate to selected tip before side branch {fork_idx}"
6919 );
6920 for side_idx in 0..side_branch_len {
6921 session.append_message(make_test_message(&format!("side-{fork_idx}-{side_idx}")));
6922 }
6923
6924 assert!(
6925 session.navigate_to(&selected_tip),
6926 "return to selected tip before active branch {fork_idx}"
6927 );
6928 selected_tip = session.append_message(make_test_message(&format!("active-{fork_idx}")));
6929 }
6930
6931 (session, selected_tip)
6932 }
6933
6934 const LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA: &str = "pi.session.large_replay_correctness.v1";
6935
6936 #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
6937 struct LargeReplayCorrectnessEvidence {
6938 schema: String,
6939 fixture: String,
6940 entry_count: usize,
6941 selected_depth: usize,
6942 replayed_entries: usize,
6943 skipped_sibling_entries: usize,
6944 index_initial_miss_files: usize,
6945 index_cached_hit_files: usize,
6946 index_cached_reused_files: usize,
6947 index_failed_files: usize,
6948 elapsed_budget_class: String,
6949 fallback_reason: Option<String>,
6950 baseline_message_count: usize,
6951 accelerated_message_count: usize,
6952 baseline_leaf: String,
6953 accelerated_leaf: String,
6954 verdict: String,
6955 }
6956
6957 fn cold_start_elapsed_budget_class(elapsed_us: u64) -> &'static str {
6958 match elapsed_us {
6959 0..=250_000 => "target",
6960 250_001..=1_000_000 => "bounded",
6961 _ => "observed_slow",
6962 }
6963 }
6964
6965 fn current_path_message_json(session: &Session) -> serde_json::Value {
6966 let mut value = serde_json::to_value(session.to_messages_for_current_path())
6967 .expect("serialize current-path messages");
6968 redact_json_timestamps(&mut value);
6969 value
6970 }
6971
6972 fn redact_json_timestamps(value: &mut serde_json::Value) {
6973 match value {
6974 serde_json::Value::Object(object) => {
6975 if object.contains_key("timestamp") {
6976 object.insert("timestamp".to_string(), serde_json::json!(0));
6977 }
6978 for child in object.values_mut() {
6979 redact_json_timestamps(child);
6980 }
6981 }
6982 serde_json::Value::Array(items) => {
6983 for item in items {
6984 redact_json_timestamps(item);
6985 }
6986 }
6987 serde_json::Value::Null
6988 | serde_json::Value::Bool(_)
6989 | serde_json::Value::Number(_)
6990 | serde_json::Value::String(_) => {}
6991 }
6992 }
6993
6994 fn append_large_replay_mixed_turns(session: &mut Session, selected_tip: &str) -> String {
6995 assert!(
6996 session.navigate_to(selected_tip),
6997 "return to active branch before mixed replay fixture"
6998 );
6999 let first_kept_id = selected_tip.to_string();
7000 session.append_model_change(
7001 "openai-responses".to_string(),
7002 "gpt-5.2-replay-harness".to_string(),
7003 );
7004 let tool_call_id = "call_large_replay";
7005 session.append_message(make_test_tool_call_message(tool_call_id));
7006 session.append_message(make_test_tool_result_message(tool_call_id));
7007 let aborted_id =
7008 session.append_message(make_test_aborted_assistant_message("interrupted assistant"));
7009 session.append_bash_execution(
7010 "cargo test session replay index".to_string(),
7011 "cancelled by operator".to_string(),
7012 130,
7013 true,
7014 false,
7015 None,
7016 );
7017 session.append_compaction(
7018 "large replay harness compaction".to_string(),
7019 first_kept_id,
7020 42_000,
7021 Some(serde_json::json!({
7022 "reason": "large_replay_correctness_harness",
7023 })),
7024 Some(false),
7025 );
7026 session.append_branch_summary(
7027 aborted_id,
7028 "interrupted turn branch summary".to_string(),
7029 Some(serde_json::json!({
7030 "turn_state": "interrupted",
7031 })),
7032 Some(false),
7033 );
7034 session.append_message(make_test_message("active-after-interrupted-turn"))
7035 }
7036
7037 #[allow(clippy::too_many_lines)]
7038 #[test]
7039 fn cold_start_replay_minimization_bounds_branch_heavy_v2_resume() {
7040 const FORKS: usize = 700;
7041 const SIDE_BRANCH_LEN: usize = 15;
7042 const MIXED_ACTIVE_ENTRIES: usize = 8;
7043 const MIXED_ACTIVE_REPLAYED_ENTRIES: usize = 7;
7044 const MIXED_ACTIVE_PROJECTED_MESSAGES: usize = 5;
7045
7046 let temp = tempdir_under_tmpdir("branch-heavy-v2-resume");
7047 let path = temp.path().join("branch-heavy.jsonl");
7048 let (mut session, mut selected_tip) =
7049 build_branch_heavy_session(&path, FORKS, SIDE_BRANCH_LEN);
7050 selected_tip = append_large_replay_mixed_turns(&mut session, &selected_tip);
7051 session.header.current_leaf = Some("stale-missing-leaf".to_string());
7052 run_async(async { session.save().await }).expect("save branch-heavy session");
7053
7054 let baseline_loaded =
7055 run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7056 .expect("open JSONL baseline session");
7057 let baseline_messages = current_path_message_json(&baseline_loaded);
7058
7059 create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7060
7061 let first_trace = run_async(async {
7062 Session::cold_start_trace_bundle(&path, temp.path())
7063 .await
7064 .expect("initial cold-start trace")
7065 });
7066 let trace = run_async(async {
7067 Session::cold_start_trace_bundle(&path, temp.path())
7068 .await
7069 .expect("cached cold-start trace")
7070 });
7071
7072 let accelerated_loaded =
7073 run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7074 .expect("open accelerated session");
7075 let accelerated_messages = current_path_message_json(&accelerated_loaded);
7076
7077 let expected_entries = 1 + (FORKS * (SIDE_BRANCH_LEN + 1)) + MIXED_ACTIVE_ENTRIES;
7078 let expected_depth = 1 + FORKS + MIXED_ACTIVE_ENTRIES;
7079 let expected_replayed_entries = 1 + FORKS + MIXED_ACTIVE_REPLAYED_ENTRIES;
7080 let expected_projected_messages = 1 + FORKS + MIXED_ACTIVE_PROJECTED_MESSAGES;
7081 assert_eq!(trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7082 assert_eq!(
7083 trace.replay_minimization.schema,
7084 SESSION_REPLAY_MINIMIZATION_TRACE_SCHEMA
7085 );
7086 assert_eq!(trace.storage.selected_backend, "v2_sidecar");
7087 assert_eq!(trace.storage.opened_backend, "v2_sidecar");
7088 assert_eq!(trace.input.total_entries, expected_depth);
7089 assert_eq!(trace.replay_minimization.entry_count, expected_entries);
7090 assert_eq!(trace.replay_minimization.branch_count, FORKS);
7091 assert_eq!(trace.replay_minimization.selected_depth, expected_depth);
7092 assert_eq!(
7093 trace.replay_minimization.replayed_entries,
7094 expected_replayed_entries
7095 );
7096 assert_eq!(
7097 trace.replay_minimization.skipped_sibling_entries,
7098 expected_entries - expected_depth
7099 );
7100 assert!(trace.replay_minimization.scanned_files >= 1);
7101 assert_eq!(trace.replay_minimization.fallback_behavior, None);
7102 assert_eq!(trace.replay_minimization.verdict, "bounded_selected_branch");
7103 assert_eq!(trace.compaction_scan.scanned_entries, expected_depth);
7104 assert_eq!(trace.compaction_scan.compaction_entries, 1);
7105 assert!(trace.compaction_scan.latest_compaction_present);
7106 assert_eq!(trace.compaction_scan.first_kept_entry_found, Some(true));
7107 assert_eq!(trace.first_render.current_path_entries, expected_depth);
7108 assert_eq!(
7109 trace.first_render.projected_messages,
7110 expected_projected_messages
7111 );
7112 assert_eq!(trace.first_render.tool_messages, 2);
7113 assert_eq!(trace.first_render.assistant_messages, 2);
7114 assert_eq!(trace.first_render.user_messages, 1 + FORKS + 1);
7115 assert_eq!(trace.first_render.system_messages, 0);
7116 assert!(first_trace.index_refresh.refreshed_files >= 1);
7117 assert_eq!(first_trace.index_refresh.failed_files, 0);
7118 assert!(trace.index_refresh.cache_hit_files >= 1);
7119 assert_eq!(
7120 trace.index_refresh.cache_hit_files,
7121 trace.index_refresh.reused_files
7122 );
7123 assert_eq!(trace.index_refresh.failed_files, 0);
7124
7125 assert_eq!(baseline_loaded.leaf_id(), Some(selected_tip.as_str()));
7126 assert_eq!(accelerated_loaded.leaf_id(), Some(selected_tip.as_str()));
7127 assert!(accelerated_loaded.v2_partial_hydration);
7128 assert_eq!(
7129 accelerated_loaded.v2_resume_mode,
7130 Some(V2OpenMode::ActivePath)
7131 );
7132 assert_eq!(
7133 accelerated_loaded.entries_for_current_path().len(),
7134 baseline_loaded.entries_for_current_path().len()
7135 );
7136 assert_eq!(
7137 accelerated_messages, baseline_messages,
7138 "accelerated V2 replay must match full JSONL replay"
7139 );
7140
7141 let evidence = LargeReplayCorrectnessEvidence {
7142 schema: LARGE_REPLAY_CORRECTNESS_EVIDENCE_SCHEMA.to_string(),
7143 fixture: "branch-heavy-v2-resume".to_string(),
7144 entry_count: trace.replay_minimization.entry_count,
7145 selected_depth: trace.replay_minimization.selected_depth,
7146 replayed_entries: trace.replay_minimization.replayed_entries,
7147 skipped_sibling_entries: trace.replay_minimization.skipped_sibling_entries,
7148 index_initial_miss_files: first_trace.index_refresh.refreshed_files,
7149 index_cached_hit_files: trace.index_refresh.cache_hit_files,
7150 index_cached_reused_files: trace.index_refresh.reused_files,
7151 index_failed_files: trace.index_refresh.failed_files,
7152 elapsed_budget_class: cold_start_elapsed_budget_class(trace.total_elapsed_us)
7153 .to_string(),
7154 fallback_reason: trace.replay_minimization.fallback_behavior.clone(),
7155 baseline_message_count: baseline_loaded.to_messages_for_current_path().len(),
7156 accelerated_message_count: accelerated_loaded.to_messages_for_current_path().len(),
7157 baseline_leaf: baseline_loaded.leaf_id().unwrap_or_default().to_string(),
7158 accelerated_leaf: accelerated_loaded.leaf_id().unwrap_or_default().to_string(),
7159 verdict: trace.replay_minimization.verdict,
7160 };
7161 assert_eq!(evidence.fallback_reason, None);
7162 assert_eq!(
7163 evidence.baseline_message_count,
7164 evidence.accelerated_message_count
7165 );
7166 assert!(matches!(
7167 evidence.elapsed_budget_class.as_str(),
7168 "target" | "bounded" | "observed_slow"
7169 ));
7170 let serialized = serde_json::to_string(&evidence).expect("serialize evidence");
7171 assert!(!serialized.contains("side-0-0"));
7172 let parsed: LargeReplayCorrectnessEvidence =
7173 serde_json::from_str(&serialized).expect("parse evidence");
7174 assert_eq!(parsed, evidence);
7175 }
7176
7177 #[test]
7178 fn cold_start_replay_minimization_reports_missing_and_stale_sidecar_fallbacks() {
7179 let temp = tempdir_under_tmpdir("branch-heavy-fallbacks");
7180 let path = temp.path().join("branch-fallback.jsonl");
7181 let (mut session, _selected_tip) = build_branch_heavy_session(&path, 12, 3);
7182 run_async(async { session.save().await }).expect("save branch-heavy session");
7183
7184 let missing_sidecar_trace = run_async(async {
7185 Session::cold_start_trace_bundle(&path, temp.path())
7186 .await
7187 .expect("missing sidecar trace")
7188 });
7189 assert_eq!(missing_sidecar_trace.storage.selected_backend, "jsonl");
7190 assert_eq!(missing_sidecar_trace.storage.opened_backend, "jsonl");
7191 assert_eq!(
7192 missing_sidecar_trace
7193 .replay_minimization
7194 .fallback_behavior
7195 .as_deref(),
7196 Some("jsonl_full_scan_without_sidecar")
7197 );
7198 assert_eq!(
7199 missing_sidecar_trace.replay_minimization.verdict,
7200 "fallback_explicit"
7201 );
7202 assert!(
7203 missing_sidecar_trace
7204 .replay_minimization
7205 .skipped_sibling_entries
7206 > 0
7207 );
7208
7209 let corrupt_path = temp.path().join("branch-corrupt-tail.jsonl");
7210 let (mut corrupt_session, _selected_tip) = build_branch_heavy_session(&corrupt_path, 8, 2);
7211 run_async(async { corrupt_session.save().await }).expect("save corrupt-tail fixture");
7212 {
7213 use std::io::Write as _;
7214
7215 let mut file = std::fs::OpenOptions::new()
7216 .append(true)
7217 .open(&corrupt_path)
7218 .expect("open corrupt-tail fixture");
7219 writeln!(file, "{{invalid-tail-frame").expect("append corrupt tail frame");
7220 }
7221
7222 let corrupt_tail_trace = run_async(async {
7223 Session::cold_start_trace_bundle(&corrupt_path, temp.path())
7224 .await
7225 .expect("corrupt tail trace")
7226 });
7227 assert_eq!(
7228 corrupt_tail_trace.open_diagnostics.skipped_entries, 1,
7229 "corrupt tail frames must be surfaced in cold-start diagnostics"
7230 );
7231 assert_eq!(
7232 corrupt_tail_trace
7233 .replay_minimization
7234 .fallback_behavior
7235 .as_deref(),
7236 Some("corrupt_jsonl_entries_skipped")
7237 );
7238 assert_eq!(
7239 corrupt_tail_trace.replay_minimization.verdict,
7240 "fallback_explicit"
7241 );
7242
7243 create_v2_sidecar_from_jsonl(&path).expect("create v2 sidecar");
7244 std::thread::sleep(Duration::from_millis(25));
7245 session.append_message(make_test_message("jsonl-tail-after-sidecar"));
7246 run_async(async { session.save().await }).expect("save stale jsonl tail");
7247
7248 let stale_sidecar_trace = run_async(async {
7249 Session::cold_start_trace_bundle(&path, temp.path())
7250 .await
7251 .expect("stale sidecar trace")
7252 });
7253 assert!(stale_sidecar_trace.storage.v2_sidecar_present);
7254 assert!(stale_sidecar_trace.storage.v2_sidecar_stale);
7255 assert_eq!(stale_sidecar_trace.storage.selected_backend, "jsonl");
7256 assert_eq!(stale_sidecar_trace.storage.opened_backend, "jsonl");
7257 assert_eq!(
7258 stale_sidecar_trace
7259 .replay_minimization
7260 .fallback_behavior
7261 .as_deref(),
7262 Some("v2_sidecar_stale")
7263 );
7264 assert_eq!(
7265 stale_sidecar_trace.replay_minimization.verdict,
7266 "fallback_explicit"
7267 );
7268 }
7269
7270 #[test]
7271 fn test_session_jsonl_serialization() {
7272 let temp = tempfile::tempdir().unwrap();
7273 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7274 session.header.provider = Some("anthropic".to_string());
7275 session.header.model_id = Some("claude-test".to_string());
7276 session.header.thinking_level = Some("medium".to_string());
7277
7278 let user_id = session.append_message(make_test_message("Hello"));
7279 let assistant = AssistantMessage {
7280 content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
7281 api: "anthropic".to_string(),
7282 provider: "anthropic".to_string(),
7283 model: "claude-test".to_string(),
7284 usage: Usage::default(),
7285 stop_reason: StopReason::Stop,
7286 error_message: None,
7287 timestamp: 0,
7288 };
7289 session.append_message(SessionMessage::Assistant { message: assistant });
7290 session.append_model_change("anthropic".to_string(), "claude-test".to_string());
7291 session.append_thinking_level_change("high".to_string());
7292 session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
7293 session.append_branch_summary(user_id, "branch".to_string(), None, None);
7294 session.append_session_info(Some("my-session".to_string()));
7295
7296 run_async(async { session.save().await }).unwrap();
7297
7298 let path = session.path.clone().unwrap();
7299 let contents = std::fs::read_to_string(path).unwrap();
7300 let mut lines = contents.lines();
7301
7302 let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
7303 assert_eq!(header["type"], "session");
7304 assert_eq!(header["version"], SESSION_VERSION);
7305
7306 let mut types = Vec::new();
7307 for line in lines {
7308 let value: serde_json::Value = serde_json::from_str(line).unwrap();
7309 let entry_type = value["type"].as_str().unwrap_or_default().to_string();
7310 types.push(entry_type);
7311 }
7312
7313 assert!(types.contains(&"message".to_string()));
7314 assert!(types.contains(&"model_change".to_string()));
7315 assert!(types.contains(&"thinking_level_change".to_string()));
7316 assert!(types.contains(&"compaction".to_string()));
7317 assert!(types.contains(&"branch_summary".to_string()));
7318 assert!(types.contains(&"session_info".to_string()));
7319 }
7320
7321 #[test]
7322 fn cold_start_trace_bundle_is_bounded_redacted_and_cache_aware() {
7323 let temp = tempdir_under_tmpdir("pi-session-cold-start-");
7324 if let Some(tmpdir) = env::var_os("TMPDIR") {
7325 assert!(temp.path().starts_with(PathBuf::from(tmpdir)));
7326 }
7327
7328 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7329 session.header.cwd = "/private/project/secret-cwd".to_string();
7330 session.header.provider = Some("test-provider".to_string());
7331 session.header.model_id = Some("test-model".to_string());
7332
7333 let mut first_kept_entry_id = None;
7334 for idx in 0..640 {
7335 let text = if idx == 7 {
7336 "secret-user-message should not appear in trace".to_string()
7337 } else {
7338 format!("large-history-message-{idx}")
7339 };
7340 let id = session.append_message(make_test_message(&text));
7341 if idx == 512 {
7342 first_kept_entry_id = Some(id);
7343 }
7344 if idx % 128 == 0 {
7345 session.append_message(make_test_assistant_message(
7346 &format!("secret-assistant-message-{idx}"),
7347 32,
7348 ));
7349 }
7350 }
7351
7352 session.append_compaction(
7353 "secret compaction summary should not appear in trace".to_string(),
7354 first_kept_entry_id.expect("first kept entry id"),
7355 12_345,
7356 None,
7357 None,
7358 );
7359 for idx in 0..16 {
7360 session.append_message(make_test_message(&format!("tail-message-{idx}")));
7361 }
7362
7363 run_async(async { session.save().await }).expect("save large session");
7364 let path = session.path.clone().expect("session path");
7365
7366 let first_trace = run_async(async {
7367 Session::cold_start_trace_bundle(&path, temp.path())
7368 .await
7369 .expect("first cold-start trace")
7370 });
7371 let second_trace = run_async(async {
7372 Session::cold_start_trace_bundle(&path, temp.path())
7373 .await
7374 .expect("second cold-start trace")
7375 });
7376
7377 assert_eq!(first_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7378 assert_eq!(second_trace.schema, SESSION_COLD_START_TRACE_SCHEMA);
7379 assert_eq!(second_trace.storage.selected_backend, "jsonl");
7380 assert_eq!(second_trace.storage.opened_backend, "jsonl");
7381 assert!(second_trace.index_refresh.scanned_files >= 1);
7382 assert!(second_trace.index_refresh.cache_hit_files >= 1);
7383 assert_eq!(
7384 second_trace.index_refresh.cache_hit_files,
7385 second_trace.index_refresh.reused_files
7386 );
7387 assert_eq!(
7388 second_trace.open_diagnostics,
7389 SessionColdStartOpenDiagnosticsTrace {
7390 skipped_entries: 0,
7391 orphaned_parent_links: 0,
7392 }
7393 );
7394 assert!(second_trace.compaction_scan.latest_compaction_present);
7395 assert_eq!(
7396 second_trace.compaction_scan.first_kept_entry_found,
7397 Some(true)
7398 );
7399 assert!(second_trace.first_render.ready);
7400 assert!(second_trace.first_render.current_path_entries >= 640);
7401 assert!(second_trace.first_render.projected_messages >= 640);
7402 assert!(second_trace.first_render.total_tokens >= 32);
7403
7404 let phase_names = second_trace
7405 .phases
7406 .iter()
7407 .map(|phase| phase.name.as_str())
7408 .collect::<Vec<_>>();
7409 assert_eq!(
7410 phase_names,
7411 vec![
7412 "session_open",
7413 "session_index_refresh",
7414 "compaction_scan",
7415 "first_render_ready",
7416 ]
7417 );
7418 assert!(second_trace.phases.len() <= second_trace.bounds.max_phase_count);
7419 assert!(!second_trace.bounds.raw_path_included);
7420 assert!(!second_trace.bounds.raw_cwd_included);
7421 assert!(!second_trace.bounds.raw_message_content_included);
7422
7423 let serialized = serde_json::to_string(&second_trace).expect("serialize trace");
7424 assert!(!serialized.contains("secret-user-message"));
7425 assert!(!serialized.contains("secret-assistant-message"));
7426 assert!(!serialized.contains("secret compaction summary"));
7427 assert!(!serialized.contains("secret-cwd"));
7428 assert!(!serialized.contains(&path.display().to_string()));
7429 assert!(!serialized.contains(&temp.path().display().to_string()));
7430 assert_eq!(second_trace.session_path_hash.len(), 16);
7431 }
7432
7433 #[test]
7434 fn test_save_handles_short_or_empty_session_id() {
7435 let temp = tempfile::tempdir().unwrap();
7436 let project_cwd = temp.path().join("project");
7437 std::fs::create_dir(&project_cwd).expect("create project cwd");
7438 let project_cwd = project_cwd.display().to_string();
7439
7440 let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7441 short_id_session.header.cwd.clone_from(&project_cwd);
7442 short_id_session.header.id = "x".to_string();
7443 run_async(async { short_id_session.save().await }).expect("save with short id");
7444 let short_name = short_id_session
7445 .path
7446 .as_ref()
7447 .and_then(|p| p.file_name())
7448 .and_then(|n| n.to_str())
7449 .expect("short id filename");
7450 assert!(short_name.contains("_x."));
7451
7452 let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7453 empty_id_session.header.cwd.clone_from(&project_cwd);
7454 empty_id_session.header.id.clear();
7455 run_async(async { empty_id_session.save().await }).expect("save with empty id");
7456 let empty_name = empty_id_session
7457 .path
7458 .as_ref()
7459 .and_then(|p| p.file_name())
7460 .and_then(|n| n.to_str())
7461 .expect("empty id filename");
7462 assert!(empty_name.contains("_session."));
7463
7464 let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7465 unsafe_id_session.header.cwd.clone_from(&project_cwd);
7466 unsafe_id_session.header.id = "../etc/passwd".to_string();
7467 run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
7468 let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
7469 let unsafe_name = unsafe_path
7470 .file_name()
7471 .and_then(|n| n.to_str())
7472 .expect("unsafe id filename");
7473 assert!(unsafe_name.contains("____etc_p."));
7474 let expected_dir = temp.path().join(encode_cwd(Path::new(&project_cwd)));
7475 assert_eq!(
7476 unsafe_path.parent().expect("unsafe id parent"),
7477 expected_dir.as_path()
7478 );
7479 }
7480
7481 #[test]
7482 fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
7483 let temp = tempfile::tempdir().unwrap();
7484 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7485
7486 let first_id = session.append_message(make_test_message("Hello"));
7487 let second_id = session.append_message(make_test_message("World"));
7488 assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
7489
7490 run_async(async { session.save().await }).unwrap();
7491 let path = session.path.clone().expect("session path set");
7492
7493 let mut lines = std::fs::read_to_string(&path)
7494 .expect("read session")
7495 .lines()
7496 .map(str::to_string)
7497 .collect::<Vec<_>>();
7498 assert!(lines.len() >= 3, "expected header + 2 entries");
7499
7500 let corrupted_line_number = lines.len(); let last_index = lines.len() - 1;
7502 lines[last_index] = "{ this is not json }".to_string();
7503
7504 let corrupted_path = temp.path().join("corrupted.jsonl");
7505 std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
7506 .expect("write corrupted session");
7507
7508 let (loaded, diagnostics) = run_async(async {
7509 Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
7510 })
7511 .expect("open corrupted session");
7512
7513 assert_eq!(diagnostics.skipped_entries.len(), 1);
7514 assert_eq!(
7515 diagnostics.skipped_entries[0].line_number,
7516 corrupted_line_number
7517 );
7518
7519 let warnings = diagnostics.warning_lines();
7520 assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
7521 assert!(
7522 warnings[0].starts_with(&format!(
7523 "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
7524 )),
7525 "unexpected warning: {}",
7526 warnings[0]
7527 );
7528 assert_eq!(
7529 warnings[1],
7530 "Warning: Skipped 1 corrupted entries while loading session"
7531 );
7532
7533 assert_eq!(
7534 loaded.entries.len(),
7535 session.entries.len() - 1,
7536 "expected last entry to be dropped"
7537 );
7538 assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
7539 }
7540
7541 #[test]
7542 fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
7543 let temp = tempfile::tempdir().unwrap();
7544 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7545
7546 let root_id = session.append_message(make_test_message("Hello"));
7547 session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
7548 session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
7549
7550 run_async(async { session.save().await }).unwrap();
7551 let path = session.path.clone().expect("session path set");
7552
7553 let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7554 .expect("reopen session");
7555
7556 assert!(loaded.entries.iter().any(|entry| {
7557 matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary.eq("compacted") && compaction.tokens_before.eq(&123))
7558 }));
7559 assert!(loaded.entries.iter().any(|entry| {
7560 matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary.eq("branch summary"))
7561 }));
7562
7563 let html = loaded.to_html();
7564 assert!(html.contains("compacted"));
7565 assert!(html.contains("branch summary"));
7566 }
7567
7568 #[test]
7569 fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
7570 let temp = tempfile::tempdir().unwrap();
7571 let base_dir = temp.path().join("sessions");
7572
7573 let mut session = Session::create_with_dir(Some(base_dir));
7574 session.append_message(make_test_message("Hello"));
7575
7576 run_async(async { session.save().await }).expect("initial save");
7577 let path = session.path.clone().expect("session path set");
7578
7579 let path1 = path.clone();
7580 let path2 = path.clone();
7581
7582 let t1 = std::thread::spawn(move || {
7583 let runtime = RuntimeBuilder::current_thread()
7584 .build()
7585 .expect("build runtime");
7586 runtime.block_on(async move {
7587 let mut s = Session::open(path1.to_string_lossy().as_ref())
7588 .await
7589 .expect("open session");
7590 s.append_message(make_test_message("From thread 1"));
7591 s.save().await
7592 })
7593 });
7594
7595 let t2 = std::thread::spawn(move || {
7596 let runtime = RuntimeBuilder::current_thread()
7597 .build()
7598 .expect("build runtime");
7599 runtime.block_on(async move {
7600 let mut s = Session::open(path2.to_string_lossy().as_ref())
7601 .await
7602 .expect("open session");
7603 s.append_message(make_test_message("From thread 2"));
7604 s.save().await
7605 })
7606 });
7607
7608 let r1 = t1.join().expect("thread 1 join");
7609 let r2 = t2.join().expect("thread 2 join");
7610 assert!(
7611 r1.is_ok() || r2.is_ok(),
7612 "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
7613 );
7614
7615 let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
7616 .expect("open after concurrent saves");
7617 assert!(!loaded.entries.is_empty());
7618 }
7619
7620 #[test]
7621 fn test_to_messages_for_current_path() {
7622 let mut session = Session::in_memory();
7623
7624 let _id_a = session.append_message(make_test_message("A"));
7628 let id_b = session.append_message(make_test_message("B"));
7629 let _id_c = session.append_message(make_test_message("C"));
7630
7631 session.create_branch_from(&id_b);
7633 let id_d = session.append_message(make_test_message("D"));
7634
7635 session.navigate_to(&id_d);
7637 let messages = session.to_messages_for_current_path();
7638 assert_eq!(messages.len(), 3);
7639
7640 if let Message::User(user) = &messages[0] {
7642 if let UserContent::Text(text) = &user.content {
7643 assert_eq!(text, "A");
7644 }
7645 }
7646 if let Message::User(user) = &messages[2] {
7647 if let UserContent::Text(text) = &user.content {
7648 assert_eq!(text, "D");
7649 }
7650 }
7651 }
7652
7653 #[test]
7654 fn test_reset_leaf_produces_empty_current_path() {
7655 let mut session = Session::in_memory();
7656
7657 let _id_a = session.append_message(make_test_message("A"));
7658 let _id_b = session.append_message(make_test_message("B"));
7659
7660 session.reset_leaf();
7661 assert!(session.entries_for_current_path().is_empty());
7662 assert!(session.to_messages_for_current_path().is_empty());
7663
7664 let id_root = session.append_message(make_test_message("Root"));
7666 let entry = session.get_entry(&id_root).expect("entry");
7667 assert!(entry.base().parent_id.is_none());
7668 }
7669
7670 #[test]
7671 fn test_encode_cwd() {
7672 let path = std::path::Path::new("/home/user/project");
7673 let encoded = encode_cwd(path);
7674 assert!(encoded.starts_with("--"));
7675 assert!(encoded.ends_with("--"));
7676 assert!(encoded.contains("home-user-project"));
7677 }
7678
7679 #[test]
7684 fn test_session_header_defaults() {
7685 let header = SessionHeader::new();
7686 assert_eq!(header.r#type, "session");
7687 assert_eq!(header.version, Some(SESSION_VERSION));
7688 assert!(!header.id.is_empty());
7689 assert!(!header.timestamp.is_empty());
7690 assert!(header.provider.is_none());
7691 assert!(header.model_id.is_none());
7692 assert!(header.thinking_level.is_none());
7693 assert!(header.parent_session.is_none());
7694 }
7695
7696 #[test]
7697 fn test_session_create_produces_unique_ids() {
7698 let s1 = Session::create();
7699 let s2 = Session::create();
7700 assert_ne!(s1.header.id, s2.header.id);
7701 }
7702
7703 #[test]
7704 fn test_in_memory_session_has_no_path() {
7705 let session = Session::in_memory();
7706 assert!(session.path.is_none());
7707 assert!(session.leaf_id.is_none());
7708 assert!(session.entries.is_empty());
7709 }
7710
7711 #[test]
7712 fn test_create_with_dir_stores_session_dir() {
7713 let temp = tempfile::tempdir().unwrap();
7714 let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7715 assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
7716 }
7717
7718 #[test]
7723 fn test_append_tool_result_message() {
7724 let mut session = Session::in_memory();
7725 let user_id = session.append_message(make_test_message("Hello"));
7726
7727 let tool_msg = SessionMessage::ToolResult {
7728 tool_call_id: "call_123".to_string(),
7729 tool_name: "read".to_string(),
7730 content: vec![ContentBlock::Text(TextContent::new("file contents"))],
7731 details: None,
7732 is_error: false,
7733 timestamp: Some(1000),
7734 };
7735 let tool_id = session.append_message(tool_msg);
7736
7737 let entry = session.get_entry(&tool_id).unwrap();
7739 assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
7740
7741 let messages = session.to_messages();
7743 assert_eq!(messages.len(), 2);
7744 assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id.eq("call_123")));
7745 }
7746
7747 #[test]
7748 fn test_tool_result_artifact_metadata_round_trip_without_full_payload() {
7749 let temp = tempfile::tempdir().unwrap();
7750 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
7751 session.append_message(make_test_message("list the huge directory"));
7752
7753 let omitted_payload = "x".repeat(1024);
7754 let preview = "entry-0000.txt\nentry-0001.txt\n\n[Full tool output artifact: /tmp/pi-tool-artifacts/call/abc.txt (5000000 bytes, 50000 lines, sha256 abc). Use read on this path to inspect more.]";
7755 session.append_message(SessionMessage::ToolResult {
7756 tool_call_id: "call_artifact".to_string(),
7757 tool_name: "ls".to_string(),
7758 content: vec![ContentBlock::Text(TextContent::new(preview))],
7759 details: Some(serde_json::json!({
7760 "artifact": {
7761 "schema": "pi.tool_output_artifact.v1",
7762 "id": "tool-artifact-abc",
7763 "toolName": "ls",
7764 "sourceKind": "directoryEntries",
7765 "path": "/tmp/pi-tool-artifacts/call/abc.txt",
7766 "metadataPath": "/tmp/pi-tool-artifacts/call/abc.json",
7767 "sha256": "abc",
7768 "byteCount": 5_000_000_u64,
7769 "lineCount": 50_000,
7770 "previewBytes": preview.len(),
7771 "contentType": "text/plain; charset=utf-8"
7772 }
7773 })),
7774 is_error: false,
7775 timestamp: Some(12346),
7776 });
7777
7778 run_async(async { session.save().await }).unwrap();
7779 let path = session.path.clone().unwrap();
7780 let jsonl = std::fs::read_to_string(&path).unwrap();
7781 assert!(jsonl.contains("\"schema\":\"pi.tool_output_artifact.v1\""));
7782 assert!(!jsonl.contains(&omitted_payload));
7783
7784 let loaded =
7785 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7786 let tool_result = loaded
7787 .to_messages()
7788 .into_iter()
7789 .find_map(|message| match message {
7790 Message::ToolResult(result) if result.tool_call_id.eq("call_artifact") => {
7791 Some(result)
7792 }
7793 _ => None,
7794 })
7795 .expect("artifact tool result");
7796
7797 assert_eq!(
7798 tool_result
7799 .details
7800 .as_ref()
7801 .and_then(|details| details.pointer("/artifact/schema"))
7802 .and_then(Value::as_str),
7803 Some("pi.tool_output_artifact.v1")
7804 );
7805 assert!(tool_result.content.iter().all(|block| match block {
7806 ContentBlock::Text(text) => !text.text.contains(&omitted_payload),
7807 _ => true,
7808 }));
7809 }
7810
7811 #[test]
7812 fn test_append_tool_result_error() {
7813 let mut session = Session::in_memory();
7814 session.append_message(make_test_message("Hello"));
7815
7816 let tool_msg = SessionMessage::ToolResult {
7817 tool_call_id: "call_err".to_string(),
7818 tool_name: "bash".to_string(),
7819 content: vec![ContentBlock::Text(TextContent::new("command not found"))],
7820 details: None,
7821 is_error: true,
7822 timestamp: Some(2000),
7823 };
7824 let tool_id = session.append_message(tool_msg);
7825
7826 let entry = session.get_entry(&tool_id).expect("should find tool entry");
7827 if let SessionEntry::Message(msg) = entry {
7828 if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
7829 assert!(is_error);
7830 } else {
7831 test_fail!("Expected SessionMessage::ToolResult, got {:?}", msg.message);
7832 }
7833 } else {
7834 test_fail!("Expected SessionEntry::Message");
7835 }
7836 }
7837
7838 #[test]
7839 fn test_append_bash_execution() {
7840 let mut session = Session::in_memory();
7841 session.append_message(make_test_message("run something"));
7842
7843 let bash_id = session.append_bash_execution(
7844 "echo hello".to_string(),
7845 "hello\n".to_string(),
7846 0,
7847 false,
7848 false,
7849 None,
7850 );
7851
7852 let entry = session.get_entry(&bash_id).expect("should find bash entry");
7853 if let SessionEntry::Message(msg) = entry {
7854 if let SessionMessage::BashExecution {
7855 command, exit_code, ..
7856 } = &msg.message
7857 {
7858 assert_eq!(command, "echo hello");
7859 assert_eq!(*exit_code, 0);
7860 } else {
7861 test_fail!(
7862 "Expected SessionMessage::BashExecution, got {:?}",
7863 msg.message
7864 );
7865 }
7866 } else {
7867 test_fail!("Expected SessionEntry::Message");
7868 }
7869
7870 let messages = session.to_messages();
7872 assert_eq!(messages.len(), 2);
7873 assert!(matches!(&messages[1], Message::User(_)));
7874 }
7875
7876 #[test]
7877 fn test_bash_execution_exclude_from_context() {
7878 let mut session = Session::in_memory();
7879 session.append_message(make_test_message("run something"));
7880
7881 let id = session.next_entry_id();
7882 let base = EntryBase::new(session.leaf_id.clone(), id.clone());
7883 let mut extra = HashMap::new();
7884 extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
7885 let entry = SessionEntry::Message(MessageEntry {
7886 base,
7887 message: SessionMessage::BashExecution {
7888 command: "secret".to_string(),
7889 output: "hidden".to_string(),
7890 exit_code: 0,
7891 cancelled: None,
7892 truncated: None,
7893 full_output_path: None,
7894 timestamp: Some(0),
7895 extra,
7896 },
7897 });
7898 session.leaf_id = Some(id);
7899 session.entries.push(entry);
7900 session.entry_ids = entry_id_set(&session.entries);
7901
7902 let messages = session.to_messages();
7904 assert_eq!(messages.len(), 1); }
7906
7907 #[test]
7908 fn test_append_custom_message() {
7909 let mut session = Session::in_memory();
7910 session.append_message(make_test_message("Hello"));
7911
7912 let custom_msg = SessionMessage::Custom {
7913 custom_type: "extension_state".to_string(),
7914 content: "some state".to_string(),
7915 display: false,
7916 details: Some(serde_json::json!({"key": "value"})),
7917 timestamp: Some(0),
7918 };
7919 let custom_id = session.append_message(custom_msg);
7920
7921 let entry = session
7922 .get_entry(&custom_id)
7923 .expect("should find custom entry");
7924 if let SessionEntry::Message(msg) = entry {
7925 if let SessionMessage::Custom {
7926 custom_type,
7927 display,
7928 ..
7929 } = &msg.message
7930 {
7931 assert_eq!(custom_type, "extension_state");
7932 assert!(!display);
7933 } else {
7934 test_fail!("Expected SessionMessage::Custom, got {:?}", msg.message);
7935 }
7936 } else {
7937 test_fail!("Expected SessionEntry::Message");
7938 }
7939 }
7940
7941 #[test]
7942 fn test_append_custom_entry() {
7943 let mut session = Session::in_memory();
7944 let root_id = session.append_message(make_test_message("Hello"));
7945
7946 let custom_id =
7947 session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
7948
7949 let entry = session
7950 .get_entry(&custom_id)
7951 .expect("should find custom entry");
7952 if let SessionEntry::Custom(custom) = entry {
7953 assert_eq!(custom.custom_type, "my_type");
7954 assert_eq!(custom.data, Some(serde_json::json!(42)));
7955 assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
7956 } else {
7957 test_fail!("Expected SessionEntry::Custom, got {:?}", entry);
7958 }
7959 }
7960
7961 #[test]
7966 fn test_parent_linking_chain() {
7967 let mut session = Session::in_memory();
7968
7969 let id1 = session.append_message(make_test_message("A"));
7970 let id2 = session.append_message(make_test_message("B"));
7971 let id3 = session.append_message(make_test_message("C"));
7972
7973 let e1 = session.get_entry(&id1).unwrap();
7975 assert!(e1.base().parent_id.is_none());
7976
7977 let e2 = session.get_entry(&id2).unwrap();
7979 assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
7980
7981 let e3 = session.get_entry(&id3).unwrap();
7983 assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
7984 }
7985
7986 #[test]
7987 fn test_model_change_updates_leaf() {
7988 let mut session = Session::in_memory();
7989
7990 let msg_id = session.append_message(make_test_message("Hello"));
7991 let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
7992
7993 assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
7994
7995 let entry = session
7996 .get_entry(&change_id)
7997 .expect("should find change entry");
7998 assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
7999
8000 if let SessionEntry::ModelChange(mc) = entry {
8001 assert_eq!(mc.provider, "openai");
8002 assert_eq!(mc.model_id, "gpt-4");
8003 } else {
8004 test_fail!("Expected SessionEntry::ModelChange, got {:?}", entry);
8005 }
8006 }
8007
8008 #[test]
8009 fn test_thinking_level_change_updates_leaf() {
8010 let mut session = Session::in_memory();
8011 session.append_message(make_test_message("Hello"));
8012
8013 let change_id = session.append_thinking_level_change("high".to_string());
8014 assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
8015
8016 let entry = session
8017 .get_entry(&change_id)
8018 .expect("should find change entry");
8019 if let SessionEntry::ThinkingLevelChange(tlc) = entry {
8020 assert_eq!(tlc.thinking_level, "high");
8021 } else {
8022 test_fail!(
8023 "Expected SessionEntry::ThinkingLevelChange, got {:?}",
8024 entry
8025 );
8026 }
8027 }
8028
8029 #[test]
8034 fn test_get_name_returns_latest() {
8035 let mut session = Session::in_memory();
8036
8037 assert!(session.get_name().is_none());
8038
8039 session.set_name("first");
8040 assert_eq!(session.get_name().as_deref(), Some("first"));
8041
8042 session.set_name("second");
8043 assert_eq!(session.get_name().as_deref(), Some("second"));
8044 }
8045
8046 #[test]
8047 fn test_set_name_returns_entry_id() {
8048 let mut session = Session::in_memory();
8049 let id = session.set_name("test-name");
8050 assert!(!id.is_empty());
8051 let entry = session.get_entry(&id).unwrap();
8052 assert!(matches!(entry, SessionEntry::SessionInfo(_)));
8053 }
8054
8055 #[test]
8060 fn test_add_label_to_existing_entry() {
8061 let mut session = Session::in_memory();
8062 let msg_id = session.append_message(make_test_message("Hello"));
8063
8064 let label_id = session.add_label(&msg_id, Some("important".to_string()));
8065 assert!(label_id.is_some());
8066
8067 let entry = session
8068 .get_entry(&label_id.unwrap())
8069 .expect("should find label entry");
8070 if let SessionEntry::Label(label) = entry {
8071 assert_eq!(label.target_id, msg_id);
8072 assert_eq!(label.label.as_deref(), Some("important"));
8073 } else {
8074 test_fail!("Expected SessionEntry::Label, got {:?}", entry);
8075 }
8076 }
8077
8078 #[test]
8079 fn test_add_label_to_nonexistent_entry_returns_none() {
8080 let mut session = Session::in_memory();
8081 let result = session.add_label("nonexistent", Some("label".to_string()));
8082 assert!(result.is_none());
8083 }
8084
8085 #[test]
8090 fn test_round_trip_preserves_all_message_types() {
8091 let temp = tempfile::tempdir().unwrap();
8092 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8093
8094 session.append_message(make_test_message("user text"));
8096
8097 let assistant = AssistantMessage {
8098 content: vec![ContentBlock::Text(TextContent::new("response"))],
8099 api: "anthropic".to_string(),
8100 provider: "anthropic".to_string(),
8101 model: "claude-test".to_string(),
8102 usage: Usage::default(),
8103 stop_reason: StopReason::Stop,
8104 error_message: None,
8105 timestamp: 0,
8106 };
8107 session.append_message(SessionMessage::Assistant { message: assistant });
8108
8109 session.append_message(SessionMessage::ToolResult {
8110 tool_call_id: "call_1".to_string(),
8111 tool_name: "read".to_string(),
8112 content: vec![ContentBlock::Text(TextContent::new("result"))],
8113 details: None,
8114 is_error: false,
8115 timestamp: Some(100),
8116 });
8117
8118 session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
8119
8120 session.append_custom_entry(
8121 "ext_data".to_string(),
8122 Some(serde_json::json!({"foo": "bar"})),
8123 );
8124
8125 run_async(async { session.save().await }).unwrap();
8126 let path = session.path.clone().unwrap();
8127
8128 let loaded =
8129 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8130
8131 assert_eq!(loaded.entries.len(), session.entries.len());
8132 assert_eq!(loaded.header.id, session.header.id);
8133 assert_eq!(loaded.header.version, Some(SESSION_VERSION));
8134
8135 let has_tool_result = loaded.entries.iter().any(|e| {
8137 matches!(
8138 e,
8139 SessionEntry::Message(m) if matches!(
8140 &m.message,
8141 SessionMessage::ToolResult { tool_name, .. } if tool_name.eq("read")
8142 )
8143 )
8144 });
8145 assert!(has_tool_result, "tool result should survive round-trip");
8146
8147 let has_bash = loaded.entries.iter().any(|e| {
8148 matches!(
8149 e,
8150 SessionEntry::Message(m) if matches!(
8151 &m.message,
8152 SessionMessage::BashExecution { command, .. } if command.eq("ls")
8153 )
8154 )
8155 });
8156 assert!(has_bash, "bash execution should survive round-trip");
8157
8158 let has_custom = loaded.entries.iter().any(|e| {
8159 matches!(
8160 e,
8161 SessionEntry::Custom(c) if c.custom_type.eq("ext_data")
8162 )
8163 });
8164 assert!(has_custom, "custom entry should survive round-trip");
8165 }
8166
8167 #[test]
8168 fn test_round_trip_preserves_leaf_id() {
8169 let temp = tempfile::tempdir().unwrap();
8170 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8171
8172 let _id1 = session.append_message(make_test_message("A"));
8173 let id2 = session.append_message(make_test_message("B"));
8174
8175 run_async(async { session.save().await }).unwrap();
8176 let path = session.path.clone().unwrap();
8177
8178 let loaded =
8179 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8180
8181 assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
8182 }
8183
8184 #[test]
8185 fn test_round_trip_preserves_selected_branch_leaf_and_header_state() {
8186 let temp = tempfile::tempdir().unwrap();
8187 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8188
8189 let root_id = session.append_message(make_test_message("root"));
8190 let _openai_model =
8191 session.append_model_change("openai".to_string(), "gpt-5.4".to_string());
8192 session.set_model_header(
8193 Some("openai".to_string()),
8194 Some("gpt-5.4".to_string()),
8195 None,
8196 );
8197 let high_id = session.append_thinking_level_change("high".to_string());
8198 session.set_model_header(None, None, Some("high".to_string()));
8199
8200 assert!(session.create_branch_from(&root_id));
8201 let _anthropic_model =
8202 session.append_model_change("anthropic".to_string(), "claude-sonnet-4".to_string());
8203 session.set_model_header(
8204 Some("anthropic".to_string()),
8205 Some("claude-sonnet-4".to_string()),
8206 None,
8207 );
8208 session.append_thinking_level_change("medium".to_string());
8209 session.set_model_header(None, None, Some("medium".to_string()));
8210
8211 assert!(session.navigate_to(&high_id));
8212
8213 run_async(async { session.save().await }).unwrap();
8214 let path = session.path.clone().unwrap();
8215
8216 let loaded =
8217 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8218
8219 assert_eq!(loaded.leaf_id.as_deref(), Some(high_id.as_str()));
8220 assert_eq!(
8221 loaded.header.current_leaf.as_deref(),
8222 Some(high_id.as_str())
8223 );
8224 assert_eq!(loaded.header.provider.as_deref(), Some("openai"));
8225 assert_eq!(loaded.header.model_id.as_deref(), Some("gpt-5.4"));
8226 assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8227 }
8228
8229 #[test]
8230 fn test_append_after_branch_navigation_clears_persisted_leaf_override() {
8231 let temp = tempfile::tempdir().unwrap();
8232 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8233
8234 let id_a = session.append_message(make_test_message("A"));
8235 let id_b = session.append_message(make_test_message("B"));
8236 session.append_message(make_test_message("C"));
8237
8238 assert!(session.create_branch_from(&id_a));
8239 session.append_message(make_test_message("D"));
8240
8241 assert!(session.navigate_to(&id_b));
8242 let id_e = session.append_message(make_test_message("E"));
8243
8244 run_async(async { session.save().await }).unwrap();
8245 let path = session.path.clone().unwrap();
8246
8247 let loaded =
8248 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8249
8250 assert_eq!(loaded.leaf_id.as_deref(), Some(id_e.as_str()));
8251 assert!(loaded.header.current_leaf.is_none());
8252 }
8253
8254 #[test]
8255 fn test_round_trip_preserves_header_fields() {
8256 let temp = tempfile::tempdir().unwrap();
8257 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8258 session.header.provider = Some("anthropic".to_string());
8259 session.header.model_id = Some("claude-opus".to_string());
8260 session.header.thinking_level = Some("high".to_string());
8261 session.header.parent_session = Some("/old/session.jsonl".to_string());
8262
8263 session.append_message(make_test_message("Hello"));
8264 run_async(async { session.save().await }).unwrap();
8265 let path = session.path.clone().unwrap();
8266
8267 let loaded =
8268 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8269
8270 assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
8271 assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
8272 assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
8273 assert_eq!(
8274 loaded.header.parent_session.as_deref(),
8275 Some("/old/session.jsonl")
8276 );
8277 }
8278
8279 #[test]
8280 fn test_empty_session_save_and_reload() {
8281 let temp = tempfile::tempdir().unwrap();
8282 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8283
8284 run_async(async { session.save().await }).unwrap();
8285 let path = session.path.clone().unwrap();
8286
8287 let loaded =
8288 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8289
8290 assert!(loaded.entries.is_empty());
8291 assert!(loaded.leaf_id.is_none());
8292 assert_eq!(loaded.header.id, session.header.id);
8293 }
8294
8295 #[test]
8300 fn test_corrupted_middle_entry_preserves_surrounding_entries() {
8301 let temp = tempfile::tempdir().unwrap();
8302 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8303
8304 let id1 = session.append_message(make_test_message("First"));
8305 let id2 = session.append_message(make_test_message("Second"));
8306 let id3 = session.append_message(make_test_message("Third"));
8307
8308 run_async(async { session.save().await }).unwrap();
8309 let path = session.path.clone().unwrap();
8310
8311 let mut lines: Vec<String> = std::fs::read_to_string(&path)
8313 .unwrap()
8314 .lines()
8315 .map(str::to_string)
8316 .collect();
8317 assert!(lines.len() >= 4);
8318 lines[2] = "GARBAGE JSON".to_string();
8319 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8320
8321 let (loaded, diagnostics) = run_async(async {
8322 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8323 })
8324 .unwrap();
8325
8326 let diag = serde_json::json!({
8327 "fixture_id": "session-corrupted-middle-entry-replay-integrity",
8328 "path": path.display().to_string(),
8329 "seed": "deterministic-static",
8330 "env": {
8331 "os": std::env::consts::OS,
8332 "arch": std::env::consts::ARCH,
8333 },
8334 "expected": {
8335 "skipped_entries": 1,
8336 "orphaned_parent_links": 1,
8337 },
8338 "actual": {
8339 "skipped_entries": diagnostics.skipped_entries.len(),
8340 "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
8341 "leaf_id": loaded.leaf_id,
8342 },
8343 })
8344 .to_string();
8345
8346 assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
8347 assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
8348 assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
8349 assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
8350 assert_eq!(
8351 diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
8352 "{diag}"
8353 );
8354 assert!(
8355 diagnostics.warning_lines().iter().any(|line| {
8356 line.contains("references missing parent")
8357 && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
8358 }),
8359 "{diag}"
8360 );
8361
8362 assert_eq!(loaded.entries.len(), 2, "{diag}");
8364 assert!(loaded.get_entry(&id1).is_some(), "{diag}");
8365 assert!(loaded.get_entry(&id3).is_some(), "{diag}");
8366 }
8367
8368 #[test]
8369 fn test_multiple_corrupted_entries_recovery() {
8370 let temp = tempfile::tempdir().unwrap();
8371 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8372
8373 session.append_message(make_test_message("A"));
8374 session.append_message(make_test_message("B"));
8375 session.append_message(make_test_message("C"));
8376 session.append_message(make_test_message("D"));
8377
8378 run_async(async { session.save().await }).unwrap();
8379 let path = session.path.clone().unwrap();
8380
8381 let mut lines: Vec<String> = std::fs::read_to_string(&path)
8382 .unwrap()
8383 .lines()
8384 .map(str::to_string)
8385 .collect();
8386 lines[2] = "BAD".to_string();
8388 lines[4] = "ALSO BAD".to_string();
8389 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
8390
8391 let (loaded, diagnostics) = run_async(async {
8392 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8393 })
8394 .unwrap();
8395
8396 assert_eq!(diagnostics.skipped_entries.len(), 2);
8397 assert_eq!(loaded.entries.len(), 2); }
8399
8400 #[test]
8401 fn test_corrupted_header_fails_to_open() {
8402 let temp = tempfile::tempdir().unwrap();
8403 let path = temp.path().join("bad_header.jsonl");
8404 std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
8405
8406 let result = run_async(async {
8407 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
8408 });
8409 assert!(
8410 result.is_err(),
8411 "corrupted header should cause open failure"
8412 );
8413 }
8414
8415 #[test]
8420 fn test_create_branch_from_nonexistent_returns_false() {
8421 let mut session = Session::in_memory();
8422 session.append_message(make_test_message("A"));
8423 assert!(!session.create_branch_from("nonexistent"));
8424 }
8425
8426 #[test]
8427 fn test_deep_branching() {
8428 let mut session = Session::in_memory();
8429
8430 let id_a = session.append_message(make_test_message("A"));
8432 let id_b = session.append_message(make_test_message("B"));
8433 let _id_c = session.append_message(make_test_message("C"));
8434
8435 session.create_branch_from(&id_a);
8437 let _id_d = session.append_message(make_test_message("D"));
8438
8439 session.create_branch_from(&id_b);
8441 let id_e = session.append_message(make_test_message("E"));
8442
8443 let leaves = session.list_leaves();
8445 assert_eq!(leaves.len(), 3);
8446
8447 let path = session.get_path_to_entry(&id_e);
8449 assert_eq!(path.len(), 3);
8450 assert_eq!(path[0], id_a);
8451 assert_eq!(path[1], id_b);
8452 assert_eq!(path[2], id_e);
8453 }
8454
8455 #[test]
8456 fn test_sibling_branches_at_fork() {
8457 let mut session = Session::in_memory();
8458
8459 let id_a = session.append_message(make_test_message("A"));
8461 let _id_b = session.append_message(make_test_message("B"));
8462 let _id_c = session.append_message(make_test_message("C"));
8463
8464 session.create_branch_from(&id_a);
8466 let id_d = session.append_message(make_test_message("D"));
8467
8468 session.navigate_to(&id_d);
8470
8471 let siblings = session.sibling_branches();
8472 assert!(siblings.is_some());
8473 let (fork_point, branches) = siblings.unwrap();
8474 assert!(
8475 fork_point.is_none()
8476 || fork_point
8477 .as_deref()
8478 .is_some_and(|fork_point_id| fork_point_id.eq(id_a.as_str()))
8479 );
8480 assert_eq!(branches.len(), 2);
8481
8482 let current_count = branches.iter().filter(|b| b.is_current).count();
8484 assert_eq!(current_count, 1);
8485 }
8486
8487 #[test]
8488 fn test_sibling_branches_no_fork() {
8489 let mut session = Session::in_memory();
8490 session.append_message(make_test_message("A"));
8491 session.append_message(make_test_message("B"));
8492
8493 assert!(session.sibling_branches().is_none());
8495 }
8496
8497 #[test]
8502 fn test_plan_fork_from_user_message() {
8503 let mut session = Session::in_memory();
8504
8505 let _id_a = session.append_message(make_test_message("First question"));
8506 let assistant = AssistantMessage {
8507 content: vec![ContentBlock::Text(TextContent::new("Answer"))],
8508 api: "anthropic".to_string(),
8509 provider: "anthropic".to_string(),
8510 model: "test".to_string(),
8511 usage: Usage::default(),
8512 stop_reason: StopReason::Stop,
8513 error_message: None,
8514 timestamp: 0,
8515 };
8516 let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
8517 let id_c = session.append_message(make_test_message("Second question"));
8518
8519 let plan = session.plan_fork_from_user_message(&id_c).unwrap();
8521 assert_eq!(plan.selected_text, "Second question");
8522 assert_eq!(plan.entries.len(), 2); }
8525
8526 #[test]
8527 fn test_plan_fork_from_root_message() {
8528 let mut session = Session::in_memory();
8529 let id_a = session.append_message(make_test_message("Root question"));
8530
8531 let plan = session.plan_fork_from_user_message(&id_a).unwrap();
8532 assert_eq!(plan.selected_text, "Root question");
8533 assert!(plan.entries.is_empty()); assert!(plan.leaf_id.is_none());
8535 }
8536
8537 #[test]
8538 fn test_plan_fork_from_nonexistent_fails() {
8539 let session = Session::in_memory();
8540 assert!(session.plan_fork_from_user_message("nonexistent").is_err());
8541 }
8542
8543 #[test]
8544 fn test_plan_fork_from_assistant_message_fails() {
8545 let mut session = Session::in_memory();
8546 session.append_message(make_test_message("Q"));
8547 let assistant = AssistantMessage {
8548 content: vec![ContentBlock::Text(TextContent::new("A"))],
8549 api: "anthropic".to_string(),
8550 provider: "anthropic".to_string(),
8551 model: "test".to_string(),
8552 usage: Usage::default(),
8553 stop_reason: StopReason::Stop,
8554 error_message: None,
8555 timestamp: 0,
8556 };
8557 let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
8558
8559 assert!(session.plan_fork_from_user_message(&asst_id).is_err());
8560 }
8561
8562 #[test]
8567 fn test_compaction_truncates_model_context() {
8568 let mut session = Session::in_memory();
8569
8570 let _id_a = session.append_message(make_test_message("old message A"));
8571 let _id_b = session.append_message(make_test_message("old message B"));
8572 let id_c = session.append_message(make_test_message("kept message C"));
8573
8574 session.append_compaction(
8576 "Summary of old messages".to_string(),
8577 id_c,
8578 5000,
8579 None,
8580 None,
8581 );
8582
8583 let id_d = session.append_message(make_test_message("new message D"));
8584
8585 session.navigate_to(&id_d);
8587
8588 let messages = session.to_messages_for_current_path();
8589 assert!(messages.len() <= 4); let all_text: String = messages
8595 .iter()
8596 .filter_map(|m| match m {
8597 Message::User(u) => match &u.content {
8598 UserContent::Text(t) => Some(t.clone()),
8599 UserContent::Blocks(blocks) => {
8600 let texts: Vec<String> = blocks
8601 .iter()
8602 .filter_map(|b| {
8603 if let ContentBlock::Text(t) = b {
8604 Some(t.text.clone())
8605 } else {
8606 None
8607 }
8608 })
8609 .collect();
8610 Some(texts.join(" "))
8611 }
8612 },
8613 _ => None,
8614 })
8615 .collect::<Vec<_>>()
8616 .join(" ");
8617
8618 assert!(
8619 !all_text.contains("old message A"),
8620 "compacted message A should not appear in context"
8621 );
8622 assert!(
8623 !all_text.contains("old message B"),
8624 "compacted message B should not appear in context"
8625 );
8626 assert!(
8627 all_text.contains("kept message C") || all_text.contains("new message D"),
8628 "kept messages should appear in context"
8629 );
8630 }
8631
8632 #[test]
8637 fn test_large_session_append_and_path() {
8638 let mut session = Session::in_memory();
8639
8640 let mut last_id = String::new();
8641 for i in 0..500 {
8642 last_id = session.append_message(make_test_message(&format!("msg-{i}")));
8643 }
8644
8645 assert_eq!(session.entries.len(), 500);
8646 assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
8647
8648 let path = session.get_path_to_entry(&last_id);
8650 assert_eq!(path.len(), 500);
8651
8652 let current = session.entries_for_current_path();
8654 assert_eq!(current.len(), 500);
8655 }
8656
8657 #[test]
8658 fn test_large_session_save_and_reload() {
8659 let temp = tempfile::tempdir().unwrap();
8660 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8661
8662 for i in 0..200 {
8663 session.append_message(make_test_message(&format!("message {i}")));
8664 }
8665
8666 run_async(async { session.save().await }).unwrap();
8667 let path = session.path.clone().unwrap();
8668
8669 let loaded =
8670 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8671
8672 assert_eq!(loaded.entries.len(), 200);
8673 assert_eq!(loaded.header.id, session.header.id);
8674 }
8675
8676 #[test]
8681 fn test_ensure_entry_ids_fills_missing() {
8682 let mut entries = vec![
8683 SessionEntry::Message(MessageEntry {
8684 base: EntryBase {
8685 id: None,
8686 parent_id: None,
8687 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8688 },
8689 message: SessionMessage::User {
8690 content: UserContent::Text("test".to_string()),
8691 timestamp: Some(0),
8692 },
8693 }),
8694 SessionEntry::Message(MessageEntry {
8695 base: EntryBase {
8696 id: Some("existing".to_string()),
8697 parent_id: None,
8698 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
8699 },
8700 message: SessionMessage::User {
8701 content: UserContent::Text("test2".to_string()),
8702 timestamp: Some(0),
8703 },
8704 }),
8705 ];
8706
8707 ensure_entry_ids(&mut entries);
8708
8709 assert!(entries[0].base().id.is_some());
8711 assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
8713 assert_ne!(entries[0].base().id, entries[1].base().id);
8715 }
8716
8717 #[test]
8718 fn test_generate_entry_id_produces_8_char_hex() {
8719 let existing = HashSet::new();
8720 let id = generate_entry_id(&existing);
8721 assert_eq!(id.len(), 8);
8722 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
8723 }
8724
8725 #[test]
8730 fn test_set_model_header() {
8731 let mut session = Session::in_memory();
8732 session.set_model_header(
8733 Some("anthropic".to_string()),
8734 Some("claude-opus".to_string()),
8735 Some("high".to_string()),
8736 );
8737 assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
8738 assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
8739 assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
8740 }
8741
8742 #[test]
8743 fn test_effective_model_and_thinking_use_current_header_without_change_entries() {
8744 let mut session = Session::in_memory();
8745 session.set_model_header(
8746 Some("openai".to_string()),
8747 Some("gpt-5.4".to_string()),
8748 Some("medium".to_string()),
8749 );
8750
8751 assert_eq!(
8752 session.effective_model_for_current_path(),
8753 Some(("openai".to_string(), "gpt-5.4".to_string()))
8754 );
8755 assert_eq!(
8756 session
8757 .effective_thinking_level_for_current_path()
8758 .as_deref(),
8759 Some("medium")
8760 );
8761 }
8762
8763 #[test]
8764 fn test_set_branched_from() {
8765 let mut session = Session::in_memory();
8766 assert!(session.header.parent_session.is_none());
8767
8768 session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
8769 assert_eq!(
8770 session.header.parent_session.as_deref(),
8771 Some("/path/to/parent.jsonl")
8772 );
8773 }
8774
8775 #[test]
8780 fn test_to_html_contains_all_message_types() {
8781 let mut session = Session::in_memory();
8782
8783 session.append_message(make_test_message("user question"));
8784
8785 let assistant = AssistantMessage {
8786 content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
8787 api: "anthropic".to_string(),
8788 provider: "anthropic".to_string(),
8789 model: "test".to_string(),
8790 usage: Usage::default(),
8791 stop_reason: StopReason::Stop,
8792 error_message: None,
8793 timestamp: 0,
8794 };
8795 session.append_message(SessionMessage::Assistant { message: assistant });
8796 session.append_model_change("anthropic".to_string(), "claude-test".to_string());
8797 session.set_name("test-session-html");
8798
8799 let html = session.to_html();
8800 assert!(html.contains("<!doctype html>"));
8801 assert!(html.contains("user question"));
8802 assert!(html.contains("assistant answer"));
8803 assert!(html.contains("anthropic"));
8804 assert!(html.contains("test-session-html"));
8805 }
8806
8807 #[test]
8812 fn test_to_messages_includes_all_message_entries() {
8813 let mut session = Session::in_memory();
8814
8815 session.append_message(make_test_message("Q1"));
8816 let assistant = AssistantMessage {
8817 content: vec![ContentBlock::Text(TextContent::new("A1"))],
8818 api: "anthropic".to_string(),
8819 provider: "anthropic".to_string(),
8820 model: "test".to_string(),
8821 usage: Usage::default(),
8822 stop_reason: StopReason::Stop,
8823 error_message: None,
8824 timestamp: 0,
8825 };
8826 session.append_message(SessionMessage::Assistant { message: assistant });
8827 session.append_message(SessionMessage::ToolResult {
8828 tool_call_id: "c1".to_string(),
8829 tool_name: "edit".to_string(),
8830 content: vec![ContentBlock::Text(TextContent::new("edited"))],
8831 details: None,
8832 is_error: false,
8833 timestamp: Some(0),
8834 });
8835
8836 session.append_model_change("openai".to_string(), "gpt-4".to_string());
8838 session.append_session_info(Some("name".to_string()));
8839
8840 let messages = session.to_messages();
8841 assert_eq!(messages.len(), 3); }
8843
8844 #[test]
8849 fn test_jsonl_header_is_first_line() {
8850 let temp = tempfile::tempdir().unwrap();
8851 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8852 session.append_message(make_test_message("test"));
8853
8854 run_async(async { session.save().await }).unwrap();
8855 let path = session.path.clone().unwrap();
8856
8857 let contents = std::fs::read_to_string(path).unwrap();
8858 let first_line = contents.lines().next().unwrap();
8859 let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
8860
8861 assert_eq!(header["type"], "session");
8862 assert_eq!(header["version"], SESSION_VERSION);
8863 assert!(!header["id"].as_str().unwrap().is_empty());
8864 assert!(!header["timestamp"].as_str().unwrap().is_empty());
8865 }
8866
8867 #[test]
8868 fn test_jsonl_entries_have_camelcase_fields() {
8869 let temp = tempfile::tempdir().unwrap();
8870 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8871
8872 session.append_message(make_test_message("test"));
8873 session.append_model_change("provider".to_string(), "model".to_string());
8874
8875 run_async(async { session.save().await }).unwrap();
8876 let path = session.path.clone().unwrap();
8877
8878 let contents = std::fs::read_to_string(path).unwrap();
8879 let lines: Vec<&str> = contents.lines().collect();
8880
8881 let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
8883 assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
8884
8885 let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
8887 assert!(mc_value.get("modelId").is_some());
8888 }
8889
8890 #[test]
8895 fn test_open_nonexistent_file_returns_error() {
8896 let result =
8897 run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
8898 assert!(result.is_err());
8899 }
8900
8901 #[test]
8902 fn test_open_empty_file_returns_error() {
8903 let temp = tempfile::tempdir().unwrap();
8904 let path = temp.path().join("empty.jsonl");
8905 std::fs::write(&path, "").unwrap();
8906
8907 let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
8908 assert!(result.is_err());
8909 }
8910
8911 #[test]
8912 fn test_open_rejects_semantically_invalid_header() {
8913 let temp = tempfile::tempdir().unwrap();
8914 let path = temp.path().join("invalid_header.jsonl");
8915 std::fs::write(
8916 &path,
8917 r#"{"type":"note","version":3,"id":"bad","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp"}"#,
8918 )
8919 .unwrap();
8920
8921 let err = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
8922 .expect_err("invalid header should fail");
8923 let message = err.to_string();
8924 assert!(
8925 message.contains("Invalid session header"),
8926 "expected invalid session header error, got {message}"
8927 );
8928 }
8929
8930 #[test]
8931 fn test_save_rejects_semantically_invalid_header() {
8932 let temp = tempfile::tempdir().unwrap();
8933 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
8934 session.header.r#type = "note".to_string();
8935
8936 let err =
8937 run_async(async { session.save().await }).expect_err("invalid header should fail");
8938 let message = err.to_string();
8939 assert!(
8940 message.contains("Invalid session header"),
8941 "expected invalid session header error, got {message}"
8942 );
8943 }
8944
8945 #[test]
8950 fn test_get_entry_returns_correct_entry() {
8951 let mut session = Session::in_memory();
8952 let id = session.append_message(make_test_message("Hello"));
8953
8954 let entry = session.get_entry(&id);
8955 assert!(entry.is_some());
8956 assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
8957 }
8958
8959 #[test]
8960 fn test_get_entry_mut_allows_modification() {
8961 let mut session = Session::in_memory();
8962 let id = session.append_message(make_test_message("Original"));
8963
8964 let entry = session.get_entry_mut(&id).unwrap();
8965 if let SessionEntry::Message(msg) = entry {
8966 msg.message = SessionMessage::User {
8967 content: UserContent::Text("Modified".to_string()),
8968 timestamp: Some(0),
8969 };
8970 }
8971
8972 let entry = session.get_entry(&id).unwrap();
8974 if let SessionEntry::Message(msg) = entry {
8975 if let SessionMessage::User { content, .. } = &msg.message {
8976 match content {
8977 UserContent::Text(t) => assert_eq!(t, "Modified"),
8978 UserContent::Blocks(_) => test_fail!("Expected UserContent::Text, got Blocks"),
8979 }
8980 } else {
8981 test_fail!("Expected SessionMessage::User, got {:?}", msg.message);
8982 }
8983 }
8984 }
8985
8986 #[test]
8987 fn test_get_entry_nonexistent_returns_none() {
8988 let session = Session::in_memory();
8989 assert!(session.get_entry("nonexistent").is_none());
8990 }
8991
8992 #[test]
8997 fn test_branching_round_trip_preserves_tree_structure() {
8998 let temp = tempfile::tempdir().unwrap();
8999 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9000
9001 let id_a = session.append_message(make_test_message("A"));
9003 let id_b = session.append_message(make_test_message("B"));
9004 let id_c = session.append_message(make_test_message("C"));
9005
9006 session.create_branch_from(&id_a);
9007 let id_d = session.append_message(make_test_message("D"));
9008
9009 let leaves = session.list_leaves();
9011 assert_eq!(leaves.len(), 2);
9012
9013 run_async(async { session.save().await }).unwrap();
9014 let path = session.path.clone().unwrap();
9015
9016 let loaded =
9017 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9018
9019 assert_eq!(loaded.entries.len(), 4);
9021 let loaded_leaves = loaded.list_leaves();
9022 assert_eq!(loaded_leaves.len(), 2);
9023 assert!(loaded_leaves.contains(&id_c));
9024 assert!(loaded_leaves.contains(&id_d));
9025
9026 let path_to_c = loaded.get_path_to_entry(&id_c);
9028 assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
9029
9030 let path_to_d = loaded.get_path_to_entry(&id_d);
9031 assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
9032 }
9033
9034 #[test]
9039 fn test_encode_cwd_strips_leading_separators() {
9040 let path = std::path::Path::new("/home/user/my-project");
9041 let encoded = encode_cwd(path);
9042 assert_eq!(encoded, "--home-user-my-project--");
9043 assert!(!encoded.contains('/'));
9044 }
9045
9046 #[test]
9047 fn test_encode_cwd_handles_deeply_nested_path() {
9048 let path = std::path::Path::new("/a/b/c/d/e/f");
9049 let encoded = encode_cwd(path);
9050 assert_eq!(encoded, "--a-b-c-d-e-f--");
9051 }
9052
9053 #[test]
9054 fn test_save_creates_project_session_dir_from_cwd() {
9055 let temp = tempfile::tempdir().unwrap();
9056 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9057 session.append_message(make_test_message("test"));
9058
9059 run_async(async { session.save().await }).unwrap();
9060 let path = session.path.clone().unwrap();
9061
9062 let parent = path.parent().unwrap();
9064 let dir_name = parent.file_name().unwrap().to_string_lossy();
9065 assert!(
9066 dir_name.starts_with("--"),
9067 "session dir should start with --"
9068 );
9069 assert!(dir_name.ends_with("--"), "session dir should end with --");
9070
9071 assert_eq!(path.extension().unwrap(), "jsonl");
9073 }
9074
9075 #[test]
9076 fn test_save_uses_session_header_cwd_for_project_session_dir() {
9077 let _lock = current_dir_lock();
9078 let process_cwd = tempfile::tempdir().unwrap();
9079 let _guard = CurrentDirGuard::new(process_cwd.path());
9080
9081 let sessions_root = tempfile::tempdir().unwrap();
9082 let session_cwd = tempfile::tempdir().unwrap();
9083 let mut session = Session::create_with_dir(Some(sessions_root.path().to_path_buf()));
9084 session.header.cwd = session_cwd.path().display().to_string();
9085 session.append_message(make_test_message("test"));
9086
9087 run_async(async { session.save().await }).unwrap();
9088 let path = session.path.clone().expect("session path");
9089 let expected_dir = sessions_root.path().join(encode_cwd(session_cwd.path()));
9090 let process_dir = sessions_root.path().join(encode_cwd(process_cwd.path()));
9091
9092 assert_eq!(path.parent(), Some(expected_dir.as_path()));
9093 assert_ne!(path.parent(), Some(process_dir.as_path()));
9094 }
9095
9096 #[test]
9097 fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
9098 let known_entry = SessionPickEntry {
9099 path: PathBuf::from("session.jsonl"),
9100 id: "session-id".to_string(),
9101 cwd: "/work".to_string(),
9102 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9103 message_count: 4,
9104 name: Some("cached".to_string()),
9105 last_modified_ms: 1234,
9106 size_bytes: 4096,
9107 };
9108
9109 assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
9110 assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
9111 assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
9112 }
9113
9114 #[test]
9115 fn read_capped_utf8_line_with_limit_rejects_oversized_line_without_newline() {
9116 let oversized = "x".repeat(5);
9117 let mut reader = std::io::Cursor::new(oversized.into_bytes());
9118
9119 let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9120 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9121 assert!(err.to_string().contains("JSONL line exceeds 4 bytes"));
9122 }
9123
9124 #[test]
9125 fn read_capped_utf8_line_with_limit_allows_exact_limit_before_newline() {
9126 let mut reader = std::io::Cursor::new(b"abcd\n".to_vec());
9127
9128 let line = read_capped_utf8_line_with_limit(&mut reader, 4)
9129 .expect("read line")
9130 .expect("line present");
9131 assert_eq!(line, "abcd\n");
9132 assert!(
9133 read_capped_utf8_line_with_limit(&mut reader, 4)
9134 .expect("read eof")
9135 .is_none()
9136 );
9137 }
9138
9139 #[test]
9140 fn read_capped_utf8_line_with_limit_drains_oversized_line_remainder() {
9141 let mut reader = std::io::Cursor::new(b"xxxxx\ny\n".to_vec());
9142
9143 let err = read_capped_utf8_line_with_limit(&mut reader, 4).expect_err("oversized line");
9144 assert_eq!(err.kind(), std::io::ErrorKind::InvalidData);
9145
9146 let next_line = read_capped_utf8_line_with_limit(&mut reader, 4)
9147 .expect("read next line")
9148 .expect("next line present");
9149 assert_eq!(next_line, "y\n");
9150 }
9151
9152 #[test]
9153 fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
9154 let temp = tempfile::tempdir().unwrap();
9155 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9156 session.append_message(make_test_message("first"));
9157 session.append_message(make_test_message("second"));
9158
9159 run_async(async { session.save().await }).unwrap();
9160 let path = session.path.clone().expect("session path");
9161 let metadata = std::fs::metadata(&path).expect("session metadata");
9162 let disk_size = metadata.len();
9163 #[allow(clippy::cast_possible_truncation)]
9164 let disk_ms = metadata
9165 .modified()
9166 .unwrap_or(SystemTime::UNIX_EPOCH)
9167 .duration_since(UNIX_EPOCH)
9168 .unwrap_or_default()
9169 .as_millis() as i64;
9170
9171 let stale_known_entry = SessionPickEntry {
9172 path: path.clone(),
9173 id: session.header.id.clone(),
9174 cwd: session.header.cwd.clone(),
9175 timestamp: session.header.timestamp.clone(),
9176 message_count: 999,
9177 name: Some("stale".to_string()),
9178 last_modified_ms: disk_ms,
9179 size_bytes: disk_size.saturating_add(1),
9180 };
9181
9182 let session_dir = path.parent().expect("session parent").to_path_buf();
9183 let scanned =
9184 run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9185 .expect("scan sessions");
9186 assert!(scanned.failed_paths.is_empty());
9187 assert_eq!(scanned.entries.len(), 1);
9188 assert_eq!(scanned.refreshed_entries.len(), 1);
9189 assert_eq!(scanned.entries[0].path, path);
9190 assert_eq!(scanned.entries[0].message_count, 2);
9191 assert_eq!(scanned.entries[0].size_bytes, disk_size);
9192 }
9193
9194 #[test]
9195 fn test_merge_scanned_session_entries_replaces_cached_entry_when_size_changes() {
9196 let path = PathBuf::from("session.jsonl");
9197 let mut by_path = HashMap::from([(
9198 path.clone(),
9199 SessionPickEntry {
9200 path: path.clone(),
9201 id: "session-id".to_string(),
9202 cwd: "/work".to_string(),
9203 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9204 message_count: 1,
9205 name: Some("cached".to_string()),
9206 last_modified_ms: 1234,
9207 size_bytes: 4096,
9208 },
9209 )]);
9210
9211 merge_scanned_session_entries(
9212 &mut by_path,
9213 vec![SessionPickEntry {
9214 path: path.clone(),
9215 id: "session-id".to_string(),
9216 cwd: "/work".to_string(),
9217 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9218 message_count: 2,
9219 name: Some("disk".to_string()),
9220 last_modified_ms: 1234,
9221 size_bytes: 8192,
9222 }],
9223 );
9224
9225 let merged = by_path.get(&path).expect("merged entry");
9226 assert_eq!(merged.message_count, 2);
9227 assert_eq!(merged.name.as_deref(), Some("disk"));
9228 assert_eq!(merged.size_bytes, 8192);
9229 }
9230
9231 #[test]
9232 fn test_merge_scanned_session_entries_replaces_cached_entry_even_if_disk_mtime_regresses() {
9233 let path = PathBuf::from("session.jsonl");
9234 let mut by_path = HashMap::from([(
9235 path.clone(),
9236 SessionPickEntry {
9237 path: path.clone(),
9238 id: "session-id".to_string(),
9239 cwd: "/work".to_string(),
9240 timestamp: "2026-01-02T00:00:00.000Z".to_string(),
9241 message_count: 9,
9242 name: Some("cached".to_string()),
9243 last_modified_ms: 2000,
9244 size_bytes: 4096,
9245 },
9246 )]);
9247
9248 merge_scanned_session_entries(
9249 &mut by_path,
9250 vec![SessionPickEntry {
9251 path: path.clone(),
9252 id: "session-id".to_string(),
9253 cwd: "/work".to_string(),
9254 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
9255 message_count: 3,
9256 name: Some("disk".to_string()),
9257 last_modified_ms: 1000,
9258 size_bytes: 2048,
9259 }],
9260 );
9261
9262 let merged = by_path.get(&path).expect("merged entry");
9263 assert_eq!(merged.message_count, 3);
9264 assert_eq!(merged.name.as_deref(), Some("disk"));
9265 assert_eq!(merged.last_modified_ms, 1000);
9266 assert_eq!(merged.size_bytes, 2048);
9267 }
9268
9269 #[test]
9270 fn test_scan_sessions_on_disk_reports_failed_paths_for_corrupt_changed_session() {
9271 let temp = tempfile::tempdir().unwrap();
9272 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9273 session.append_message(make_test_message("first"));
9274 session.append_message(make_test_message("second"));
9275
9276 run_async(async { session.save().await }).unwrap();
9277 let path = session.path.clone().expect("session path");
9278 let metadata = std::fs::metadata(&path).expect("session metadata");
9279 let disk_size = metadata.len();
9280 #[allow(clippy::cast_possible_truncation)]
9281 let disk_ms = metadata
9282 .modified()
9283 .unwrap_or(SystemTime::UNIX_EPOCH)
9284 .duration_since(UNIX_EPOCH)
9285 .unwrap_or_default()
9286 .as_millis() as i64;
9287
9288 let stale_known_entry = SessionPickEntry {
9289 path: path.clone(),
9290 id: session.header.id.clone(),
9291 cwd: session.header.cwd.clone(),
9292 timestamp: session.header.timestamp.clone(),
9293 message_count: 999,
9294 name: Some("stale".to_string()),
9295 last_modified_ms: disk_ms,
9296 size_bytes: disk_size,
9297 };
9298
9299 std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9300
9301 let session_dir = path.parent().expect("session parent").to_path_buf();
9302 let scanned =
9303 run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9304 .expect("scan sessions");
9305
9306 assert!(scanned.entries.is_empty());
9307 assert!(scanned.refreshed_entries.is_empty());
9308 assert_eq!(scanned.failed_paths, vec![path]);
9309 }
9310
9311 #[test]
9312 fn test_continue_recent_in_dir_prunes_corrupt_stale_index_entry() {
9313 let _lock = current_dir_lock();
9314 let process_cwd = tempfile::tempdir().unwrap();
9315 let _guard = CurrentDirGuard::new(process_cwd.path());
9316
9317 let temp = tempfile::tempdir().unwrap();
9318 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9319 session.append_message(make_test_message("first"));
9320 session.append_message(make_test_message("second"));
9321
9322 run_async(async { session.save().await }).expect("save session");
9323 let path = session.path.clone().expect("session path");
9324
9325 let index = SessionIndex::for_sessions_root(temp.path());
9326 index.index_session(&session).expect("index session");
9327 let cwd_display = session.header.cwd.clone();
9328 let expected_path = path.display().to_string();
9329 let has_indexed_path = index
9330 .list_sessions(Some(&cwd_display))
9331 .expect("list indexed sessions")
9332 .into_iter()
9333 .any(|meta| meta.path.eq(&expected_path));
9334 assert!(
9335 has_indexed_path,
9336 "expected indexed session before corruption"
9337 );
9338
9339 std::fs::write(&path, b"not valid jsonl\n").expect("corrupt session");
9340
9341 let resumed = run_async(async {
9342 Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9343 })
9344 .expect("continue recent");
9345
9346 assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9347 assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9348
9349 let still_indexed = index
9350 .list_sessions(Some(&cwd_display))
9351 .expect("list indexed sessions after cleanup")
9352 .into_iter()
9353 .any(|meta| meta.path.eq(&expected_path));
9354 assert!(
9355 !still_indexed,
9356 "corrupt session should be pruned from the recent-session index"
9357 );
9358 }
9359
9360 #[test]
9361 fn test_continue_recent_in_dir_prunes_missing_stale_index_entry() {
9362 let _lock = current_dir_lock();
9363 let process_cwd = tempfile::tempdir().unwrap();
9364 let _guard = CurrentDirGuard::new(process_cwd.path());
9365
9366 let temp = tempfile::tempdir().unwrap();
9367 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9368 session.append_message(make_test_message("first"));
9369
9370 run_async(async { session.save().await }).expect("save session");
9371 let path = session.path.clone().expect("session path");
9372
9373 let index = SessionIndex::for_sessions_root(temp.path());
9374 index.index_session(&session).expect("index session");
9375 let cwd_display = session.header.cwd.clone();
9376 let expected_path = path.display().to_string();
9377 let has_indexed_path = index
9378 .list_sessions(Some(&cwd_display))
9379 .expect("list indexed sessions")
9380 .into_iter()
9381 .any(|meta| meta.path.eq(&expected_path));
9382 assert!(
9383 has_indexed_path,
9384 "expected indexed session before moving file"
9385 );
9386
9387 let moved_path = path.with_extension("bak");
9388 std::fs::rename(&path, &moved_path).expect("move session away from indexed path");
9389
9390 let resumed = run_async(async {
9391 Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9392 })
9393 .expect("continue recent");
9394
9395 assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9396 assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9397
9398 let still_indexed = index
9399 .list_sessions(Some(&cwd_display))
9400 .expect("list indexed sessions after cleanup")
9401 .into_iter()
9402 .any(|meta| meta.path.eq(&expected_path));
9403 assert!(
9404 !still_indexed,
9405 "missing session should be pruned from the recent-session index"
9406 );
9407 }
9408
9409 #[test]
9410 fn test_continue_recent_in_dir_prunes_index_when_project_dir_is_missing() {
9411 let _lock = current_dir_lock();
9412 let process_cwd = tempfile::tempdir().unwrap();
9413 let _guard = CurrentDirGuard::new(process_cwd.path());
9414
9415 let temp = tempfile::tempdir().unwrap();
9416 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9417 session.append_message(make_test_message("first"));
9418
9419 run_async(async { session.save().await }).expect("save session");
9420 let path = session.path.clone().expect("session path");
9421
9422 let index = SessionIndex::for_sessions_root(temp.path());
9423 index.index_session(&session).expect("index session");
9424 let cwd_display = session.header.cwd.clone();
9425 let expected_path = path.display().to_string();
9426 let cwd = std::path::Path::new(&cwd_display);
9427 let project_session_dir = temp.path().join(encode_cwd(cwd));
9428 let moved_project_dir = temp.path().join("moved-project-dir");
9429
9430 std::fs::rename(&project_session_dir, &moved_project_dir)
9431 .expect("move project session dir away");
9432
9433 let resumed = run_async(async {
9434 Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9435 })
9436 .expect("continue recent");
9437
9438 assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9439 assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9440
9441 let still_indexed = index
9442 .list_sessions(Some(&cwd_display))
9443 .expect("list indexed sessions after cleanup")
9444 .into_iter()
9445 .any(|meta| meta.path.eq(&expected_path));
9446 assert!(
9447 !still_indexed,
9448 "missing project dir should prune stale rows from the recent-session index"
9449 );
9450 }
9451
9452 #[cfg(unix)]
9453 #[test]
9454 fn split_indexed_session_entries_keeps_permission_denied_path_out_of_missing_bucket() {
9455 use crate::session_index::SessionMeta;
9456 use std::os::unix::fs::PermissionsExt;
9457
9458 let temp = tempfile::tempdir().unwrap();
9459 let guarded_dir = temp.path().join("guarded");
9460 std::fs::create_dir(&guarded_dir).expect("create guarded dir");
9461 let session_path = guarded_dir.join("session.jsonl");
9462 std::fs::write(&session_path, b"{\"version\":\"3\"}\n").expect("write session file");
9463
9464 let original_mode = std::fs::metadata(&guarded_dir)
9465 .expect("guarded dir metadata")
9466 .permissions()
9467 .mode();
9468 std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(0o000))
9469 .expect("chmod guarded dir");
9470
9471 assert!(
9472 session_path.try_exists().is_err(),
9473 "expected permission-denied path probe for inaccessible parent directory"
9474 );
9475
9476 let meta = SessionMeta {
9477 path: session_path.display().to_string(),
9478 id: "session-id".to_string(),
9479 cwd: temp.path().display().to_string(),
9480 timestamp: "2026-03-15T00:00:00.000Z".to_string(),
9481 message_count: 1,
9482 last_modified_ms: 0,
9483 size_bytes: 16,
9484 name: Some("guarded".to_string()),
9485 };
9486
9487 let (entries, missing_paths) = split_indexed_session_entries(vec![meta]);
9488
9489 std::fs::set_permissions(&guarded_dir, std::fs::Permissions::from_mode(original_mode))
9490 .expect("restore guarded dir permissions");
9491
9492 assert!(
9493 missing_paths.is_empty(),
9494 "permission errors must not be classified as missing indexed sessions"
9495 );
9496 assert_eq!(entries.len(), 1);
9497 assert_eq!(entries[0].path, session_path);
9498 }
9499
9500 #[cfg(unix)]
9501 #[test]
9502 fn test_continue_recent_in_dir_prunes_unreadable_cached_entry_on_open_failure() {
9503 use std::os::unix::fs::PermissionsExt;
9504
9505 let temp = tempfile::tempdir().unwrap();
9506 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9507 session.append_message(make_test_message("first"));
9508
9509 run_async(async { session.save().await }).expect("save session");
9510 let path = session.path.clone().expect("session path");
9511
9512 let original_mode = std::fs::metadata(&path)
9513 .expect("session metadata")
9514 .permissions()
9515 .mode();
9516
9517 let index = SessionIndex::for_sessions_root(temp.path());
9518 index.index_session(&session).expect("index session");
9519 let expected_path = path.display().to_string();
9520 let cwd_display = std::env::current_dir()
9521 .expect("current dir")
9522 .display()
9523 .to_string();
9524
9525 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o000))
9526 .expect("chmod unreadable");
9527
9528 let resumed = run_async(async {
9529 Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9530 })
9531 .expect("continue recent");
9532
9533 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(original_mode))
9534 .expect("restore permissions");
9535
9536 assert!(resumed.path.is_none(), "expected a fresh unsaved session");
9537 assert_eq!(resumed.session_dir, Some(temp.path().to_path_buf()));
9538
9539 let still_indexed = index
9540 .list_sessions(Some(&cwd_display))
9541 .expect("list indexed sessions after cleanup")
9542 .into_iter()
9543 .any(|meta| meta.path.eq(&expected_path));
9544 assert!(
9545 !still_indexed,
9546 "unreadable session should be pruned from the recent-session index"
9547 );
9548 }
9549
9550 #[test]
9551 fn test_continue_recent_in_dir_refreshes_index_after_changed_disk_session() {
9552 let _lock = current_dir_lock();
9553 let process_cwd = tempfile::tempdir().unwrap();
9554 let _guard = CurrentDirGuard::new(process_cwd.path());
9555
9556 let temp = tempfile::tempdir().expect("tempdir");
9557 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9558 session.append_message(make_test_message("first"));
9559
9560 run_async(async { session.save().await }).expect("save session");
9561 let path = session.path.clone().expect("session path");
9562
9563 let index = SessionIndex::for_sessions_root(temp.path());
9564 index.index_session(&session).expect("index session");
9565 let cwd_display = session.header.cwd.clone();
9566
9567 std::fs::write(
9568 &path,
9569 format!(
9570 "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9571 serde_json::to_string(&session.header).expect("serialize header"),
9572 ),
9573 )
9574 .expect("rewrite session");
9575
9576 let resumed = run_async(async {
9577 Session::continue_recent_in_dir(Some(temp.path()), &Config::default()).await
9578 })
9579 .expect("continue recent");
9580
9581 assert_eq!(resumed.path.as_ref(), Some(&path));
9582
9583 let indexed = index
9584 .list_sessions(Some(&cwd_display))
9585 .expect("list indexed sessions");
9586 assert_eq!(indexed.len(), 1);
9587 assert_eq!(indexed[0].path, path.display().to_string());
9588 assert_eq!(indexed[0].message_count, 2);
9589 assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9590 }
9591
9592 #[test]
9593 fn test_resume_with_picker_refreshes_index_after_changed_disk_session() {
9594 let _lock = current_dir_lock();
9595 let process_cwd = tempfile::tempdir().unwrap();
9596 let _guard = CurrentDirGuard::new(process_cwd.path());
9597
9598 let temp = tempfile::tempdir().expect("tempdir");
9599 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9600 session.append_message(make_test_message("first"));
9601
9602 run_async(async { session.save().await }).expect("save session");
9603 let path = session.path.clone().expect("session path");
9604
9605 let index = SessionIndex::for_sessions_root(temp.path());
9606 index.index_session(&session).expect("index session");
9607 let cwd_display = session.header.cwd.clone();
9608
9609 std::fs::write(
9610 &path,
9611 format!(
9612 "{}\n{{\"type\":\"message\"}}\n{{\"type\":\"message\"}}\n{{\"type\":\"session_info\",\"name\":\"Refreshed\"}}\n",
9613 serde_json::to_string(&session.header).expect("serialize header"),
9614 ),
9615 )
9616 .expect("rewrite session");
9617
9618 let resumed = run_async(async {
9619 Session::resume_with_picker(
9620 Some(temp.path()),
9621 &Config::default(),
9622 Some("1".to_string()),
9623 )
9624 .await
9625 })
9626 .expect("resume with picker");
9627
9628 assert_eq!(resumed.path.as_ref(), Some(&path));
9629
9630 let indexed = index
9631 .list_sessions(Some(&cwd_display))
9632 .expect("list indexed sessions");
9633 assert_eq!(indexed.len(), 1);
9634 assert_eq!(indexed[0].path, path.display().to_string());
9635 assert_eq!(indexed[0].message_count, 2);
9636 assert_eq!(indexed[0].name.as_deref(), Some("Refreshed"));
9637 }
9638
9639 #[test]
9640 fn test_load_session_meta_jsonl_errors_on_invalid_utf8_entry_line() {
9641 use std::io::Write;
9642
9643 let temp = tempfile::tempdir().unwrap();
9644 let session_path = temp.path().join("invalid-utf8.jsonl");
9645
9646 let mut header = SessionHeader::new();
9647 header.id = "invalid-utf8".to_string();
9648 header.cwd = temp.path().display().to_string();
9649 header.timestamp = "2025-06-01T12:00:00.000Z".to_string();
9650
9651 std::fs::write(
9652 &session_path,
9653 format!(
9654 "{}\n",
9655 serde_json::to_string(&header).expect("serialize header")
9656 ),
9657 )
9658 .expect("write header");
9659
9660 let mut file = std::fs::OpenOptions::new()
9661 .append(true)
9662 .open(&session_path)
9663 .expect("open session");
9664 file.write_all(b"{\"type\":\"message\"}\n")
9665 .expect("write valid entry");
9666 file.write_all(b"\xFF\xFE\n").expect("write invalid utf8");
9667 file.flush().expect("flush session");
9668 drop(file);
9669
9670 let err = load_session_meta_jsonl(&session_path).expect_err("invalid utf8 should error");
9671 assert!(
9672 err.to_string().contains("Failed to read session entry"),
9673 "{err}"
9674 );
9675 }
9676
9677 #[cfg(feature = "sqlite-sessions")]
9678 #[test]
9679 fn test_scan_sessions_on_disk_reloads_sqlite_when_wal_stats_change() {
9680 let temp = tempfile::tempdir().unwrap();
9681 let mut session = Session::create_with_dir_and_store(
9682 Some(temp.path().to_path_buf()),
9683 SessionStoreKind::Sqlite,
9684 );
9685 session.append_message(make_test_message("sqlite"));
9686
9687 run_async(async { session.save().await }).unwrap();
9688 let path = session.path.clone().expect("sqlite session path");
9689 let session_dir = path.parent().expect("session parent").to_path_buf();
9690 let (base_ms, base_size) = session_file_stats(&path).expect("base stats");
9691
9692 let mut wal_path = path.as_os_str().to_os_string();
9693 wal_path.push("-wal");
9694 let wal_path = PathBuf::from(wal_path);
9695 std::thread::sleep(std::time::Duration::from_millis(1_100));
9696 std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9697
9698 let stale_known_entry = SessionPickEntry {
9699 path: path.clone(),
9700 id: session.header.id.clone(),
9701 cwd: session.header.cwd.clone(),
9702 timestamp: session.header.timestamp.clone(),
9703 message_count: 999,
9704 name: Some("stale".to_string()),
9705 last_modified_ms: base_ms,
9706 size_bytes: base_size,
9707 };
9708
9709 let scanned =
9710 run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
9711 .expect("scan sessions");
9712 let (updated_ms, updated_size) = session_file_stats(&path).expect("updated stats");
9713
9714 assert!(scanned.failed_paths.is_empty());
9715 assert_eq!(scanned.entries.len(), 1);
9716 assert_eq!(scanned.refreshed_entries.len(), 1);
9717 assert_eq!(scanned.entries[0].path, path);
9718 assert_eq!(scanned.entries[0].message_count, 1);
9719 assert_eq!(scanned.entries[0].size_bytes, updated_size);
9720 assert_eq!(scanned.entries[0].last_modified_ms, updated_ms);
9721 }
9722
9723 #[cfg(feature = "sqlite-sessions")]
9724 #[test]
9725 fn test_load_session_meta_sqlite_uses_wal_aware_stats() {
9726 let temp = tempfile::tempdir().unwrap();
9727 let mut session = Session::create_with_dir_and_store(
9728 Some(temp.path().to_path_buf()),
9729 SessionStoreKind::Sqlite,
9730 );
9731 session.append_message(make_test_message("sqlite"));
9732
9733 run_async(async { session.save().await }).unwrap();
9734 let path = session.path.clone().expect("sqlite session path");
9735
9736 let mut wal_path = path.as_os_str().to_os_string();
9737 wal_path.push("-wal");
9738 let wal_path = PathBuf::from(wal_path);
9739 std::thread::sleep(std::time::Duration::from_millis(1_100));
9740 std::fs::write(&wal_path, b"walpayload").expect("write sqlite wal");
9741
9742 let meta = load_session_meta_sqlite(&path).expect("load sqlite meta");
9743 let (expected_ms, expected_size) = session_file_stats(&path).expect("sqlite file stats");
9744
9745 assert_eq!(meta.path, path);
9746 assert_eq!(meta.size_bytes, expected_size);
9747 assert_eq!(meta.last_modified_ms, expected_ms);
9748 }
9749
9750 #[test]
9755 fn test_all_entries_corrupted_produces_empty_session() {
9756 let temp = tempfile::tempdir().unwrap();
9757 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9758 session.append_message(make_test_message("A"));
9759 session.append_message(make_test_message("B"));
9760
9761 run_async(async { session.save().await }).unwrap();
9762 let path = session.path.clone().unwrap();
9763
9764 let mut lines: Vec<String> = std::fs::read_to_string(&path)
9765 .unwrap()
9766 .lines()
9767 .map(str::to_string)
9768 .collect();
9769 for (i, line) in lines.iter_mut().enumerate().skip(1) {
9771 *line = format!("GARBAGE_{i}");
9772 }
9773 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
9774
9775 let (loaded, diagnostics) = run_async(async {
9776 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
9777 })
9778 .unwrap();
9779
9780 assert_eq!(diagnostics.skipped_entries.len(), 2);
9781 assert!(loaded.entries.is_empty());
9782 assert!(loaded.leaf_id.is_none());
9783 assert_eq!(loaded.header.id, session.header.id);
9785 }
9786
9787 #[test]
9792 fn test_unicode_content_round_trip() {
9793 let temp = tempfile::tempdir().unwrap();
9794 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9795
9796 let unicode_texts = [
9797 "Hello \u{1F600} World", "\u{4F60}\u{597D}", "\u{0410}\u{0411}\u{0412}", "caf\u{00E9}", "tab\there\nnewline", "\"quoted\" and \\escaped", ];
9804
9805 for text in &unicode_texts {
9806 session.append_message(make_test_message(text));
9807 }
9808
9809 run_async(async { session.save().await }).unwrap();
9810 let path = session.path.clone().unwrap();
9811
9812 let loaded =
9813 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9814
9815 assert_eq!(loaded.entries.len(), unicode_texts.len());
9816
9817 for (i, entry) in loaded.entries.iter().enumerate() {
9818 if let SessionEntry::Message(msg) = entry {
9819 if let SessionMessage::User { content, .. } = &msg.message {
9820 match content {
9821 UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
9822 UserContent::Blocks(_) => {
9823 test_fail!("Expected UserContent::Text, got Blocks")
9824 }
9825 }
9826 }
9827 }
9828 }
9829 }
9830
9831 #[test]
9836 fn test_multiple_compactions_latest_wins() {
9837 let mut session = Session::in_memory();
9838
9839 let _id_a = session.append_message(make_test_message("old A"));
9840 let _id_b = session.append_message(make_test_message("old B"));
9841 let id_c = session.append_message(make_test_message("kept C"));
9842
9843 session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
9845
9846 let _id_d = session.append_message(make_test_message("new D"));
9847 let id_e = session.append_message(make_test_message("new E"));
9848
9849 session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
9851
9852 let id_f = session.append_message(make_test_message("newest F"));
9853
9854 session.navigate_to(&id_f);
9855 let messages = session.to_messages_for_current_path();
9856
9857 let all_text: String = messages
9859 .iter()
9860 .filter_map(|m| match m {
9861 Message::User(u) => match &u.content {
9862 UserContent::Text(t) => Some(t.clone()),
9863 UserContent::Blocks(_) => None,
9864 },
9865 _ => None,
9866 })
9867 .collect::<Vec<_>>()
9868 .join(" ");
9869
9870 assert!(!all_text.contains("old A"), "A should be compacted away");
9871 assert!(!all_text.contains("old B"), "B should be compacted away");
9872 }
9873
9874 #[test]
9879 fn test_session_with_only_metadata_entries() {
9880 let mut session = Session::in_memory();
9881
9882 session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
9883 session.append_thinking_level_change("high".to_string());
9884 session.set_name("metadata-only");
9885
9886 let messages = session.to_messages();
9888 assert!(messages.is_empty());
9889
9890 let entries = session.entries_for_current_path();
9892 assert_eq!(entries.len(), 3);
9893 }
9894
9895 #[test]
9896 fn test_metadata_only_session_round_trip() {
9897 let temp = tempfile::tempdir().unwrap();
9898 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9899
9900 session.append_model_change("openai".to_string(), "gpt-4o".to_string());
9901 session.append_thinking_level_change("medium".to_string());
9902
9903 run_async(async { session.save().await }).unwrap();
9904 let path = session.path.clone().unwrap();
9905
9906 let loaded =
9907 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9908
9909 assert_eq!(loaded.entries.len(), 2);
9910 assert!(
9911 loaded
9912 .entries
9913 .iter()
9914 .any(|e| matches!(e, SessionEntry::ModelChange(_)))
9915 );
9916 assert!(
9917 loaded
9918 .entries
9919 .iter()
9920 .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
9921 );
9922 }
9923
9924 #[test]
9929 fn test_session_name_survives_round_trip() {
9930 let temp = tempfile::tempdir().unwrap();
9931 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9932
9933 session.append_message(make_test_message("Hello"));
9934 session.set_name("my-important-session");
9935
9936 run_async(async { session.save().await }).unwrap();
9937 let path = session.path.clone().unwrap();
9938
9939 let loaded =
9940 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9941
9942 assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
9943 }
9944
9945 #[test]
9950 fn test_trailing_whitespace_in_jsonl_ignored() {
9951 let temp = tempfile::tempdir().unwrap();
9952 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
9953 session.append_message(make_test_message("test"));
9954
9955 run_async(async { session.save().await }).unwrap();
9956 let path = session.path.clone().unwrap();
9957
9958 let mut contents = std::fs::read_to_string(&path).unwrap();
9960 contents.push_str("\n\n\n");
9961 std::fs::write(&path, contents).unwrap();
9962
9963 let loaded =
9964 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
9965
9966 assert_eq!(loaded.entries.len(), 1);
9967 }
9968
9969 #[test]
9974 fn test_branching_after_compaction() {
9975 let mut session = Session::in_memory();
9976
9977 let _id_a = session.append_message(make_test_message("old A"));
9978 let id_b = session.append_message(make_test_message("kept B"));
9979
9980 session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
9981
9982 let id_c = session.append_message(make_test_message("C after compaction"));
9983
9984 session.create_branch_from(&id_b);
9986 let id_d = session.append_message(make_test_message("D branch after compaction"));
9987
9988 let leaves = session.list_leaves();
9989 assert_eq!(leaves.len(), 2);
9990 assert!(leaves.contains(&id_c));
9991 assert!(leaves.contains(&id_d));
9992 }
9993
9994 #[test]
9999 fn test_assistant_with_tool_calls_round_trip() {
10000 let temp = tempfile::tempdir().unwrap();
10001 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
10002
10003 session.append_message(make_test_message("read my file"));
10004
10005 let assistant = AssistantMessage {
10006 content: vec![
10007 ContentBlock::Text(TextContent::new("Let me read that for you.")),
10008 ContentBlock::ToolCall(crate::model::ToolCall {
10009 id: "call_abc".to_string(),
10010 name: "read".to_string(),
10011 arguments: serde_json::json!({"path": "src/main.rs"}),
10012 thought_signature: None,
10013 }),
10014 ],
10015 api: "anthropic".to_string(),
10016 provider: "anthropic".to_string(),
10017 model: "claude-test".to_string(),
10018 usage: Usage {
10019 input: 100,
10020 output: 50,
10021 cache_read: 0,
10022 cache_write: 0,
10023 total_tokens: 150,
10024 cost: Cost::default(),
10025 },
10026 stop_reason: StopReason::ToolUse,
10027 error_message: None,
10028 timestamp: 12345,
10029 };
10030 session.append_message(SessionMessage::Assistant { message: assistant });
10031
10032 session.append_message(SessionMessage::ToolResult {
10033 tool_call_id: "call_abc".to_string(),
10034 tool_name: "read".to_string(),
10035 content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
10036 details: Some(serde_json::json!({"lines": 1, "truncated": false})),
10037 is_error: false,
10038 timestamp: Some(12346),
10039 });
10040
10041 run_async(async { session.save().await }).unwrap();
10042 let path = session.path.clone().unwrap();
10043
10044 let loaded =
10045 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10046
10047 assert_eq!(loaded.entries.len(), 3);
10048
10049 let has_tool_call = loaded.entries.iter().any(|e| {
10051 if let SessionEntry::Message(msg) = e {
10052 if let SessionMessage::Assistant { message } = &msg.message {
10053 return message
10054 .content
10055 .iter()
10056 .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id.eq("call_abc")));
10057 }
10058 }
10059 false
10060 });
10061 assert!(has_tool_call, "tool call should survive round-trip");
10062
10063 let has_details = loaded.entries.iter().any(|e| {
10065 if let SessionEntry::Message(msg) = e {
10066 if let SessionMessage::ToolResult { details, .. } = &msg.message {
10067 return details.is_some();
10068 }
10069 }
10070 false
10071 });
10072 assert!(has_details, "tool result details should survive round-trip");
10073 }
10074
10075 mod proptest_session {
10080 use super::*;
10081 use proptest::prelude::*;
10082 use serde_json::json;
10083
10084 fn timestamp_strategy() -> impl Strategy<Value = String> {
10086 (
10087 2020u32..2030,
10088 1u32..13,
10089 1u32..29,
10090 0u32..24,
10091 0u32..60,
10092 0u32..60,
10093 )
10094 .prop_map(|(y, mo, d, h, mi, s)| {
10095 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
10096 })
10097 }
10098
10099 fn entry_id_strategy() -> impl Strategy<Value = String> {
10101 "[0-9a-f]{8}"
10102 }
10103
10104 fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
10106 if max_depth == 0 {
10107 prop_oneof![
10108 Just(json!(null)),
10109 any::<bool>().prop_map(|b| json!(b)),
10110 any::<i64>().prop_map(|n| json!(n)),
10111 "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10112 ]
10113 .boxed()
10114 } else {
10115 prop_oneof![
10116 Just(json!(null)),
10117 any::<bool>().prop_map(|b| json!(b)),
10118 any::<i64>().prop_map(|n| json!(n)),
10119 "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
10120 prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
10121 .prop_map(serde_json::Value::Array),
10122 ]
10123 .boxed()
10124 }
10125 }
10126
10127 #[allow(clippy::too_many_lines)]
10129 fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
10130 let ts = timestamp_strategy();
10131 let eid = entry_id_strategy();
10132 let parent = prop::option::of(entry_id_strategy());
10133
10134 (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
10135 let base = json!({
10136 "id": eid,
10137 "parentId": parent,
10138 "timestamp": ts,
10139 });
10140
10141 match variant {
10142 0 => {
10143 "[a-zA-Z0-9 ]{1,64}"
10145 .prop_map(move |text| {
10146 let mut v = base.clone();
10147 v["type"] = json!("message");
10148 v["message"] = json!({
10149 "role": "user",
10150 "content": text,
10151 });
10152 v
10153 })
10154 .boxed()
10155 }
10156 1 => {
10157 "[a-zA-Z0-9 ]{1,64}"
10159 .prop_map(move |text| {
10160 let mut v = base.clone();
10161 v["type"] = json!("message");
10162 v["message"] = json!({
10163 "role": "assistant",
10164 "content": [{"type": "text", "text": text}],
10165 "api": "anthropic",
10166 "provider": "anthropic",
10167 "model": "test-model",
10168 "usage": {
10169 "input": 10,
10170 "output": 5,
10171 "cacheRead": 0,
10172 "cacheWrite": 0,
10173 "totalTokens": 15,
10174 "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
10175 },
10176 "stopReason": "end_turn",
10177 "timestamp": 12345,
10178 });
10179 v
10180 })
10181 .boxed()
10182 }
10183 2 => {
10184 ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
10186 .prop_map(move |(provider, model)| {
10187 let mut v = base.clone();
10188 v["type"] = json!("model_change");
10189 v["provider"] = json!(provider);
10190 v["modelId"] = json!(model);
10191 v
10192 })
10193 .boxed()
10194 }
10195 3 => {
10196 prop_oneof![
10198 Just("off".to_string()),
10199 Just("low".to_string()),
10200 Just("medium".to_string()),
10201 Just("high".to_string()),
10202 ]
10203 .prop_map(move |level| {
10204 let mut v = base.clone();
10205 v["type"] = json!("thinking_level_change");
10206 v["thinkingLevel"] = json!(level);
10207 v
10208 })
10209 .boxed()
10210 }
10211 4 => {
10212 ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
10214 .prop_map(move |(summary, kept_id, tokens)| {
10215 let mut v = base.clone();
10216 v["type"] = json!("compaction");
10217 v["summary"] = json!(summary);
10218 v["firstKeptEntryId"] = json!(kept_id);
10219 v["tokensBefore"] = json!(tokens);
10220 v
10221 })
10222 .boxed()
10223 }
10224 5 => {
10225 (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
10227 .prop_map(move |(target, label)| {
10228 let mut v = base.clone();
10229 v["type"] = json!("label");
10230 v["targetId"] = json!(target);
10231 if let Some(l) = label {
10232 v["label"] = json!(l);
10233 }
10234 v
10235 })
10236 .boxed()
10237 }
10238 6 => {
10239 prop::option::of("[a-zA-Z0-9 ]{1,32}")
10241 .prop_map(move |name| {
10242 let mut v = base.clone();
10243 v["type"] = json!("session_info");
10244 if let Some(n) = name {
10245 v["name"] = json!(n);
10246 }
10247 v
10248 })
10249 .boxed()
10250 }
10251 _ => {
10252 ("[a-z_]{3,12}", bounded_json_value(2))
10254 .prop_map(move |(custom_type, data)| {
10255 let mut v = base.clone();
10256 v["type"] = json!("custom");
10257 v["customType"] = json!(custom_type);
10258 v["data"] = data;
10259 v
10260 })
10261 .boxed()
10262 }
10263 }
10264 })
10265 }
10266
10267 fn corrupted_entry_json() -> impl Strategy<Value = String> {
10269 prop_oneof![
10270 Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10272 Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10274 Just(r"{}".to_string()),
10276 Just(r"[1,2,3]".to_string()),
10278 Just(r"42".to_string()),
10280 Just(r#""just a string""#.to_string()),
10281 Just(r"null".to_string()),
10282 Just(r"true".to_string()),
10283 Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
10285 Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
10287 ]
10288 }
10289
10290 fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
10292 let mut lines = vec![header.to_string()];
10293 lines.extend(entry_lines.iter().cloned());
10294 lines.join("\n")
10295 }
10296
10297 proptest! {
10301 #![proptest_config(ProptestConfig {
10302 cases: 256,
10303 max_shrink_iters: 200,
10304 .. ProptestConfig::default()
10305 })]
10306
10307 #[test]
10308 fn session_entry_deser_never_panics(
10309 entry_json in valid_session_entry_json()
10310 ) {
10311 let json_str = entry_json.to_string();
10312 let _ = serde_json::from_str::<SessionEntry>(&json_str);
10314 }
10315 }
10316
10317 proptest! {
10321 #![proptest_config(ProptestConfig {
10322 cases: 256,
10323 max_shrink_iters: 200,
10324 .. ProptestConfig::default()
10325 })]
10326
10327 #[test]
10328 fn corrupted_entry_deser_never_panics(
10329 line in corrupted_entry_json()
10330 ) {
10331 let _ = serde_json::from_str::<SessionEntry>(&line);
10332 }
10333
10334 #[test]
10335 fn arbitrary_bytes_deser_never_panics(
10336 raw in prop::collection::vec(any::<u8>(), 0..512)
10337 ) {
10338 if let Ok(s) = String::from_utf8(raw) {
10340 let _ = serde_json::from_str::<SessionEntry>(&s);
10341 }
10342 }
10343 }
10344
10345 proptest! {
10349 #![proptest_config(ProptestConfig {
10350 cases: 256,
10351 max_shrink_iters: 200,
10352 .. ProptestConfig::default()
10353 })]
10354
10355 #[test]
10356 fn valid_entry_round_trip(
10357 entry_json in valid_session_entry_json()
10358 ) {
10359 let json_str = entry_json.to_string();
10360 if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
10361 let reserialized = serde_json::to_string(&entry).unwrap();
10363 let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
10365 assert_eq!(entry.base_id(), re_entry.base_id());
10367 assert_eq!(
10369 std::mem::discriminant(&entry),
10370 std::mem::discriminant(&re_entry)
10371 );
10372 }
10373 }
10374 }
10375
10376 proptest! {
10381 #![proptest_config(ProptestConfig {
10382 cases: 128,
10383 max_shrink_iters: 100,
10384 .. ProptestConfig::default()
10385 })]
10386
10387 #[test]
10388 fn jsonl_corrupted_recovery(
10389 valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
10390 corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
10391 interleave_seed in any::<u64>(),
10392 ) {
10393 let header_json = json!({
10394 "type": "session",
10395 "version": 3,
10396 "id": "testid01",
10397 "timestamp": "2024-01-01T00:00:00.000Z",
10398 "cwd": "/tmp/test"
10399 }).to_string();
10400
10401 let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
10403 let total = valid_strs.len() + corrupted_lines.len();
10404 let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
10405 for s in &valid_strs {
10406 all_lines.push((true, s.clone()));
10407 }
10408 for s in &corrupted_lines {
10409 all_lines.push((false, s.clone()));
10410 }
10411
10412 let mut seed = interleave_seed;
10414 for i in (1..all_lines.len()).rev() {
10415 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
10416 let j = (seed >> 33) as usize % (i + 1);
10417 all_lines.swap(i, j);
10418 }
10419
10420 let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
10421 let content = build_jsonl(&header_json, &entry_lines);
10422
10423 let temp_dir = tempfile::tempdir().unwrap();
10425 let file_path = temp_dir.path().join("test_session.jsonl");
10426 std::fs::write(&file_path, &content).unwrap();
10427
10428 let (session, diagnostics) = run_async(async {
10429 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10430 }).unwrap();
10431
10432 let total_parsed = session.entries.len();
10434 assert_eq!(
10435 total_parsed + diagnostics.skipped_entries.len(),
10436 total,
10437 "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
10438 diagnostics.skipped_entries.len()
10439 );
10440 }
10441 }
10442
10443 proptest! {
10447 #![proptest_config(ProptestConfig {
10448 cases: 128,
10449 max_shrink_iters: 100,
10450 .. ProptestConfig::default()
10451 })]
10452
10453 #[test]
10454 fn orphaned_parent_links_detected(
10455 n_entries in 2usize..10,
10456 orphan_idx in 0usize..8,
10457 ) {
10458 let orphan_idx = orphan_idx % n_entries;
10459 let header_json = json!({
10460 "type": "session",
10461 "version": 3,
10462 "id": "testid01",
10463 "timestamp": "2024-01-01T00:00:00.000Z",
10464 "cwd": "/tmp/test"
10465 }).to_string();
10466
10467 let mut entry_lines = Vec::new();
10468 let mut prev_id: Option<String> = None;
10469
10470 for i in 0..n_entries {
10471 let eid = format!("{i:08x}");
10472 let parent = if i.eq(&orphan_idx) {
10473 Some("deadbeef".to_string())
10475 } else {
10476 prev_id.clone()
10477 };
10478
10479 let entry = json!({
10480 "type": "message",
10481 "id": eid,
10482 "parentId": parent,
10483 "timestamp": "2024-01-01T00:00:00.000Z",
10484 "message": {
10485 "role": "user",
10486 "content": format!("msg {i}"),
10487 }
10488 });
10489 entry_lines.push(entry.to_string());
10490 prev_id = Some(eid);
10491 }
10492
10493 let content = build_jsonl(&header_json, &entry_lines);
10494 let temp_dir = tempfile::tempdir().unwrap();
10495 let file_path = temp_dir.path().join("orphan_test.jsonl");
10496 std::fs::write(&file_path, &content).unwrap();
10497
10498 let (_session, diagnostics) = run_async(async {
10499 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10500 }).unwrap();
10501
10502 let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
10504 o.missing_parent_id == "deadbeef"
10505 });
10506 assert!(
10507 has_orphan,
10508 "orphaned parent link to 'deadbeef' should be detected"
10509 );
10510 }
10511 }
10512
10513 proptest! {
10517 #![proptest_config(ProptestConfig {
10518 cases: 128,
10519 max_shrink_iters: 100,
10520 .. ProptestConfig::default()
10521 })]
10522
10523 #[test]
10524 fn ensure_entry_ids_fills_gaps(
10525 n_total in 1usize..20,
10526 missing_mask in prop::collection::vec(any::<bool>(), 1..20),
10527 ) {
10528 let n = n_total.min(missing_mask.len());
10529 let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
10530 let id = if missing_mask[i] {
10531 None
10532 } else {
10533 Some(format!("{i:08x}"))
10534 };
10535 SessionEntry::Message(MessageEntry {
10536 base: EntryBase {
10537 id,
10538 parent_id: None,
10539 timestamp: "2024-01-01T00:00:00.000Z".to_string(),
10540 },
10541 message: SessionMessage::User {
10542 content: UserContent::Text(format!("msg {i}")),
10543 timestamp: Some(0),
10544 },
10545 })
10546 }).collect();
10547
10548 ensure_entry_ids(&mut entries);
10549
10550 for entry in &entries {
10552 assert!(
10553 entry.base_id().is_some(),
10554 "all entries must have IDs after ensure_entry_ids"
10555 );
10556 }
10557
10558 let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
10560 let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
10561 assert_eq!(
10562 ids.len(),
10563 unique.len(),
10564 "all entry IDs must be unique"
10565 );
10566 }
10567 }
10568
10569 proptest! {
10573 #![proptest_config(ProptestConfig {
10574 cases: 256,
10575 max_shrink_iters: 200,
10576 .. ProptestConfig::default()
10577 })]
10578
10579 #[test]
10580 fn session_header_deser_never_panics(
10581 version in prop::option::of(0u8..255),
10582 id in "[a-zA-Z0-9-]{0,64}",
10583 ts in timestamp_strategy(),
10584 cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
10585 provider in prop::option::of("[a-z]{2,10}"),
10586 model_id in prop::option::of("[a-z0-9-]{2,20}"),
10587 thinking_level in prop::option::of("[a-z]{2,8}"),
10588 ) {
10589 let mut obj = json!({
10590 "type": "session",
10591 "id": id,
10592 "timestamp": ts,
10593 "cwd": cwd,
10594 });
10595 if let Some(v) = version {
10596 obj["version"] = json!(v);
10597 }
10598 if let Some(p) = &provider {
10599 obj["provider"] = json!(p);
10600 }
10601 if let Some(m) = &model_id {
10602 obj["modelId"] = json!(m);
10603 }
10604 if let Some(t) = &thinking_level {
10605 obj["thinkingLevel"] = json!(t);
10606 }
10607 let json_str = obj.to_string();
10608 let _ = serde_json::from_str::<SessionHeader>(&json_str);
10609 }
10610 }
10611
10612 #[test]
10617 fn empty_file_returns_error() {
10618 let temp_dir = tempfile::tempdir().unwrap();
10619 let file_path = temp_dir.path().join("empty.jsonl");
10620 std::fs::write(&file_path, "").unwrap();
10621
10622 let result = run_async(async {
10623 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10624 });
10625 assert!(result.is_err(), "empty file should return error");
10626 }
10627
10628 #[test]
10629 fn header_only_file_produces_empty_session() {
10630 let header = json!({
10631 "type": "session",
10632 "version": 3,
10633 "id": "testid01",
10634 "timestamp": "2024-01-01T00:00:00.000Z",
10635 "cwd": "/tmp/test"
10636 })
10637 .to_string();
10638
10639 let temp_dir = tempfile::tempdir().unwrap();
10640 let file_path = temp_dir.path().join("header_only.jsonl");
10641 std::fs::write(&file_path, &header).unwrap();
10642
10643 let (session, diagnostics) = run_async(async {
10644 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10645 })
10646 .unwrap();
10647
10648 assert!(
10649 session.entries.is_empty(),
10650 "header-only file should have no entries"
10651 );
10652 assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
10653 }
10654
10655 #[test]
10656 fn file_with_only_invalid_lines_has_diagnostics() {
10657 let header = json!({
10658 "type": "session",
10659 "version": 3,
10660 "id": "testid01",
10661 "timestamp": "2024-01-01T00:00:00.000Z",
10662 "cwd": "/tmp/test"
10663 })
10664 .to_string();
10665
10666 let content = format!(
10667 "{}\n{}\n{}\n{}",
10668 header,
10669 r#"{"bad":"json","no":"type"}"#,
10670 r"not json at all",
10671 r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
10672 );
10673
10674 let temp_dir = tempfile::tempdir().unwrap();
10675 let file_path = temp_dir.path().join("all_invalid.jsonl");
10676 std::fs::write(&file_path, &content).unwrap();
10677
10678 let (session, diagnostics) = run_async(async {
10679 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10680 })
10681 .unwrap();
10682
10683 assert!(
10684 session.entries.is_empty(),
10685 "all-invalid file should have no entries"
10686 );
10687 assert_eq!(
10688 diagnostics.skipped_entries.len(),
10689 3,
10690 "should have 3 skipped entries"
10691 );
10692 }
10693
10694 #[test]
10695 fn duplicate_entry_ids_are_loaded_without_panic() {
10696 let header = json!({
10697 "type": "session",
10698 "version": 3,
10699 "id": "testid01",
10700 "timestamp": "2024-01-01T00:00:00.000Z",
10701 "cwd": "/tmp/test"
10702 })
10703 .to_string();
10704
10705 let entry1 = json!({
10706 "type": "message",
10707 "id": "deadbeef",
10708 "timestamp": "2024-01-01T00:00:00.000Z",
10709 "message": {"role": "user", "content": "first"}
10710 })
10711 .to_string();
10712
10713 let entry2 = json!({
10714 "type": "message",
10715 "id": "deadbeef",
10716 "timestamp": "2024-01-01T00:00:01.000Z",
10717 "message": {"role": "user", "content": "second (duplicate id)"}
10718 })
10719 .to_string();
10720
10721 let content = format!("{header}\n{entry1}\n{entry2}");
10722
10723 let temp_dir = tempfile::tempdir().unwrap();
10724 let file_path = temp_dir.path().join("dup_ids.jsonl");
10725 std::fs::write(&file_path, &content).unwrap();
10726
10727 let (session, _diagnostics) = run_async(async {
10729 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
10730 })
10731 .unwrap();
10732
10733 assert_eq!(session.entries.len(), 2, "both entries should be loaded");
10734 }
10735 }
10736
10737 #[test]
10742 fn test_incremental_append_writes_only_new_entries() {
10743 let temp_dir = tempfile::tempdir().expect("temp dir");
10744 let mut session = Session::create();
10745 session.session_dir = Some(temp_dir.path().to_path_buf());
10746
10747 session.append_message(make_test_message("msg A"));
10749 session.append_message(make_test_message("msg B"));
10750 run_async(async { session.save().await }).unwrap();
10751
10752 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10753 assert_eq!(session.appends_since_checkpoint, 0);
10754
10755 let path = session.path.clone().unwrap();
10756 let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
10757 assert_eq!(lines_after_first, 3);
10759
10760 session.append_message(make_test_message("msg C"));
10762 run_async(async { session.save().await }).unwrap();
10763
10764 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10765 assert_eq!(session.appends_since_checkpoint, 1);
10766
10767 let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10768 assert_eq!(lines_after_second, 4);
10770 }
10771
10772 #[test]
10773 fn test_header_change_forces_full_rewrite() {
10774 let temp_dir = tempfile::tempdir().expect("temp dir");
10775 let mut session = Session::create();
10776 session.session_dir = Some(temp_dir.path().to_path_buf());
10777
10778 session.append_message(make_test_message("msg A"));
10779 run_async(async { session.save().await }).unwrap();
10780 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10781 assert!(!session.header_dirty);
10782
10783 session.set_model_header(Some("new-provider".to_string()), None, None);
10785 assert!(session.header_dirty);
10786
10787 session.append_message(make_test_message("msg B"));
10788 run_async(async { session.save().await }).unwrap();
10789
10790 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10792 assert!(!session.header_dirty);
10793 assert_eq!(session.appends_since_checkpoint, 0);
10794
10795 let path = session.path.clone().unwrap();
10797 let first_line = std::fs::read_to_string(&path)
10798 .unwrap()
10799 .lines()
10800 .next()
10801 .unwrap()
10802 .to_string();
10803 let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
10804 assert_eq!(header["provider"], "new-provider");
10805 }
10806
10807 #[test]
10808 fn test_compaction_entry_uses_incremental_append() {
10809 let temp_dir = tempfile::tempdir().expect("temp dir");
10810 let mut session = Session::create();
10811 session.session_dir = Some(temp_dir.path().to_path_buf());
10812
10813 let id_a = session.append_message(make_test_message("msg A"));
10814 run_async(async { session.save().await }).unwrap();
10815 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10816
10817 session.append_compaction("summary".to_string(), id_a, 100, None, None);
10821 session.append_message(make_test_message("msg B"));
10822
10823 run_async(async { session.save().await }).unwrap();
10824
10825 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
10827 assert_eq!(session.appends_since_checkpoint, 1);
10828
10829 let path = session.path.clone().unwrap();
10830 let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
10831 assert_eq!(lines_after_second, 4);
10833 }
10834
10835 #[test]
10836 fn test_checkpoint_interval_forces_full_rewrite() {
10837 let temp_dir = tempfile::tempdir().expect("temp dir");
10838 let mut session = Session::create();
10839 session.session_dir = Some(temp_dir.path().to_path_buf());
10840
10841 session.append_message(make_test_message("initial"));
10843 run_async(async { session.save().await }).unwrap();
10844
10845 let interval = compaction_checkpoint_interval();
10847 session.appends_since_checkpoint = interval;
10848
10849 session.append_message(make_test_message("triggers checkpoint"));
10851 run_async(async { session.save().await }).unwrap();
10852
10853 assert_eq!(session.appends_since_checkpoint, 0);
10855 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
10856 }
10857
10858 #[test]
10859 fn test_incremental_append_load_round_trip() {
10860 let temp_dir = tempfile::tempdir().expect("temp dir");
10861 let mut session = Session::create();
10862 session.session_dir = Some(temp_dir.path().to_path_buf());
10863
10864 session.append_message(make_test_message("msg A"));
10866 session.append_message(make_test_message("msg B"));
10867 run_async(async { session.save().await }).unwrap();
10868
10869 session.append_message(make_test_message("msg C"));
10871 run_async(async { session.save().await }).unwrap();
10872
10873 let path = session.path.clone().unwrap();
10874
10875 let loaded =
10877 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10878
10879 assert_eq!(loaded.entries.len(), 3);
10880 let texts: Vec<&str> = loaded
10882 .entries
10883 .iter()
10884 .filter_map(|e| match e {
10885 SessionEntry::Message(m) => match &m.message {
10886 SessionMessage::User {
10887 content: UserContent::Text(t),
10888 ..
10889 } => Some(t.as_str()),
10890 _ => None,
10891 },
10892 _ => None,
10893 })
10894 .collect();
10895 assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
10896 }
10897
10898 #[test]
10899 fn test_persisted_entry_count_set_on_open() {
10900 let temp_dir = tempfile::tempdir().expect("temp dir");
10901 let mut session = Session::create();
10902 session.session_dir = Some(temp_dir.path().to_path_buf());
10903
10904 session.append_message(make_test_message("msg A"));
10905 session.append_message(make_test_message("msg B"));
10906 session.append_message(make_test_message("msg C"));
10907 run_async(async { session.save().await }).unwrap();
10908
10909 let path = session.path.clone().unwrap();
10910 let loaded =
10911 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
10912
10913 assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
10914 assert!(!loaded.header_dirty);
10915 assert_eq!(loaded.appends_since_checkpoint, 0);
10916 }
10917
10918 #[test]
10919 fn test_no_new_entries_is_noop() {
10920 let temp_dir = tempfile::tempdir().expect("temp dir");
10921 let mut session = Session::create();
10922 session.session_dir = Some(temp_dir.path().to_path_buf());
10923
10924 session.append_message(make_test_message("msg A"));
10925 run_async(async { session.save().await }).unwrap();
10926
10927 let path = session.path.clone().unwrap();
10928 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
10929
10930 std::thread::sleep(std::time::Duration::from_millis(50));
10932
10933 run_async(async { session.save().await }).unwrap();
10935
10936 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
10937 assert_eq!(
10938 mtime_before, mtime_after,
10939 "file should not be modified on no-op save"
10940 );
10941 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
10942 }
10943
10944 #[test]
10945 fn test_incremental_append_caches_stay_valid() {
10946 let temp_dir = tempfile::tempdir().expect("temp dir");
10947 let mut session = Session::create();
10948 session.session_dir = Some(temp_dir.path().to_path_buf());
10949
10950 session.append_message(make_test_message("msg A"));
10951 run_async(async { session.save().await }).unwrap();
10952
10953 assert_eq!(session.entry_index.len(), 1);
10955
10956 let id_b = session.append_message(make_test_message("msg B"));
10958 let id_c = session.append_message(make_test_message("msg C"));
10959 run_async(async { session.save().await }).unwrap();
10960
10961 assert_eq!(session.entry_index.len(), 3);
10963 assert!(session.entry_index.contains_key(&id_b));
10964 assert!(session.entry_index.contains_key(&id_c));
10965 assert_eq!(session.cached_message_count, 3);
10966 }
10967
10968 #[test]
10969 fn test_set_branched_from_marks_header_dirty() {
10970 let mut session = Session::create();
10971 assert!(!session.header_dirty);
10972
10973 session.set_branched_from(Some("/some/path".to_string()));
10974 assert!(session.header_dirty);
10975 }
10976
10977 fn build_crash_test_session_file(num_entries: usize) -> String {
10983 let header = serde_json::json!({
10984 "type": "session",
10985 "version": 3,
10986 "id": "crash-test",
10987 "timestamp": "2024-06-01T00:00:00.000Z",
10988 "cwd": "/tmp/test"
10989 });
10990 let mut lines = vec![serde_json::to_string(&header).unwrap()];
10991 for i in 0..num_entries {
10992 let entry = serde_json::json!({
10993 "type": "message",
10994 "id": format!("entry-{i}"),
10995 "timestamp": "2024-06-01T00:00:00.000Z",
10996 "message": {"role": "user", "content": format!("message {i}")}
10997 });
10998 lines.push(serde_json::to_string(&entry).unwrap());
10999 }
11000 lines.join("\n")
11001 }
11002
11003 #[test]
11004 fn crash_empty_file_returns_error() {
11005 let temp_dir = tempfile::tempdir().unwrap();
11006 let file_path = temp_dir.path().join("empty.jsonl");
11007 std::fs::write(&file_path, "").unwrap();
11008
11009 let result = run_async(async {
11010 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11011 });
11012 assert!(result.is_err(), "empty file should fail to open");
11013 }
11014
11015 #[test]
11016 fn crash_corrupted_header_returns_error() {
11017 let temp_dir = tempfile::tempdir().unwrap();
11018 let file_path = temp_dir.path().join("bad_header.jsonl");
11019 std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
11020
11021 let result = run_async(async {
11022 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11023 });
11024 assert!(result.is_err(), "corrupted header should fail");
11025 }
11026
11027 #[test]
11028 fn crash_header_only_loads_empty() {
11029 let temp_dir = tempfile::tempdir().unwrap();
11030 let file_path = temp_dir.path().join("header_only.jsonl");
11031 let header = serde_json::json!({
11032 "type": "session",
11033 "version": 3,
11034 "id": "hdr-only",
11035 "timestamp": "2024-06-01T00:00:00.000Z",
11036 "cwd": "/tmp/test"
11037 });
11038 std::fs::write(
11039 &file_path,
11040 format!("{}\n", serde_json::to_string(&header).unwrap()),
11041 )
11042 .unwrap();
11043
11044 let (session, diagnostics) = run_async(async {
11045 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11046 })
11047 .unwrap();
11048
11049 assert!(session.entries.is_empty());
11050 assert!(diagnostics.skipped_entries.is_empty());
11051 }
11052
11053 #[test]
11054 fn crash_truncated_last_entry_recovers_preceding() {
11055 let temp_dir = tempfile::tempdir().unwrap();
11056 let file_path = temp_dir.path().join("truncated.jsonl");
11057
11058 let mut content = build_crash_test_session_file(3);
11059 let truncation_point = content.rfind('\n').unwrap();
11060 content.truncate(truncation_point);
11061 content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
11062
11063 std::fs::write(&file_path, &content).unwrap();
11064
11065 let (session, diagnostics) = run_async(async {
11066 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11067 })
11068 .unwrap();
11069
11070 assert_eq!(session.entries.len(), 2);
11071 assert_eq!(diagnostics.skipped_entries.len(), 1);
11072 }
11073
11074 #[test]
11075 fn crash_multiple_corrupted_entries_recovers_valid() {
11076 let temp_dir = tempfile::tempdir().unwrap();
11077 let file_path = temp_dir.path().join("multi_corrupt.jsonl");
11078
11079 let header = serde_json::json!({
11080 "type": "session",
11081 "version": 3,
11082 "id": "multi-corrupt",
11083 "timestamp": "2024-06-01T00:00:00.000Z",
11084 "cwd": "/tmp/test"
11085 });
11086
11087 let valid_entry = |id: &str, text: &str| {
11088 serde_json::json!({
11089 "type": "message",
11090 "id": id,
11091 "timestamp": "2024-06-01T00:00:00.000Z",
11092 "message": {"role": "user", "content": text}
11093 })
11094 .to_string()
11095 };
11096
11097 let lines = [
11098 serde_json::to_string(&header).unwrap(),
11099 valid_entry("v1", "first"),
11100 "GARBAGE LINE 1".to_string(),
11101 valid_entry("v2", "second"),
11102 "{incomplete json".to_string(),
11103 valid_entry("v3", "third"),
11104 ];
11105
11106 std::fs::write(&file_path, lines.join("\n")).unwrap();
11107
11108 let (session, diagnostics) = run_async(async {
11109 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11110 })
11111 .unwrap();
11112
11113 assert_eq!(session.entries.len(), 3, "3 valid entries survive");
11114 assert_eq!(diagnostics.skipped_entries.len(), 2);
11115 }
11116
11117 #[test]
11118 fn crash_incremental_append_survives_partial_write() {
11119 use std::io::Write;
11120
11121 let temp_dir = tempfile::tempdir().unwrap();
11122 let mut session = Session::create();
11123 session.session_dir = Some(temp_dir.path().to_path_buf());
11124
11125 session.append_message(make_test_message("msg A"));
11126 session.append_message(make_test_message("msg B"));
11127 run_async(async { session.save().await }).unwrap();
11128 let path = session.path.clone().unwrap();
11129
11130 let mut file = std::fs::OpenOptions::new()
11132 .append(true)
11133 .open(&path)
11134 .unwrap();
11135 write!(
11136 file,
11137 "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
11138 )
11139 .unwrap();
11140 drop(file);
11141
11142 let (loaded, diagnostics) = run_async(async {
11143 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11144 })
11145 .unwrap();
11146
11147 assert_eq!(loaded.entries.len(), 2, "original entries recovered");
11148 assert_eq!(diagnostics.skipped_entries.len(), 1);
11149 }
11150
11151 #[test]
11152 fn crash_full_rewrite_atomic_persist() {
11153 let temp_dir = tempfile::tempdir().unwrap();
11154 let mut session = Session::create();
11155 session.session_dir = Some(temp_dir.path().to_path_buf());
11156
11157 session.append_message(make_test_message("original"));
11158 run_async(async { session.save().await }).unwrap();
11159 let path = session.path.clone().unwrap();
11160
11161 let original_content = std::fs::read_to_string(&path).unwrap();
11162
11163 session.set_model_header(Some("new-provider".to_string()), None, None);
11164 session.append_message(make_test_message("second"));
11165 run_async(async { session.save().await }).unwrap();
11166
11167 let new_content = std::fs::read_to_string(&path).unwrap();
11168 assert_ne!(original_content, new_content);
11169
11170 let loaded =
11171 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11172 assert_eq!(loaded.entries.len(), 2);
11173 }
11174
11175 #[cfg(unix)]
11176 #[test]
11177 fn crash_full_rewrite_preserves_existing_file_permissions() {
11178 use std::os::unix::fs::PermissionsExt;
11179
11180 let temp_dir = tempfile::tempdir().unwrap();
11181 let mut session = Session::create();
11182 session.session_dir = Some(temp_dir.path().to_path_buf());
11183
11184 session.append_message(make_test_message("original"));
11185 run_async(async { session.save().await }).unwrap();
11186 let path = session.path.clone().unwrap();
11187
11188 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11189
11190 session.set_model_header(Some("new-provider".to_string()), None, None);
11191 session.append_message(make_test_message("second"));
11192 run_async(async { session.save().await }).unwrap();
11193
11194 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
11195 assert_eq!(mode, 0o444, "full rewrite must preserve existing mode bits");
11196 }
11197
11198 #[test]
11199 fn full_rewrite_preserves_entries_appended_by_other_writer() {
11200 let temp_dir = tempfile::tempdir().unwrap();
11201 let mut session = Session::create();
11202 session.session_dir = Some(temp_dir.path().to_path_buf());
11203
11204 session.append_message(make_test_message("original"));
11205 run_async(async { session.save().await }).unwrap();
11206 let path = session.path.clone().unwrap();
11207
11208 let mut stale_rewriter =
11209 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11210 let mut appender =
11211 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11212
11213 appender.append_message(make_test_message("from appender"));
11214 run_async(async { appender.save().await }).unwrap();
11215
11216 stale_rewriter.set_model_header(Some("new-provider".to_string()), None, None);
11217 run_async(async { stale_rewriter.save().await }).unwrap();
11218
11219 let loaded =
11220 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11221 let entry_texts = loaded
11222 .entries
11223 .iter()
11224 .filter_map(|entry| match entry {
11225 SessionEntry::Message(message) => match &message.message {
11226 SessionMessage::User { content, .. } => match content {
11227 UserContent::Text(text) => Some(text.clone()),
11228 UserContent::Blocks(_) => None,
11229 },
11230 SessionMessage::Assistant { message } => {
11231 message.content.iter().find_map(|block| match block {
11232 ContentBlock::Text(TextContent { text, .. }) => Some(text.clone()),
11233 _ => None,
11234 })
11235 }
11236 SessionMessage::ToolResult { .. } => None,
11237 SessionMessage::Custom { .. } => None,
11238 SessionMessage::BashExecution { .. } => None,
11239 SessionMessage::BranchSummary { .. } => None,
11240 SessionMessage::CompactionSummary { .. } => None,
11241 },
11242 _ => None,
11243 })
11244 .collect::<Vec<_>>();
11245
11246 assert!(
11247 entry_texts.iter().any(|text| text.eq("from appender")),
11248 "full rewrite should preserve entries appended after this session was opened"
11249 );
11250 assert_eq!(loaded.header.provider.as_deref(), Some("new-provider"));
11251 }
11252
11253 #[test]
11254 fn crash_flush_failure_restores_pending_mutations() {
11255 let mut queue = AutosaveQueue::with_limit(10);
11256
11257 queue.enqueue_mutation(AutosaveMutationKind::Message);
11258 queue.enqueue_mutation(AutosaveMutationKind::Message);
11259 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11260 assert_eq!(queue.pending_mutations, 3);
11261
11262 let ticket = queue
11263 .begin_flush(AutosaveFlushTrigger::Periodic)
11264 .expect("should have ticket");
11265 assert_eq!(queue.pending_mutations, 0);
11266
11267 queue.finish_flush(ticket, false);
11268 assert_eq!(queue.pending_mutations, 3, "mutations restored");
11269 assert_eq!(queue.flush_failed, 1);
11270 }
11271
11272 #[test]
11273 fn crash_flush_failure_respects_queue_capacity() {
11274 let mut queue = AutosaveQueue::with_limit(3);
11275
11276 for _ in 0..3 {
11277 queue.enqueue_mutation(AutosaveMutationKind::Message);
11278 }
11279 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11280
11281 queue.enqueue_mutation(AutosaveMutationKind::Message);
11282 queue.enqueue_mutation(AutosaveMutationKind::Message);
11283 assert_eq!(queue.pending_mutations, 2);
11284
11285 queue.finish_flush(ticket, false);
11286 assert_eq!(queue.pending_mutations, 3, "capped at max");
11287 assert!(queue.backpressure_events >= 2);
11288 }
11289
11290 #[test]
11291 fn crash_shutdown_strict_propagates_error() {
11292 let temp_dir = tempfile::tempdir().unwrap();
11293 let mut session = Session::create();
11294 session.path = Some(
11295 temp_dir
11296 .path()
11297 .join("nonexistent_dir")
11298 .join("session.jsonl"),
11299 );
11300 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
11301 session.append_message(make_test_message("must save"));
11302 session
11303 .autosave_queue
11304 .enqueue_mutation(AutosaveMutationKind::Message);
11305
11306 let result = run_async(async { session.flush_autosave_on_shutdown().await });
11307 assert!(result.is_err(), "strict mode propagates errors");
11308 }
11309
11310 #[test]
11311 fn crash_shutdown_balanced_swallows_error() {
11312 let temp_dir = tempfile::tempdir().unwrap();
11313 let mut session = Session::create();
11314 session.path = Some(
11315 temp_dir
11316 .path()
11317 .join("nonexistent_dir")
11318 .join("session.jsonl"),
11319 );
11320 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
11321 session.append_message(make_test_message("best effort"));
11322 session
11323 .autosave_queue
11324 .enqueue_mutation(AutosaveMutationKind::Message);
11325
11326 let result = run_async(async { session.flush_autosave_on_shutdown().await });
11327 assert!(result.is_ok(), "balanced mode swallows errors");
11328 }
11329
11330 #[test]
11331 fn crash_shutdown_throughput_skips_flush() {
11332 let temp_dir = tempfile::tempdir().unwrap();
11333 let mut session = Session::create();
11334 session.path = Some(
11335 temp_dir
11336 .path()
11337 .join("nonexistent_dir")
11338 .join("session.jsonl"),
11339 );
11340 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
11341 session.append_message(make_test_message("no flush"));
11342 session
11343 .autosave_queue
11344 .enqueue_mutation(AutosaveMutationKind::Message);
11345
11346 let result = run_async(async { session.flush_autosave_on_shutdown().await });
11347 assert!(result.is_ok());
11348 assert!(session.autosave_queue.pending_mutations > 0);
11349 }
11350
11351 #[test]
11352 fn crash_save_reload_preserves_all_entry_types() {
11353 let temp_dir = tempfile::tempdir().unwrap();
11354 let mut session = Session::create();
11355 session.session_dir = Some(temp_dir.path().to_path_buf());
11356
11357 let id_a = session.append_message(make_test_message("msg A"));
11358 session.append_model_change("provider-x".to_string(), "model-y".to_string());
11359 session.append_thinking_level_change("high".to_string());
11360 session.append_compaction("summary".to_string(), id_a, 500, None, None);
11361 session.append_message(make_test_message("msg B"));
11362
11363 run_async(async { session.save().await }).unwrap();
11364 let path = session.path.clone().unwrap();
11365
11366 let loaded =
11367 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11368 assert_eq!(loaded.entries.len(), session.entries.len());
11369 }
11370
11371 #[test]
11372 fn crash_checkpoint_rewrite_cleans_corruption() {
11373 let temp_dir = tempfile::tempdir().unwrap();
11374 let mut session = Session::create();
11375 session.session_dir = Some(temp_dir.path().to_path_buf());
11376
11377 session.append_message(make_test_message("initial"));
11378 run_async(async { session.save().await }).unwrap();
11379 let path = session.path.clone().unwrap();
11380
11381 for i in 0..5 {
11382 session.append_message(make_test_message(&format!("msg {i}")));
11383 run_async(async { session.save().await }).unwrap();
11384 }
11385
11386 let content = std::fs::read_to_string(&path).unwrap();
11388 let mut lines: Vec<String> = content.lines().map(String::from).collect();
11389 lines[3] = "CORRUPTED_ENTRY".to_string();
11390 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11391
11392 session.appends_since_checkpoint = compaction_checkpoint_interval();
11394 session.append_message(make_test_message("post checkpoint"));
11395 run_async(async { session.save().await }).unwrap();
11396 assert_eq!(session.appends_since_checkpoint, 0);
11397
11398 let (reloaded, diagnostics) = run_async(async {
11399 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11400 })
11401 .unwrap();
11402 assert!(diagnostics.skipped_entries.is_empty());
11403 assert_eq!(reloaded.entries.len(), 7);
11404 }
11405
11406 #[test]
11407 fn crash_trailing_newlines_loads_cleanly() {
11408 let temp_dir = tempfile::tempdir().unwrap();
11409 let file_path = temp_dir.path().join("trailing_nl.jsonl");
11410
11411 let mut content = build_crash_test_session_file(2);
11412 content.push_str("\n\n\n");
11413 std::fs::write(&file_path, &content).unwrap();
11414
11415 let (session, diagnostics) = run_async(async {
11416 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
11417 })
11418 .unwrap();
11419
11420 assert_eq!(session.entries.len(), 2);
11421 assert!(diagnostics.skipped_entries.is_empty());
11422 }
11423
11424 #[test]
11425 fn crash_noop_save_after_reload_is_idempotent() {
11426 let temp_dir = tempfile::tempdir().unwrap();
11427 let mut session = Session::create();
11428 session.session_dir = Some(temp_dir.path().to_path_buf());
11429
11430 session.append_message(make_test_message("hello"));
11431 run_async(async { session.save().await }).unwrap();
11432 let path = session.path.clone().unwrap();
11433 let content_before = std::fs::read_to_string(&path).unwrap();
11434
11435 let mut loaded =
11436 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11437 run_async(async { loaded.save().await }).unwrap();
11438
11439 let content_after = std::fs::read_to_string(&path).unwrap();
11440 assert_eq!(content_before, content_after);
11441 }
11442
11443 #[test]
11444 fn crash_corrupt_then_continue_operation() {
11445 let temp_dir = tempfile::tempdir().unwrap();
11446 let mut session = Session::create();
11447 session.session_dir = Some(temp_dir.path().to_path_buf());
11448
11449 session.append_message(make_test_message("msg A"));
11450 session.append_message(make_test_message("msg B"));
11451 run_async(async { session.save().await }).unwrap();
11452 let path = session.path.clone().unwrap();
11453
11454 let content = std::fs::read_to_string(&path).unwrap();
11456 let mut lines: Vec<String> = content.lines().map(String::from).collect();
11457 *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
11458 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
11459
11460 let (mut recovered, diagnostics) = run_async(async {
11461 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
11462 })
11463 .unwrap();
11464 assert_eq!(diagnostics.skipped_entries.len(), 1);
11465 assert_eq!(recovered.entries.len(), 1);
11466
11467 recovered.path = Some(path.clone());
11469 recovered.session_dir = Some(temp_dir.path().to_path_buf());
11470 recovered.append_message(make_test_message("msg C"));
11471 run_async(async { recovered.save().await }).unwrap();
11472
11473 let reloaded =
11474 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11475 assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
11476 }
11477
11478 #[test]
11479 fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
11480 let temp_dir = tempfile::tempdir().unwrap();
11481 let mut session = Session::create();
11482 session.session_dir = Some(temp_dir.path().to_path_buf());
11483
11484 session.append_message(make_test_message("msg A"));
11485 run_async(async { session.save().await }).unwrap();
11486
11487 session.persisted_entry_count.store(999, Ordering::SeqCst);
11488 assert!(session.should_full_rewrite());
11489
11490 session.append_message(make_test_message("msg B"));
11491 run_async(async { session.save().await }).unwrap();
11492 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11493 assert_eq!(session.appends_since_checkpoint, 0);
11494 }
11495
11496 #[test]
11497 fn crash_persisted_count_unchanged_on_append_failure() {
11498 let temp_dir = tempfile::tempdir().unwrap();
11499 let mut session = Session::create();
11500 session.session_dir = Some(temp_dir.path().to_path_buf());
11501
11502 session.append_message(make_test_message("msg A"));
11503 run_async(async { session.save().await }).unwrap();
11504 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11505
11506 let path = session.path.clone().unwrap();
11507 session.append_message(make_test_message("msg B"));
11508
11509 #[cfg(unix)]
11510 {
11511 use std::os::unix::fs::PermissionsExt;
11512 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11513 if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11514 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11516 return;
11517 }
11518 }
11519 #[cfg(not(unix))]
11520 {
11521 return;
11522 }
11523
11524 let result = run_async(async { session.save().await });
11525
11526 #[cfg(unix)]
11527 {
11528 use std::os::unix::fs::PermissionsExt;
11529 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11530 }
11531
11532 assert!(result.is_err());
11533 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11534
11535 run_async(async { session.save().await }).unwrap();
11536 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11537 }
11538
11539 #[test]
11540 fn crash_missing_session_file_forces_full_rewrite_recovery() {
11541 let temp_dir = tempfile::tempdir().unwrap();
11542 let mut session = Session::create();
11543 session.session_dir = Some(temp_dir.path().to_path_buf());
11544
11545 session.append_message(make_test_message("msg A"));
11546 run_async(async { session.save().await }).unwrap();
11547
11548 let path = session.path.clone().unwrap();
11549 std::fs::remove_file(&path).unwrap();
11550 assert!(session.should_full_rewrite());
11551
11552 session.append_message(make_test_message("msg B"));
11553 run_async(async { session.save().await }).unwrap();
11554
11555 let reloaded =
11556 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11557 assert_eq!(reloaded.entries.len(), 2);
11558 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11559 assert_eq!(session.appends_since_checkpoint, 0);
11560 }
11561
11562 #[test]
11563 fn crash_queue_backpressure_at_limit() {
11564 let mut queue = AutosaveQueue::with_limit(3);
11565
11566 queue.enqueue_mutation(AutosaveMutationKind::Message);
11567 queue.enqueue_mutation(AutosaveMutationKind::Message);
11568 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11569 assert_eq!(queue.pending_mutations, 3);
11570
11571 queue.enqueue_mutation(AutosaveMutationKind::Label);
11572 assert_eq!(queue.pending_mutations, 3, "capped");
11573 assert_eq!(queue.backpressure_events, 1);
11574 }
11575
11576 #[test]
11577 fn crash_flush_failure_with_intervening_mutations() {
11578 let mut queue = AutosaveQueue::with_limit(8);
11579
11580 for _ in 0..4 {
11581 queue.enqueue_mutation(AutosaveMutationKind::Message);
11582 }
11583 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11584
11585 queue.enqueue_mutation(AutosaveMutationKind::Message);
11586 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11587 assert_eq!(queue.pending_mutations, 2);
11588
11589 queue.finish_flush(ticket, false);
11591 assert_eq!(queue.pending_mutations, 6);
11592 assert_eq!(queue.flush_failed, 1);
11593 }
11594
11595 #[test]
11596 fn crash_queue_metrics_snapshot() {
11597 let mut queue = AutosaveQueue::with_limit(5);
11598 queue.enqueue_mutation(AutosaveMutationKind::Message);
11599 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11600 queue.enqueue_mutation(AutosaveMutationKind::Label);
11601
11602 let metrics = queue.metrics();
11603 assert_eq!(metrics.pending_mutations, 3);
11604 assert_eq!(metrics.max_pending_mutations, 5);
11605 assert_eq!(metrics.coalesced_mutations, 2);
11606 assert_eq!(metrics.flush_started, 0);
11607 assert!(metrics.last_flush_duration_ms.is_none());
11608 }
11609
11610 #[test]
11611 fn crash_double_flush_is_noop() {
11612 let mut queue = AutosaveQueue::with_limit(10);
11613 queue.enqueue_mutation(AutosaveMutationKind::Message);
11614 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
11615 queue.finish_flush(ticket, true);
11616
11617 assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
11618 }
11619
11620 #[test]
11621 fn crash_finish_worker_result_propagates_panic_before_cancellation() {
11622 let handle = thread::spawn(|| -> () {
11623 test_fail!("jsonl worker panic");
11624 });
11625
11626 let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
11627 let _: Result<()> =
11628 finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled");
11629 }));
11630
11631 assert!(
11632 panic.is_err(),
11633 "worker panic should not be masked as cancellation"
11634 );
11635 }
11636
11637 #[test]
11638 fn crash_finish_worker_result_maps_nonpanic_cancellation_to_session_error() {
11639 let handle = thread::spawn(|| {});
11640
11641 let err = finish_worker_result::<(), _>(handle, Err(()), "Save task cancelled")
11642 .expect_err("error");
11643
11644 assert!(
11645 err.to_string().contains("Save task cancelled"),
11646 "unexpected error: {err}"
11647 );
11648 }
11649
11650 #[test]
11651 fn crash_finish_worker_result_returns_success_payload() {
11652 let handle = thread::spawn(|| {});
11653
11654 let value =
11655 finish_worker_result::<usize, ()>(handle, Ok(Ok(7usize)), "task cancelled").unwrap();
11656
11657 assert_eq!(value, 7);
11658 }
11659
11660 #[test]
11661 fn crash_entries_survive_failed_full_rewrite() {
11662 let temp_dir = tempfile::tempdir().unwrap();
11665 let mut session = Session::create();
11666 session.session_dir = Some(temp_dir.path().to_path_buf());
11667
11668 session.append_message(make_test_message("msg A"));
11669 run_async(async { session.save().await }).unwrap();
11670 let path = session.path.clone().unwrap();
11671
11672 session.set_model_header(Some("new-provider".to_string()), None, None);
11673 session.append_message(make_test_message("msg B"));
11674
11675 #[cfg(unix)]
11676 {
11677 use std::os::unix::fs::PermissionsExt;
11678 let parent = path.parent().unwrap();
11679 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
11680 if tempfile::NamedTempFile::new_in(parent).is_ok() {
11681 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11683 return;
11684 }
11685 }
11686 #[cfg(not(unix))]
11687 {
11688 return;
11689 }
11690
11691 let result = run_async(async { session.save().await });
11692 assert!(result.is_err());
11693
11694 assert_eq!(session.entries.len(), 2, "entries restored");
11695 assert_eq!(session.entry_index.len(), 2);
11696 assert!(session.header_dirty);
11697
11698 #[cfg(unix)]
11699 {
11700 use std::os::unix::fs::PermissionsExt;
11701 let parent = path.parent().unwrap();
11702 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
11703 }
11704
11705 run_async(async { session.save().await }).unwrap();
11706 assert!(!session.header_dirty);
11707 }
11708
11709 #[test]
11710 fn crash_metrics_accumulate_across_failure_recovery() {
11711 let temp_dir = tempfile::tempdir().unwrap();
11712 let mut session = Session::create();
11713 session.session_dir = Some(temp_dir.path().to_path_buf());
11714
11715 session.append_message(make_test_message("msg A"));
11716 run_async(async { session.save().await }).unwrap();
11717 let path = session.path.clone().unwrap();
11718
11719 let m = session.autosave_metrics();
11720 assert_eq!(m.flush_succeeded, 1);
11721 assert_eq!(m.flush_failed, 0);
11722
11723 #[cfg(unix)]
11724 {
11725 use std::os::unix::fs::PermissionsExt;
11726 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11727 if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11728 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11730 return;
11731 }
11732 }
11733 #[cfg(not(unix))]
11734 {
11735 return;
11736 }
11737
11738 session.append_message(make_test_message("msg B"));
11739 let _ = run_async(async { session.save().await });
11740
11741 let m = session.autosave_metrics();
11742 assert_eq!(m.flush_failed, 1);
11743 assert!(m.pending_mutations > 0);
11744
11745 #[cfg(unix)]
11746 {
11747 use std::os::unix::fs::PermissionsExt;
11748 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11749 }
11750 run_async(async { session.save().await }).unwrap();
11751
11752 let m = session.autosave_metrics();
11753 assert_eq!(m.flush_succeeded, 2);
11754 assert_eq!(m.flush_failed, 1);
11755 assert_eq!(m.pending_mutations, 0);
11756 assert_eq!(m.flush_started, 3);
11757 }
11758
11759 #[test]
11760 fn crash_many_sequential_appends_accumulate() {
11761 let temp_dir = tempfile::tempdir().unwrap();
11762 let mut session = Session::create();
11763 session.session_dir = Some(temp_dir.path().to_path_buf());
11764
11765 session.append_message(make_test_message("initial"));
11766 run_async(async { session.save().await }).unwrap();
11767
11768 for i in 0..10 {
11769 session.append_message(make_test_message(&format!("append-{i}")));
11770 run_async(async { session.save().await }).unwrap();
11771 }
11772
11773 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
11774 assert_eq!(session.appends_since_checkpoint, 10);
11775
11776 let path = session.path.clone().unwrap();
11777 let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
11778 assert_eq!(line_count, 12, "1 header + 11 entries");
11779
11780 let loaded =
11781 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11782 assert_eq!(loaded.entries.len(), 11);
11783 }
11784
11785 #[test]
11786 fn crash_load_unsaved_entry_absent() {
11787 let temp_dir = tempfile::tempdir().unwrap();
11788 let mut session = Session::create();
11789 session.session_dir = Some(temp_dir.path().to_path_buf());
11790
11791 session.append_message(make_test_message("saved A"));
11792 session.append_message(make_test_message("saved B"));
11793 run_async(async { session.save().await }).unwrap();
11794 let path = session.path.clone().unwrap();
11795
11796 session.append_message(make_test_message("unsaved C"));
11797 assert_eq!(session.entries.len(), 3);
11798
11799 let loaded =
11800 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11801 assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
11802 }
11803
11804 #[test]
11805 fn test_clone_has_independent_persisted_entry_count() {
11806 let session = Session::create();
11807 session.persisted_entry_count.store(10, Ordering::SeqCst);
11809
11810 let clone = session.clone();
11812
11813 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11815
11816 session.persisted_entry_count.store(20, Ordering::SeqCst);
11818
11819 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
11821
11822 clone.persisted_entry_count.store(30, Ordering::SeqCst);
11824
11825 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
11827 }
11828
11829 #[test]
11830 fn crash_append_retry_after_transient_failure() {
11831 let temp_dir = tempfile::tempdir().unwrap();
11832 let mut session = Session::create();
11833 session.session_dir = Some(temp_dir.path().to_path_buf());
11834
11835 session.append_message(make_test_message("msg A"));
11836 run_async(async { session.save().await }).unwrap();
11837 let path = session.path.clone().unwrap();
11838
11839 session.append_message(make_test_message("msg B"));
11840
11841 #[cfg(unix)]
11842 {
11843 use std::os::unix::fs::PermissionsExt;
11844 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
11845 if std::fs::OpenOptions::new().append(true).open(&path).is_ok() {
11846 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11848 return;
11849 }
11850 }
11851 #[cfg(not(unix))]
11852 {
11853 return;
11854 }
11855
11856 let result = run_async(async { session.save().await });
11857 assert!(result.is_err());
11858 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
11859
11860 #[cfg(unix)]
11861 {
11862 use std::os::unix::fs::PermissionsExt;
11863 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
11864 }
11865
11866 run_async(async { session.save().await }).unwrap();
11867 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
11868
11869 let loaded =
11870 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
11871 assert_eq!(loaded.entries.len(), 2);
11872 }
11873
11874 #[test]
11875 fn crash_durability_mode_parsing() {
11876 assert_eq!(
11877 AutosaveDurabilityMode::parse("strict"),
11878 Some(AutosaveDurabilityMode::Strict)
11879 );
11880 assert_eq!(
11881 AutosaveDurabilityMode::parse("BALANCED"),
11882 Some(AutosaveDurabilityMode::Balanced)
11883 );
11884 assert_eq!(
11885 AutosaveDurabilityMode::parse(" Throughput "),
11886 Some(AutosaveDurabilityMode::Throughput)
11887 );
11888 assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
11889 assert_eq!(AutosaveDurabilityMode::parse(""), None);
11890 }
11891
11892 #[test]
11893 fn crash_durability_resolve_precedence() {
11894 assert_eq!(
11895 resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
11896 AutosaveDurabilityMode::Strict
11897 );
11898 assert_eq!(
11899 resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
11900 AutosaveDurabilityMode::Throughput
11901 );
11902 assert_eq!(
11903 resolve_autosave_durability_mode(None, None, Some("strict")),
11904 AutosaveDurabilityMode::Strict
11905 );
11906 assert_eq!(
11907 resolve_autosave_durability_mode(None, None, None),
11908 AutosaveDurabilityMode::Balanced
11909 );
11910 }
11911
11912 #[test]
11919 fn autosave_queue_limit_one_accepts_single_mutation() {
11920 let mut queue = AutosaveQueue::with_limit(1);
11921 queue.enqueue_mutation(AutosaveMutationKind::Message);
11922 assert_eq!(queue.pending_mutations, 1);
11923 assert_eq!(queue.coalesced_mutations, 0);
11924 assert_eq!(queue.backpressure_events, 0);
11925 }
11926
11927 #[test]
11928 fn autosave_queue_limit_one_backpressures_second_mutation() {
11929 let mut queue = AutosaveQueue::with_limit(1);
11930 queue.enqueue_mutation(AutosaveMutationKind::Message);
11931 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11932 assert_eq!(queue.pending_mutations, 1, "capped at 1");
11933 assert_eq!(queue.backpressure_events, 1);
11934 assert_eq!(queue.coalesced_mutations, 1);
11935 }
11936
11937 #[test]
11938 fn autosave_queue_limit_one_flush_and_refill() {
11939 let mut queue = AutosaveQueue::with_limit(1);
11940 queue.enqueue_mutation(AutosaveMutationKind::Message);
11941
11942 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
11943 assert_eq!(queue.pending_mutations, 0);
11944 assert_eq!(ticket.batch_size, 1);
11945 queue.finish_flush(ticket, true);
11946
11947 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11949 assert_eq!(queue.pending_mutations, 1);
11950 assert_eq!(queue.flush_succeeded, 1);
11951 }
11952
11953 #[test]
11956 fn autosave_queue_with_limit_zero_clamps_to_one() {
11957 let queue = AutosaveQueue::with_limit(0);
11958 assert_eq!(queue.max_pending_mutations, 1);
11959 }
11960
11961 #[test]
11964 fn autosave_queue_begin_flush_on_empty_returns_none() {
11965 let mut queue = AutosaveQueue::with_limit(10);
11966 assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
11967 assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
11968 }
11969
11970 #[test]
11971 fn autosave_queue_metrics_on_fresh_queue() {
11972 let queue = AutosaveQueue::with_limit(256);
11973 let m = queue.metrics();
11974 assert_eq!(m.pending_mutations, 0);
11975 assert_eq!(m.max_pending_mutations, 256);
11976 assert_eq!(m.coalesced_mutations, 0);
11977 assert_eq!(m.backpressure_events, 0);
11978 assert_eq!(m.flush_started, 0);
11979 assert_eq!(m.flush_succeeded, 0);
11980 assert_eq!(m.flush_failed, 0);
11981 assert_eq!(m.last_flush_batch_size, 0);
11982 assert!(m.last_flush_duration_ms.is_none());
11983 assert!(m.last_flush_trigger.is_none());
11984 }
11985
11986 #[test]
11989 fn autosave_queue_all_mutation_kinds() {
11990 let mut queue = AutosaveQueue::with_limit(10);
11991 queue.enqueue_mutation(AutosaveMutationKind::Message);
11992 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
11993 queue.enqueue_mutation(AutosaveMutationKind::Label);
11994 assert_eq!(queue.pending_mutations, 3);
11995 assert_eq!(queue.coalesced_mutations, 2);
11997 }
11998
11999 #[test]
12002 fn autosave_queue_consecutive_success_flushes() {
12003 let mut queue = AutosaveQueue::with_limit(5);
12004
12005 for round in 1..=3_u64 {
12006 queue.enqueue_mutation(AutosaveMutationKind::Message);
12007 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12008 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12009 queue.finish_flush(ticket, true);
12010 assert_eq!(queue.pending_mutations, 0);
12011 assert_eq!(queue.flush_succeeded, round);
12012 assert_eq!(queue.flush_started, round);
12013 assert_eq!(queue.last_flush_batch_size, 2);
12014 }
12015 assert_eq!(queue.flush_failed, 0);
12016 }
12017
12018 #[test]
12019 fn autosave_queue_alternating_success_failure() {
12020 let mut queue = AutosaveQueue::with_limit(10);
12021
12022 queue.enqueue_mutation(AutosaveMutationKind::Message);
12024 let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12025 queue.finish_flush(t1, true);
12026 assert_eq!(queue.flush_succeeded, 1);
12027 assert_eq!(queue.flush_failed, 0);
12028 assert_eq!(queue.pending_mutations, 0);
12029
12030 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12032 queue.enqueue_mutation(AutosaveMutationKind::Label);
12033 let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12034 queue.finish_flush(t2, false);
12035 assert_eq!(queue.flush_succeeded, 1);
12036 assert_eq!(queue.flush_failed, 1);
12037 assert_eq!(queue.pending_mutations, 2, "restored from failure");
12038
12039 let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12041 assert_eq!(t3.batch_size, 2);
12042 queue.finish_flush(t3, true);
12043 assert_eq!(queue.flush_succeeded, 2);
12044 assert_eq!(queue.flush_failed, 1);
12045 assert_eq!(queue.pending_mutations, 0);
12046 assert_eq!(queue.flush_started, 3);
12047 }
12048
12049 #[test]
12052 fn autosave_queue_failure_drops_all_when_full() {
12053 let mut queue = AutosaveQueue::with_limit(3);
12054
12055 for _ in 0..3 {
12057 queue.enqueue_mutation(AutosaveMutationKind::Message);
12058 }
12059 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12060 assert_eq!(ticket.batch_size, 3);
12061 assert_eq!(queue.pending_mutations, 0);
12062
12063 for _ in 0..3 {
12065 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12066 }
12067 assert_eq!(queue.pending_mutations, 3);
12068
12069 let bp_before = queue.backpressure_events;
12071 queue.finish_flush(ticket, false);
12072 assert_eq!(queue.pending_mutations, 3, "capped at max");
12073 assert_eq!(queue.flush_failed, 1);
12074 assert_eq!(
12075 queue.backpressure_events,
12076 bp_before + 3,
12077 "dropped mutations counted as backpressure"
12078 );
12079 }
12080
12081 #[test]
12084 fn autosave_queue_tracks_trigger_across_flushes() {
12085 let mut queue = AutosaveQueue::with_limit(10);
12086
12087 queue.enqueue_mutation(AutosaveMutationKind::Message);
12089 let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12090 assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
12091 queue.finish_flush(t1, true);
12092 assert_eq!(
12093 queue.metrics().last_flush_trigger,
12094 Some(AutosaveFlushTrigger::Manual)
12095 );
12096
12097 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12099 let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12100 queue.finish_flush(t2, true);
12101 assert_eq!(
12102 queue.metrics().last_flush_trigger,
12103 Some(AutosaveFlushTrigger::Periodic)
12104 );
12105
12106 queue.enqueue_mutation(AutosaveMutationKind::Label);
12108 let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
12109 queue.finish_flush(t3, true);
12110 assert_eq!(
12111 queue.metrics().last_flush_trigger,
12112 Some(AutosaveFlushTrigger::Shutdown)
12113 );
12114 }
12115
12116 #[test]
12119 fn autosave_queue_flush_records_duration() {
12120 let mut queue = AutosaveQueue::with_limit(10);
12121 queue.enqueue_mutation(AutosaveMutationKind::Message);
12122 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12123 queue.finish_flush(ticket, true);
12124 assert!(queue.metrics().last_flush_duration_ms.is_some());
12126 }
12127
12128 #[test]
12131 fn autosave_queue_rapid_single_mutation_flushes() {
12132 let mut queue = AutosaveQueue::with_limit(10);
12133 let rounds = 20;
12134
12135 for _ in 0..rounds {
12136 queue.enqueue_mutation(AutosaveMutationKind::Message);
12137 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12138 queue.finish_flush(ticket, true);
12139 }
12140
12141 let m = queue.metrics();
12142 assert_eq!(m.flush_started, rounds);
12143 assert_eq!(m.flush_succeeded, rounds);
12144 assert_eq!(m.flush_failed, 0);
12145 assert_eq!(m.pending_mutations, 0);
12146 assert_eq!(m.last_flush_batch_size, 1);
12147 }
12148
12149 #[test]
12152 fn autosave_queue_many_backpressure_events_accumulate() {
12153 let mut queue = AutosaveQueue::with_limit(1);
12154 let excess: u64 = 100;
12155
12156 for _ in 0..=excess {
12158 queue.enqueue_mutation(AutosaveMutationKind::Message);
12159 }
12160 assert_eq!(queue.pending_mutations, 1);
12161 assert_eq!(queue.backpressure_events, excess);
12162 }
12163
12164 #[test]
12167 fn autosave_durability_mode_as_str_roundtrip() {
12168 for mode in [
12169 AutosaveDurabilityMode::Strict,
12170 AutosaveDurabilityMode::Balanced,
12171 AutosaveDurabilityMode::Throughput,
12172 ] {
12173 let s = mode.as_str();
12174 let parsed = AutosaveDurabilityMode::parse(s);
12175 assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
12176 }
12177 }
12178
12179 #[test]
12182 fn autosave_durability_mode_shutdown_behavior_truth_table() {
12183 assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
12184 assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
12185
12186 assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
12187 assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
12188
12189 assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
12190 assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
12191 }
12192
12193 #[test]
12196 fn autosave_durability_mode_parse_case_insensitive() {
12197 assert_eq!(
12198 AutosaveDurabilityMode::parse("STRICT"),
12199 Some(AutosaveDurabilityMode::Strict)
12200 );
12201 assert_eq!(
12202 AutosaveDurabilityMode::parse("Balanced"),
12203 Some(AutosaveDurabilityMode::Balanced)
12204 );
12205 assert_eq!(
12206 AutosaveDurabilityMode::parse("tHrOuGhPuT"),
12207 Some(AutosaveDurabilityMode::Throughput)
12208 );
12209 }
12210
12211 #[test]
12214 fn autosave_durability_mode_parse_trims_whitespace() {
12215 assert_eq!(
12216 AutosaveDurabilityMode::parse(" strict "),
12217 Some(AutosaveDurabilityMode::Strict)
12218 );
12219 assert_eq!(
12220 AutosaveDurabilityMode::parse("\tbalanced\n"),
12221 Some(AutosaveDurabilityMode::Balanced)
12222 );
12223 }
12224
12225 #[test]
12228 fn autosave_session_save_on_empty_queue_is_noop() {
12229 let temp_dir = tempfile::tempdir().unwrap();
12230 let mut session = Session::create();
12231 session.session_dir = Some(temp_dir.path().to_path_buf());
12232
12233 let m_before = session.autosave_metrics();
12235 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
12236 let m_after = session.autosave_metrics();
12237
12238 assert_eq!(m_before.flush_started, m_after.flush_started);
12239 assert_eq!(m_after.pending_mutations, 0);
12240 }
12241
12242 #[test]
12245 fn autosave_session_mode_change_mid_session() {
12246 let mut session = Session::create();
12247 assert_eq!(
12248 session.autosave_durability_mode(),
12249 AutosaveDurabilityMode::Balanced,
12250 "default is balanced"
12251 );
12252
12253 session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
12254 assert_eq!(
12255 session.autosave_durability_mode(),
12256 AutosaveDurabilityMode::Strict
12257 );
12258
12259 session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
12260 assert_eq!(
12261 session.autosave_durability_mode(),
12262 AutosaveDurabilityMode::Throughput
12263 );
12264 }
12265
12266 #[test]
12269 fn autosave_session_all_mutation_types_enqueue() {
12270 let mut session = Session::create();
12271
12272 let first_entry_id = session.append_message(make_test_message("msg"));
12273 assert_eq!(session.autosave_metrics().pending_mutations, 1);
12274
12275 session.append_model_change("prov".to_string(), "model".to_string());
12276 assert_eq!(session.autosave_metrics().pending_mutations, 2);
12277
12278 session.append_thinking_level_change("high".to_string());
12279 assert_eq!(session.autosave_metrics().pending_mutations, 3);
12280
12281 session.append_session_info(Some("test-session".to_string()));
12282 assert_eq!(session.autosave_metrics().pending_mutations, 4);
12283
12284 session.append_custom_entry("custom".to_string(), None);
12285 assert_eq!(session.autosave_metrics().pending_mutations, 5);
12286
12287 session.add_label(&first_entry_id, Some("test-label".to_string()));
12289 assert_eq!(session.autosave_metrics().pending_mutations, 6);
12290 }
12291
12292 #[test]
12295 fn autosave_session_manual_save_resets_pending() {
12296 let temp_dir = tempfile::tempdir().unwrap();
12297 let mut session = Session::create();
12298 session.session_dir = Some(temp_dir.path().to_path_buf());
12299
12300 session.append_message(make_test_message("a"));
12301 session.append_message(make_test_message("b"));
12302 session.append_message(make_test_message("c"));
12303 assert_eq!(session.autosave_metrics().pending_mutations, 3);
12304
12305 run_async(async { session.save().await }).unwrap();
12306
12307 let m = session.autosave_metrics();
12308 assert_eq!(m.pending_mutations, 0);
12309 assert_eq!(m.flush_succeeded, 1);
12310 assert_eq!(m.last_flush_batch_size, 3);
12311 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
12312 }
12313
12314 #[test]
12317 fn autosave_session_periodic_flush_tracks_trigger() {
12318 let temp_dir = tempfile::tempdir().unwrap();
12319 let mut session = Session::create();
12320 session.session_dir = Some(temp_dir.path().to_path_buf());
12321
12322 session.append_message(make_test_message("periodic msg"));
12323 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
12324
12325 let m = session.autosave_metrics();
12326 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
12327 assert_eq!(m.flush_succeeded, 1);
12328 }
12329
12330 #[test]
12333 fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
12334 let temp_dir = tempfile::tempdir().unwrap();
12335 let mut session = Session::create();
12336 session.session_dir = Some(temp_dir.path().to_path_buf());
12337 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
12338
12339 session.append_message(make_test_message("balanced ok"));
12340 run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12341
12342 let m = session.autosave_metrics();
12343 assert_eq!(m.flush_succeeded, 1);
12344 assert_eq!(m.pending_mutations, 0);
12345 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
12346 }
12347
12348 #[test]
12351 fn autosave_queue_failure_partial_restoration() {
12352 let mut queue = AutosaveQueue::with_limit(5);
12353
12354 for _ in 0..4 {
12356 queue.enqueue_mutation(AutosaveMutationKind::Message);
12357 }
12358 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12359 assert_eq!(ticket.batch_size, 4);
12360
12361 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
12363 queue.enqueue_mutation(AutosaveMutationKind::Label);
12364 assert_eq!(queue.pending_mutations, 2);
12365
12366 let bp_before = queue.backpressure_events;
12368 let coal_before = queue.coalesced_mutations;
12369 queue.finish_flush(ticket, false);
12370 assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
12371 assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
12372 assert_eq!(
12373 queue.coalesced_mutations,
12374 coal_before + 1,
12375 "1 dropped coalesced"
12376 );
12377 }
12378
12379 #[test]
12382 fn autosave_queue_success_does_not_restore_pending() {
12383 let mut queue = AutosaveQueue::with_limit(10);
12384
12385 queue.enqueue_mutation(AutosaveMutationKind::Message);
12386 queue.enqueue_mutation(AutosaveMutationKind::Message);
12387 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12388
12389 queue.enqueue_mutation(AutosaveMutationKind::Label);
12391 assert_eq!(queue.pending_mutations, 1);
12392
12393 queue.finish_flush(ticket, true);
12395 assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
12396 assert_eq!(queue.flush_succeeded, 1);
12397 }
12398
12399 #[test]
12402 fn autosave_queue_large_batch_tracking() {
12403 let mut queue = AutosaveQueue::with_limit(500);
12404
12405 for _ in 0..200 {
12406 queue.enqueue_mutation(AutosaveMutationKind::Message);
12407 }
12408
12409 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12410 assert_eq!(ticket.batch_size, 200);
12411 queue.finish_flush(ticket, true);
12412
12413 let m = queue.metrics();
12414 assert_eq!(m.last_flush_batch_size, 200);
12415 assert_eq!(m.flush_succeeded, 1);
12416 assert_eq!(m.pending_mutations, 0);
12417 }
12418
12419 #[test]
12422 fn autosave_resolve_all_invalid_returns_balanced() {
12423 assert_eq!(
12424 resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
12425 AutosaveDurabilityMode::Balanced
12426 );
12427 }
12428
12429 #[test]
12432 fn autosave_session_metrics_accumulate_over_many_cycles() {
12433 let temp_dir = tempfile::tempdir().unwrap();
12434 let mut session = Session::create();
12435 session.session_dir = Some(temp_dir.path().to_path_buf());
12436
12437 let cycles: u64 = 10;
12438 for i in 0..cycles {
12439 session.append_message(make_test_message(&format!("cycle-{i}")));
12440 run_async(async { session.save().await }).unwrap();
12441 }
12442
12443 let m = session.autosave_metrics();
12444 assert_eq!(m.flush_started, cycles);
12445 assert_eq!(m.flush_succeeded, cycles);
12446 assert_eq!(m.flush_failed, 0);
12447 assert_eq!(m.pending_mutations, 0);
12448 assert_eq!(m.last_flush_batch_size, 1);
12449 }
12450
12451 #[test]
12454 fn autosave_queue_coalesced_is_cumulative() {
12455 let mut queue = AutosaveQueue::with_limit(10);
12456
12457 queue.enqueue_mutation(AutosaveMutationKind::Message);
12459 queue.enqueue_mutation(AutosaveMutationKind::Message);
12460 queue.enqueue_mutation(AutosaveMutationKind::Message);
12461 assert_eq!(queue.coalesced_mutations, 2);
12462
12463 let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
12464 queue.finish_flush(t1, true);
12465
12466 queue.enqueue_mutation(AutosaveMutationKind::Label);
12468 queue.enqueue_mutation(AutosaveMutationKind::Label);
12469 assert_eq!(queue.coalesced_mutations, 3);
12470 }
12471
12472 #[test]
12475 fn autosave_session_respects_queue_limit() {
12476 let temp_dir = tempfile::tempdir().unwrap();
12477 let mut session = Session::create();
12478 session.session_dir = Some(temp_dir.path().to_path_buf());
12479 session.set_autosave_queue_limit_for_test(3);
12480
12481 for i in 0..10 {
12482 session.append_message(make_test_message(&format!("lim-{i}")));
12483 }
12484
12485 let m = session.autosave_metrics();
12486 assert_eq!(m.pending_mutations, 3);
12487 assert_eq!(m.max_pending_mutations, 3);
12488 assert_eq!(m.backpressure_events, 7);
12489
12490 run_async(async { session.save().await }).unwrap();
12492 let m = session.autosave_metrics();
12493 assert_eq!(m.last_flush_batch_size, 3);
12494 assert_eq!(m.pending_mutations, 0);
12495 }
12496
12497 #[test]
12500 fn autosave_session_throughput_shutdown_skips_after_manual_save() {
12501 let temp_dir = tempfile::tempdir().unwrap();
12502 let mut session = Session::create();
12503 session.session_dir = Some(temp_dir.path().to_path_buf());
12504 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
12505
12506 session.append_message(make_test_message("saved"));
12507 run_async(async { session.save().await }).unwrap();
12508 assert_eq!(session.autosave_metrics().flush_succeeded, 1);
12509
12510 session.append_message(make_test_message("unsaved"));
12512 assert_eq!(session.autosave_metrics().pending_mutations, 1);
12513
12514 run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
12516 assert_eq!(
12517 session.autosave_metrics().pending_mutations,
12518 1,
12519 "unsaved mutation remains"
12520 );
12521 assert_eq!(
12522 session.autosave_metrics().flush_succeeded,
12523 1,
12524 "no new flush"
12525 );
12526 }
12527
12528 #[test]
12531 fn autosave_queue_begin_flush_is_atomic_clear() {
12532 let mut queue = AutosaveQueue::with_limit(10);
12533
12534 queue.enqueue_mutation(AutosaveMutationKind::Message);
12535 queue.enqueue_mutation(AutosaveMutationKind::Message);
12536 queue.enqueue_mutation(AutosaveMutationKind::Message);
12537 assert_eq!(queue.pending_mutations, 3);
12538
12539 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12540
12541 assert_eq!(queue.pending_mutations, 0);
12543 assert_eq!(ticket.batch_size, 3);
12544
12545 queue.enqueue_mutation(AutosaveMutationKind::Label);
12547 assert_eq!(queue.pending_mutations, 1);
12548
12549 queue.finish_flush(ticket, true);
12550 assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
12551 }
12552
12553 #[test]
12556 fn autosave_queue_multiple_failures_accumulate() {
12557 let mut queue = AutosaveQueue::with_limit(10);
12558
12559 for round in 1..=5_u64 {
12564 queue.enqueue_mutation(AutosaveMutationKind::Message);
12565 #[allow(clippy::cast_possible_truncation)]
12566 let expected_batch = round as usize;
12567 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
12568 assert_eq!(ticket.batch_size, expected_batch);
12569 queue.finish_flush(ticket, false);
12570 assert_eq!(queue.flush_failed, round);
12571 assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
12572 }
12573 assert_eq!(queue.flush_succeeded, 0);
12574 assert_eq!(queue.flush_started, 5);
12575 }
12576
12577 #[test]
12580 fn export_snapshot_captures_header_and_entries() {
12581 let mut session = Session::create();
12582 session.append_message(make_test_message("hello world"));
12583 session.append_message(make_test_message("second message"));
12584
12585 let snapshot = session.export_snapshot();
12586 assert_eq!(snapshot.header.id, session.header.id);
12587 assert_eq!(snapshot.header.timestamp, session.header.timestamp);
12588 assert_eq!(snapshot.header.cwd, session.header.cwd);
12589 assert_eq!(snapshot.entries.len(), session.entries.len());
12590 assert_eq!(snapshot.path, session.path);
12591 }
12592
12593 #[test]
12594 fn export_snapshot_does_not_include_internal_caches() {
12595 let mut session = Session::create();
12596 for i in 0..10 {
12597 session.append_message(make_test_message(&format!("msg {i}")));
12598 }
12599 let snapshot = session.export_snapshot();
12602 assert_eq!(snapshot.entries.len(), 10);
12603 assert_eq!(snapshot.header.id, session.header.id);
12605 }
12606
12607 #[test]
12608 fn export_snapshot_html_matches_session_html() {
12609 let mut session = Session::create();
12610 session.append_message(make_test_message("hello"));
12611 session.append_message(make_test_message("world"));
12612
12613 let session_html = session.to_html();
12614 let snapshot_html = session.export_snapshot().to_html();
12615 assert_eq!(session_html, snapshot_html);
12616 }
12617
12618 #[test]
12619 fn export_snapshot_empty_session() {
12620 let session = Session::create();
12621 let snapshot = session.export_snapshot();
12622 assert!(snapshot.entries.is_empty());
12623 let html = snapshot.to_html();
12624 assert!(html.contains("Pi Session"));
12625 assert!(html.contains("</html>"));
12626 }
12627
12628 #[test]
12629 fn render_session_html_contains_header_info() {
12630 let mut session = Session::create();
12631 session.header.id = "test-session-id-xyz".to_string();
12632 session.header.cwd = "/test/cwd/path".to_string();
12633
12634 let html = render_session_html(&session.header, &session.entries);
12635 assert!(html.contains("test-session-id-xyz"));
12636 assert!(html.contains("/test/cwd/path"));
12637 }
12638
12639 #[test]
12640 fn render_session_html_renders_all_entry_types() {
12641 let mut session = Session::create();
12642
12643 session.append_message(make_test_message("user text here"));
12645
12646 session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
12648
12649 session.entries.push(SessionEntry::ThinkingLevelChange(
12651 ThinkingLevelChangeEntry {
12652 base: EntryBase::new(None, "tlc1".to_string()),
12653 thinking_level: "high".to_string(),
12654 },
12655 ));
12656
12657 let html = render_session_html(&session.header, &session.entries);
12658 assert!(html.contains("user text here"));
12659 assert!(html.contains("anthropic"));
12660 assert!(html.contains("claude-sonnet-4-5"));
12661 assert!(html.contains("high"));
12662 }
12663
12664 #[test]
12665 fn export_snapshot_with_path() {
12666 let mut session = Session::create();
12667 session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
12668 session.append_message(make_test_message("msg"));
12669
12670 let snapshot = session.export_snapshot();
12671 assert_eq!(
12672 snapshot.path.as_deref(),
12673 Some(Path::new("/tmp/my-session.jsonl"))
12674 );
12675 }
12676
12677 #[test]
12678 fn fork_plan_snapshot_consistency() {
12679 let mut session = Session::create();
12680 let msg1 = make_test_message("first message");
12681 session.append_message(msg1);
12682 let msg1_id = session.entries[0].base_id().unwrap().clone();
12683
12684 let msg2 = make_test_message("second message");
12685 session.append_message(msg2);
12686 let msg2_id = session.entries[1].base_id().unwrap().clone();
12687
12688 let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
12690
12691 assert_eq!(plan.leaf_id, Some(msg1_id));
12693 let plan_entry_count = plan.entries.len();
12695 session.append_message(make_test_message("third message"));
12696 assert_eq!(plan.entries.len(), plan_entry_count);
12697 }
12698}