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