1use crate::agent_cx::AgentCx;
7use crate::cli::Cli;
8use crate::config::Config;
9use crate::error::{Error, Result};
10use crate::extensions::ExtensionSession;
11use crate::model::{
12 AssistantMessage, ContentBlock, Message, TextContent, ToolResultMessage, UserContent,
13 UserMessage,
14};
15use crate::session_index::{SessionIndex, enqueue_session_index_snapshot_update};
16use crate::session_store_v2::{self, SessionStoreV2};
17use crate::tui::PiConsole;
18use asupersync::channel::oneshot;
19use asupersync::sync::Mutex;
20use async_trait::async_trait;
21use fs4::fs_std::FileExt;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use std::collections::{HashMap, HashSet};
25use std::fmt::Write as _;
26use std::io::{BufRead, BufReader, IsTerminal, Write};
27use std::path::{Path, PathBuf};
28use std::sync::atomic::{AtomicUsize, Ordering};
29use std::sync::{Arc, OnceLock};
30use std::thread;
31use std::time::{Instant, SystemTime, UNIX_EPOCH};
32
33pub const SESSION_VERSION: u8 = 3;
35
36type JsonlSaveResult = std::result::Result<Vec<SessionEntry>, (Error, Vec<SessionEntry>)>;
37
38#[derive(Clone, Debug)]
40pub struct SessionHandle(pub Arc<Mutex<Session>>);
41
42#[async_trait]
43impl ExtensionSession for SessionHandle {
44 async fn get_state(&self) -> Value {
45 let cx = AgentCx::for_request();
46 let Ok(session) = self.0.lock(cx.cx()).await else {
47 return serde_json::json!({
48 "model": null,
49 "thinkingLevel": "off",
50 "durabilityMode": "balanced",
51 "isStreaming": false,
52 "isCompacting": false,
53 "steeringMode": "one-at-a-time",
54 "followUpMode": "one-at-a-time",
55 "sessionFile": null,
56 "sessionId": "",
57 "sessionName": null,
58 "autoCompactionEnabled": false,
59 "messageCount": 0,
60 "pendingMessageCount": 0,
61 });
62 };
63 let session_file = session.path.as_ref().map(|p| p.display().to_string());
64 let session_id = session.header.id.clone();
65 let session_name = session.get_name();
66 let model = session
67 .header
68 .provider
69 .as_ref()
70 .zip(session.header.model_id.as_ref())
71 .map_or(Value::Null, |(provider, model_id)| {
72 serde_json::json!({
73 "provider": provider,
74 "id": model_id,
75 })
76 });
77 let thinking_level = session
78 .header
79 .thinking_level
80 .clone()
81 .unwrap_or_else(|| "off".to_string());
82 let message_count = session
83 .entries_for_current_path()
84 .iter()
85 .filter(|entry| matches!(entry, SessionEntry::Message(_)))
86 .count();
87 let pending_message_count = session.autosave_metrics().pending_mutations;
88 let durability_mode = session.autosave_durability_mode().as_str();
89 serde_json::json!({
90 "model": model,
91 "thinkingLevel": thinking_level,
92 "durabilityMode": durability_mode,
93 "isStreaming": false,
94 "isCompacting": false,
95 "steeringMode": "one-at-a-time",
96 "followUpMode": "one-at-a-time",
97 "sessionFile": session_file,
98 "sessionId": session_id,
99 "sessionName": session_name,
100 "autoCompactionEnabled": false,
101 "messageCount": message_count,
102 "pendingMessageCount": pending_message_count,
103 })
104 }
105
106 async fn get_messages(&self) -> Vec<SessionMessage> {
107 let cx = AgentCx::for_request();
108 let Ok(session) = self.0.lock(cx.cx()).await else {
109 return Vec::new();
110 };
111 session
114 .entries_for_current_path()
115 .iter()
116 .filter_map(|entry| match entry {
117 SessionEntry::Message(msg) => match msg.message {
118 SessionMessage::User { .. }
119 | SessionMessage::Assistant { .. }
120 | SessionMessage::ToolResult { .. }
121 | SessionMessage::BashExecution { .. }
122 | SessionMessage::Custom { .. } => Some(msg.message.clone()),
123 _ => None,
124 },
125 _ => None,
126 })
127 .collect()
128 }
129
130 async fn get_entries(&self) -> Vec<Value> {
131 let cx = AgentCx::for_request();
132 let Ok(session) = self.0.lock(cx.cx()).await else {
133 return Vec::new();
134 };
135 session
136 .entries
137 .iter()
138 .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
139 .collect()
140 }
141
142 async fn get_branch(&self) -> Vec<Value> {
143 let cx = AgentCx::for_request();
144 let Ok(session) = self.0.lock(cx.cx()).await else {
145 return Vec::new();
146 };
147 session
148 .entries_for_current_path()
149 .iter()
150 .map(|e| serde_json::to_value(e).unwrap_or(Value::Null))
151 .collect()
152 }
153
154 async fn set_name(&self, name: String) -> Result<()> {
155 let cx = AgentCx::for_request();
156 let mut session = self
157 .0
158 .lock(cx.cx())
159 .await
160 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
161 session.set_name(&name);
162 Ok(())
163 }
164
165 async fn append_message(&self, message: SessionMessage) -> Result<()> {
166 let cx = AgentCx::for_request();
167 let mut session = self
168 .0
169 .lock(cx.cx())
170 .await
171 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
172 session.append_message(message);
173 Ok(())
174 }
175
176 async fn append_custom_entry(&self, custom_type: String, data: Option<Value>) -> Result<()> {
177 let cx = AgentCx::for_request();
178 let mut session = self
179 .0
180 .lock(cx.cx())
181 .await
182 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
183 if custom_type.trim().is_empty() {
184 return Err(Error::validation("customType must not be empty"));
185 }
186 session.append_custom_entry(custom_type, data);
187 Ok(())
188 }
189
190 async fn set_model(&self, provider: String, model_id: String) -> Result<()> {
191 let cx = AgentCx::for_request();
192 let mut session = self
193 .0
194 .lock(cx.cx())
195 .await
196 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
197 session.append_model_change(provider.clone(), model_id.clone());
198 session.set_model_header(Some(provider), Some(model_id), None);
199 Ok(())
200 }
201
202 async fn get_model(&self) -> (Option<String>, Option<String>) {
203 let cx = AgentCx::for_request();
204 let Ok(session) = self.0.lock(cx.cx()).await else {
205 return (None, None);
206 };
207 (
208 session.header.provider.clone(),
209 session.header.model_id.clone(),
210 )
211 }
212
213 async fn set_thinking_level(&self, level: String) -> Result<()> {
214 let cx = AgentCx::for_request();
215 let mut session = self
216 .0
217 .lock(cx.cx())
218 .await
219 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
220 session.append_thinking_level_change(level.clone());
221 session.set_model_header(None, None, Some(level));
222 Ok(())
223 }
224
225 async fn get_thinking_level(&self) -> Option<String> {
226 let cx = AgentCx::for_request();
227 let Ok(session) = self.0.lock(cx.cx()).await else {
228 return None;
229 };
230 session.header.thinking_level.clone()
231 }
232
233 async fn set_label(&self, target_id: String, label: Option<String>) -> Result<()> {
234 let cx = AgentCx::for_request();
235 let mut session = self
236 .0
237 .lock(cx.cx())
238 .await
239 .map_err(|e| Error::session(format!("Failed to lock session: {e}")))?;
240 if session.add_label(&target_id, label).is_none() {
241 return Err(Error::validation(format!(
242 "target entry '{target_id}' not found in session"
243 )));
244 }
245 Ok(())
246 }
247}
248
249pub const DEFAULT_SHARE_VIEWER_URL: &str = "https://buildwithpi.ai/session/";
251
252fn build_share_viewer_url(base_url: Option<&str>, gist_id: &str) -> String {
253 let base_url = base_url
254 .filter(|value| !value.is_empty())
255 .unwrap_or(DEFAULT_SHARE_VIEWER_URL);
256 format!("{base_url}#{gist_id}")
257}
258
259#[must_use]
266pub fn get_share_viewer_url(gist_id: &str) -> String {
267 let base_url = std::env::var("PI_SHARE_VIEWER_URL").ok();
268 build_share_viewer_url(base_url.as_deref(), gist_id)
269}
270
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub enum SessionStoreKind {
274 Jsonl,
275 #[cfg(feature = "sqlite-sessions")]
276 Sqlite,
277}
278
279impl SessionStoreKind {
280 fn from_config(config: &Config) -> Self {
281 let Some(value) = config.session_store.as_deref() else {
282 return Self::Jsonl;
283 };
284
285 if value.eq_ignore_ascii_case("jsonl") {
286 return Self::Jsonl;
287 }
288
289 if value.eq_ignore_ascii_case("sqlite") {
290 #[cfg(feature = "sqlite-sessions")]
291 {
292 return Self::Sqlite;
293 }
294
295 #[cfg(not(feature = "sqlite-sessions"))]
296 {
297 tracing::warn!(
298 "Config requests session_store=sqlite but binary lacks `sqlite-sessions`; falling back to jsonl"
299 );
300 return Self::Jsonl;
301 }
302 }
303
304 tracing::warn!("Unknown session_store `{value}`, falling back to jsonl");
305 Self::Jsonl
306 }
307
308 const fn extension(self) -> &'static str {
309 match self {
310 Self::Jsonl => "jsonl",
311 #[cfg(feature = "sqlite-sessions")]
312 Self::Sqlite => "sqlite",
313 }
314 }
315}
316
317const DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS: usize = 256;
319
320fn autosave_max_pending_mutations() -> usize {
321 std::env::var("PI_SESSION_AUTOSAVE_MAX_PENDING")
322 .ok()
323 .and_then(|raw| raw.parse::<usize>().ok())
324 .filter(|value| *value > 0)
325 .unwrap_or(DEFAULT_AUTOSAVE_MAX_PENDING_MUTATIONS)
326}
327
328const DEFAULT_COMPACTION_CHECKPOINT_INTERVAL: u64 = 50;
330
331fn compaction_checkpoint_interval() -> u64 {
332 std::env::var("PI_SESSION_COMPACTION_INTERVAL")
333 .ok()
334 .and_then(|raw| raw.parse::<u64>().ok())
335 .filter(|value| *value > 0)
336 .unwrap_or(DEFAULT_COMPACTION_CHECKPOINT_INTERVAL)
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
341pub enum AutosaveDurabilityMode {
342 Strict,
343 Balanced,
344 Throughput,
345}
346
347impl AutosaveDurabilityMode {
348 fn parse(raw: &str) -> Option<Self> {
349 match raw.trim().to_ascii_lowercase().as_str() {
350 "strict" => Some(Self::Strict),
351 "balanced" => Some(Self::Balanced),
352 "throughput" => Some(Self::Throughput),
353 _ => None,
354 }
355 }
356
357 fn from_env() -> Self {
358 std::env::var("PI_SESSION_DURABILITY_MODE")
359 .ok()
360 .as_deref()
361 .and_then(Self::parse)
362 .unwrap_or(Self::Balanced)
363 }
364
365 const fn should_flush_on_shutdown(self) -> bool {
366 matches!(self, Self::Strict | Self::Balanced)
367 }
368
369 const fn best_effort_on_shutdown(self) -> bool {
370 matches!(self, Self::Balanced)
371 }
372
373 pub const fn as_str(self) -> &'static str {
374 match self {
375 Self::Strict => "strict",
376 Self::Balanced => "balanced",
377 Self::Throughput => "throughput",
378 }
379 }
380}
381
382fn resolve_autosave_durability_mode(
383 cli_mode: Option<&str>,
384 config_mode: Option<&str>,
385 env_mode: Option<&str>,
386) -> AutosaveDurabilityMode {
387 cli_mode
388 .and_then(AutosaveDurabilityMode::parse)
389 .or_else(|| config_mode.and_then(AutosaveDurabilityMode::parse))
390 .or_else(|| env_mode.and_then(AutosaveDurabilityMode::parse))
391 .unwrap_or(AutosaveDurabilityMode::Balanced)
392}
393
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
396pub enum AutosaveFlushTrigger {
397 Manual,
398 Periodic,
399 Shutdown,
400}
401
402#[derive(Debug, Clone, Copy, PartialEq, Eq)]
403enum AutosaveMutationKind {
404 Message,
405 Metadata,
406 Label,
407}
408
409#[derive(Debug, Clone, Copy, PartialEq, Eq)]
410struct AutosaveFlushTicket {
411 batch_size: usize,
412 started_at: Instant,
413 trigger: AutosaveFlushTrigger,
414}
415
416#[derive(Debug, Clone, Copy, Default)]
418pub struct AutosaveQueueMetrics {
419 pub pending_mutations: usize,
420 pub max_pending_mutations: usize,
421 pub coalesced_mutations: u64,
422 pub backpressure_events: u64,
423 pub flush_started: u64,
424 pub flush_succeeded: u64,
425 pub flush_failed: u64,
426 pub last_flush_batch_size: usize,
427 pub last_flush_duration_ms: Option<u64>,
428 pub last_flush_trigger: Option<AutosaveFlushTrigger>,
429}
430
431#[derive(Debug, Clone)]
432struct AutosaveQueue {
433 pending_mutations: usize,
434 max_pending_mutations: usize,
435 coalesced_mutations: u64,
436 backpressure_events: u64,
437 flush_started: u64,
438 flush_succeeded: u64,
439 flush_failed: u64,
440 last_flush_batch_size: usize,
441 last_flush_duration_ms: Option<u64>,
442 last_flush_trigger: Option<AutosaveFlushTrigger>,
443}
444
445impl AutosaveQueue {
446 fn new() -> Self {
447 Self {
448 pending_mutations: 0,
449 max_pending_mutations: autosave_max_pending_mutations(),
450 coalesced_mutations: 0,
451 backpressure_events: 0,
452 flush_started: 0,
453 flush_succeeded: 0,
454 flush_failed: 0,
455 last_flush_batch_size: 0,
456 last_flush_duration_ms: None,
457 last_flush_trigger: None,
458 }
459 }
460
461 #[cfg(test)]
462 fn with_limit(max_pending_mutations: usize) -> Self {
463 let mut queue = Self::new();
464 queue.max_pending_mutations = max_pending_mutations.max(1);
465 queue
466 }
467
468 const fn metrics(&self) -> AutosaveQueueMetrics {
469 AutosaveQueueMetrics {
470 pending_mutations: self.pending_mutations,
471 max_pending_mutations: self.max_pending_mutations,
472 coalesced_mutations: self.coalesced_mutations,
473 backpressure_events: self.backpressure_events,
474 flush_started: self.flush_started,
475 flush_succeeded: self.flush_succeeded,
476 flush_failed: self.flush_failed,
477 last_flush_batch_size: self.last_flush_batch_size,
478 last_flush_duration_ms: self.last_flush_duration_ms,
479 last_flush_trigger: self.last_flush_trigger,
480 }
481 }
482
483 const fn enqueue_mutation(&mut self, _kind: AutosaveMutationKind) {
484 if self.pending_mutations == 0 {
485 self.pending_mutations = 1;
486 return;
487 }
488 self.coalesced_mutations = self.coalesced_mutations.saturating_add(1);
489 if self.pending_mutations < self.max_pending_mutations {
490 self.pending_mutations += 1;
491 } else {
492 self.backpressure_events = self.backpressure_events.saturating_add(1);
493 }
494 }
495
496 fn begin_flush(&mut self, trigger: AutosaveFlushTrigger) -> Option<AutosaveFlushTicket> {
497 if self.pending_mutations == 0 {
498 return None;
499 }
500 let batch_size = self.pending_mutations;
501 self.pending_mutations = 0;
502 self.flush_started = self.flush_started.saturating_add(1);
503 self.last_flush_batch_size = batch_size;
504 self.last_flush_trigger = Some(trigger);
505 Some(AutosaveFlushTicket {
506 batch_size,
507 started_at: Instant::now(),
508 trigger,
509 })
510 }
511
512 fn finish_flush(&mut self, ticket: AutosaveFlushTicket, success: bool) {
513 let elapsed = ticket.started_at.elapsed().as_millis();
514 let elapsed = u64::try_from(elapsed.min(u128::from(u64::MAX)))
515 .expect("elapsed milliseconds clamped to u64::MAX");
516 self.last_flush_duration_ms = Some(elapsed);
517 self.last_flush_trigger = Some(ticket.trigger);
518 if success {
519 self.flush_succeeded = self.flush_succeeded.saturating_add(1);
520 return;
521 }
522
523 self.flush_failed = self.flush_failed.saturating_add(1);
524 let available_capacity = self
528 .max_pending_mutations
529 .saturating_sub(self.pending_mutations);
530 let restored = ticket.batch_size.min(available_capacity);
531 self.pending_mutations = self.pending_mutations.saturating_add(restored);
532 let dropped = ticket.batch_size.saturating_sub(restored);
533 if dropped > 0 {
534 let dropped = dropped as u64;
535 self.backpressure_events = self.backpressure_events.saturating_add(dropped);
536 self.coalesced_mutations = self.coalesced_mutations.saturating_add(dropped);
537 }
538 }
539}
540
541#[derive(Debug)]
547pub struct Session {
548 pub header: SessionHeader,
550 pub entries: Vec<SessionEntry>,
552 pub path: Option<PathBuf>,
554 pub leaf_id: Option<String>,
556 pub session_dir: Option<PathBuf>,
558 store_kind: SessionStoreKind,
559 entry_ids: HashSet<String>,
561
562 is_linear: bool,
567 entry_index: HashMap<String, usize>,
569 cached_message_count: u64,
571 cached_name: Option<String>,
573 autosave_queue: AutosaveQueue,
575 autosave_durability: AutosaveDurabilityMode,
577
578 persisted_entry_count: Arc<AtomicUsize>,
583 header_dirty: bool,
585 appends_since_checkpoint: u64,
587 v2_sidecar_root: Option<PathBuf>,
589 v2_partial_hydration: bool,
591 v2_resume_mode: Option<V2OpenMode>,
593 v2_message_count_offset: u64,
596}
597
598impl Clone for Session {
599 fn clone(&self) -> Self {
600 Self {
601 header: self.header.clone(),
602 entries: self.entries.clone(),
603 path: self.path.clone(),
604 leaf_id: self.leaf_id.clone(),
605 session_dir: self.session_dir.clone(),
606 store_kind: self.store_kind,
607 entry_ids: self.entry_ids.clone(),
608 is_linear: self.is_linear,
609 entry_index: self.entry_index.clone(),
610 cached_message_count: self.cached_message_count,
611 cached_name: self.cached_name.clone(),
612 autosave_queue: self.autosave_queue.clone(),
613 autosave_durability: self.autosave_durability,
614 persisted_entry_count: Arc::new(AtomicUsize::new(
618 self.persisted_entry_count.load(Ordering::SeqCst),
619 )),
620 header_dirty: self.header_dirty,
621 appends_since_checkpoint: self.appends_since_checkpoint,
622 v2_sidecar_root: self.v2_sidecar_root.clone(),
623 v2_partial_hydration: self.v2_partial_hydration,
624 v2_resume_mode: self.v2_resume_mode,
625 v2_message_count_offset: self.v2_message_count_offset,
626 }
627 }
628}
629
630#[derive(Debug, Clone)]
638pub struct ForkPlan {
639 pub entries: Vec<SessionEntry>,
641 pub leaf_id: Option<String>,
643 pub selected_text: String,
645}
646
647#[derive(Debug, Clone)]
653pub struct ExportSnapshot {
654 pub header: SessionHeader,
656 pub entries: Vec<SessionEntry>,
658 pub path: Option<PathBuf>,
660}
661
662impl ExportSnapshot {
663 pub fn to_html(&self) -> String {
667 render_session_html(&self.header, &self.entries)
668 }
669}
670
671#[derive(Debug, Clone, Default)]
673pub struct SessionOpenDiagnostics {
674 pub skipped_entries: Vec<SessionOpenSkippedEntry>,
675 pub orphaned_parent_links: Vec<SessionOpenOrphanedParentLink>,
676}
677
678#[derive(Debug, Clone)]
679pub struct SessionOpenSkippedEntry {
680 pub line_number: usize,
682 pub error: String,
683}
684
685#[derive(Debug, Clone)]
686pub struct SessionOpenOrphanedParentLink {
687 pub entry_id: String,
688 pub missing_parent_id: String,
689}
690
691#[derive(Debug, Clone, Copy, PartialEq, Eq)]
693pub enum V2OpenMode {
694 Full,
695 ActivePath,
696 Tail(u64),
697}
698
699const DEFAULT_V2_LAZY_HYDRATION_THRESHOLD: u64 = 10_000;
700const DEFAULT_V2_TAIL_HYDRATION_COUNT: u64 = 256;
701
702fn parse_v2_open_mode(raw: &str) -> Option<V2OpenMode> {
703 let normalized = raw.trim().to_ascii_lowercase();
704 if normalized.is_empty() {
705 return None;
706 }
707 match normalized.as_str() {
708 "full" => Some(V2OpenMode::Full),
709 "active" | "active_path" | "active-path" => Some(V2OpenMode::ActivePath),
710 "tail" => Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT)),
711 _ => normalized
712 .strip_prefix("tail:")
713 .and_then(|value| value.parse::<u64>().ok().map(V2OpenMode::Tail)),
714 }
715}
716
717fn resolve_v2_lazy_hydration_threshold(env_raw: Option<&str>) -> u64 {
718 env_raw
719 .and_then(|raw| raw.trim().parse::<u64>().ok())
720 .unwrap_or(DEFAULT_V2_LAZY_HYDRATION_THRESHOLD)
721}
722
723fn select_v2_open_mode_for_resume(
724 entry_count: u64,
725 mode_override_raw: Option<&str>,
726 threshold_override_raw: Option<&str>,
727) -> (V2OpenMode, &'static str, u64) {
728 let lazy_threshold = resolve_v2_lazy_hydration_threshold(threshold_override_raw);
729 if let Some(raw) = mode_override_raw {
730 if let Some(mode) = parse_v2_open_mode(raw) {
731 return (mode, "env_override", lazy_threshold);
732 }
733 }
734
735 if lazy_threshold > 0 && entry_count > lazy_threshold {
736 return (
737 V2OpenMode::ActivePath,
738 "entry_count_above_lazy_threshold",
739 lazy_threshold,
740 );
741 }
742
743 (V2OpenMode::Full, "default_full", lazy_threshold)
744}
745
746impl SessionOpenDiagnostics {
747 fn warning_lines(&self) -> Vec<String> {
748 let mut lines = Vec::new();
749 for skipped in &self.skipped_entries {
750 lines.push(format!(
751 "Warning: Skipping corrupted entry at line {} in session file: {}",
752 skipped.line_number, skipped.error
753 ));
754 }
755
756 if !self.skipped_entries.is_empty() {
757 lines.push(format!(
758 "Warning: Skipped {} corrupted entries while loading session",
759 self.skipped_entries.len()
760 ));
761 }
762
763 for orphan in &self.orphaned_parent_links {
764 lines.push(format!(
765 "Warning: Entry {} references missing parent {}",
766 orphan.entry_id, orphan.missing_parent_id
767 ));
768 }
769
770 if !self.orphaned_parent_links.is_empty() {
771 lines.push(format!(
772 "Warning: Detected {} orphaned parent links while loading session",
773 self.orphaned_parent_links.len()
774 ));
775 }
776
777 lines
778 }
779}
780
781impl Session {
782 pub async fn new(cli: &Cli, config: &Config) -> Result<Self> {
784 let session_dir = cli.session_dir.as_ref().map(PathBuf::from);
785 let durability_mode = resolve_autosave_durability_mode(
786 cli.session_durability.as_deref(),
787 config.session_durability.as_deref(),
788 std::env::var("PI_SESSION_DURABILITY_MODE").ok().as_deref(),
789 );
790 if cli.no_session {
791 let mut session = Self::in_memory();
792 session.set_autosave_durability_mode(durability_mode);
793 return Ok(session);
794 }
795
796 if let Some(path) = &cli.session {
797 let mut session = Self::open(path).await?;
798 session.set_autosave_durability_mode(durability_mode);
799 return Ok(session);
800 }
801
802 if cli.resume {
803 let picker_input_override = config
804 .session_picker_input
805 .filter(|value| *value > 0)
806 .map(|value| value.to_string());
807 let mut session = Box::pin(Self::resume_with_picker(
808 session_dir.as_deref(),
809 config,
810 picker_input_override,
811 ))
812 .await?;
813 session.set_autosave_durability_mode(durability_mode);
814 return Ok(session);
815 }
816
817 if cli.r#continue {
818 let mut session = Self::continue_recent_in_dir(session_dir.as_deref(), config).await?;
819 session.set_autosave_durability_mode(durability_mode);
820 return Ok(session);
821 }
822
823 let store_kind = SessionStoreKind::from_config(config);
824 let mut session = Self::create_with_dir_and_store(session_dir, store_kind);
825 session.set_autosave_durability_mode(durability_mode);
826
827 Ok(session)
829 }
830
831 #[allow(clippy::too_many_lines)]
833 pub async fn resume_with_picker(
834 override_dir: Option<&Path>,
835 config: &Config,
836 picker_input_override: Option<String>,
837 ) -> Result<Self> {
838 let is_interactive = std::io::stdin().is_terminal() && std::io::stdout().is_terminal();
839 let mut picker_input_override = picker_input_override;
840 if picker_input_override.is_none() && is_interactive {
841 if let Some(session) = crate::session_picker::pick_session(override_dir).await {
842 return Ok(session);
843 }
844 }
845
846 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
847 let store_kind = SessionStoreKind::from_config(config);
848 let cwd = std::env::current_dir()?;
849 let encoded_cwd = encode_cwd(&cwd);
850 let project_session_dir = base_dir.join(&encoded_cwd);
851
852 if !project_session_dir.exists() {
853 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
854 }
855
856 let base_dir_clone = base_dir.clone();
857 let cwd_display = cwd.display().to_string();
858 let (tx, rx) = oneshot::channel();
859
860 thread::spawn(move || {
861 let entries: Vec<SessionPickEntry> = SessionIndex::for_sessions_root(&base_dir_clone)
862 .list_sessions(Some(&cwd_display))
863 .map(|list| {
864 list.into_iter()
865 .filter_map(SessionPickEntry::from_meta)
866 .collect()
867 })
868 .unwrap_or_default();
869 let cx = AgentCx::for_request();
870 let _ = tx.send(cx.cx(), entries);
871 });
872
873 let cx = AgentCx::for_request();
874 let entries = rx.recv(cx.cx()).await.unwrap_or_default();
875
876 let scanned = scan_sessions_on_disk(&project_session_dir, entries.clone()).await?;
877 let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
878 for entry in entries.into_iter().chain(scanned.into_iter()) {
879 by_path
880 .entry(entry.path.clone())
881 .and_modify(|existing| {
882 if entry.last_modified_ms > existing.last_modified_ms {
883 *existing = entry.clone();
884 }
885 })
886 .or_insert(entry);
887 }
888 let mut entries = by_path.into_values().collect::<Vec<_>>();
889
890 if entries.is_empty() {
891 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
892 }
893
894 entries.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
895 let max_entries = 20usize.min(entries.len());
896 let mut entries = entries.into_iter().take(max_entries).collect::<Vec<_>>();
897
898 let console = PiConsole::new();
899 console.render_info("Select a session to resume:");
900
901 let headers = ["#", "Timestamp", "Messages", "Name", "Path"];
902
903 let mut attempts = 0;
904 loop {
905 if entries.is_empty() {
906 console.render_warning("No resumable sessions available. Starting a new session.");
907 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
908 }
909
910 let mut rows: Vec<Vec<String>> = Vec::new();
911 for (idx, entry) in entries.iter().enumerate() {
912 rows.push(vec![
913 format!("{}", idx + 1),
914 entry.timestamp.clone(),
915 entry.message_count.to_string(),
916 entry.name.clone().unwrap_or_else(|| entry.id.clone()),
917 entry.path.display().to_string(),
918 ]);
919 }
920 let row_refs: Vec<Vec<&str>> = rows
921 .iter()
922 .map(|row| row.iter().map(String::as_str).collect())
923 .collect();
924 console.render_table(&headers, &row_refs);
925
926 attempts += 1;
927 if attempts > 3 {
928 console.render_warning("No selection made. Starting a new session.");
929 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
930 }
931
932 print!(
933 "Enter selection (1-{}, blank to start new): ",
934 entries.len()
935 );
936 let _ = std::io::stdout().flush();
937
938 let input = if let Some(override_input) = picker_input_override.take() {
939 override_input
940 } else {
941 let mut input = String::new();
942 std::io::stdin().read_line(&mut input)?;
943 input
944 };
945 let input = input.trim();
946 if input.is_empty() {
947 console.render_info("Starting a new session.");
948 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
949 }
950
951 match input.parse::<usize>() {
952 Ok(index) if index > 0 && index <= entries.len() => {
953 let selected = &entries[index - 1];
954 match Self::open(selected.path.to_string_lossy().as_ref()).await {
955 Ok(mut session) => {
956 session.session_dir = Some(base_dir.clone());
957 return Ok(session);
958 }
959 Err(err) => {
960 tracing::warn!(
961 path = %selected.path.display(),
962 error = %err,
963 "Failed to open selected session while resuming"
964 );
965 entries.remove(index - 1);
966
967 if is_interactive {
968 console.render_warning(
969 "Selected session could not be opened. Pick another session.",
970 );
971 continue;
972 }
973
974 console.render_warning(
975 "Selected session could not be opened. Starting a new session.",
976 );
977 return Ok(Self::create_with_dir_and_store(
978 Some(base_dir.clone()),
979 store_kind,
980 ));
981 }
982 }
983 }
984 _ => {
985 console.render_warning("Invalid selection. Try again.");
986 }
987 }
988 }
989 }
990
991 pub fn in_memory() -> Self {
993 Self {
994 header: SessionHeader::new(),
995 entries: Vec::new(),
996 path: None,
997 leaf_id: None,
998 session_dir: None,
999 store_kind: SessionStoreKind::Jsonl,
1000 entry_ids: HashSet::new(),
1001 is_linear: true,
1002 entry_index: HashMap::new(),
1003 cached_message_count: 0,
1004 cached_name: None,
1005 autosave_queue: AutosaveQueue::new(),
1006 autosave_durability: AutosaveDurabilityMode::from_env(),
1007 persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1008 header_dirty: false,
1009 appends_since_checkpoint: 0,
1010 v2_sidecar_root: None,
1011 v2_partial_hydration: false,
1012 v2_resume_mode: None,
1013 v2_message_count_offset: 0,
1014 }
1015 }
1016
1017 pub fn create() -> Self {
1019 Self::create_with_dir(None)
1020 }
1021
1022 pub fn create_with_dir(session_dir: Option<PathBuf>) -> Self {
1024 Self::create_with_dir_and_store(session_dir, SessionStoreKind::Jsonl)
1025 }
1026
1027 pub fn create_with_dir_and_store(
1028 session_dir: Option<PathBuf>,
1029 store_kind: SessionStoreKind,
1030 ) -> Self {
1031 let header = SessionHeader::new();
1032 Self {
1033 header,
1034 entries: Vec::new(),
1035 path: None,
1036 leaf_id: None,
1037 session_dir,
1038 store_kind,
1039 entry_ids: HashSet::new(),
1040 is_linear: true,
1041 entry_index: HashMap::new(),
1042 cached_message_count: 0,
1043 cached_name: None,
1044 autosave_queue: AutosaveQueue::new(),
1045 autosave_durability: AutosaveDurabilityMode::from_env(),
1046 persisted_entry_count: Arc::new(AtomicUsize::new(0)),
1047 header_dirty: false,
1048 appends_since_checkpoint: 0,
1049 v2_sidecar_root: None,
1050 v2_partial_hydration: false,
1051 v2_resume_mode: None,
1052 v2_message_count_offset: 0,
1053 }
1054 }
1055
1056 pub async fn open(path: &str) -> Result<Self> {
1058 let (session, diagnostics) = Self::open_with_diagnostics(path).await?;
1059 for warning in diagnostics.warning_lines() {
1060 eprintln!("{warning}");
1061 }
1062 Ok(session)
1063 }
1064
1065 pub async fn open_with_diagnostics(path: &str) -> Result<(Self, SessionOpenDiagnostics)> {
1067 let path = PathBuf::from(path);
1068 if !path.exists() {
1069 return Err(crate::Error::SessionNotFound {
1070 path: path.display().to_string(),
1071 });
1072 }
1073
1074 if path.extension().is_some_and(|ext| ext == "sqlite") {
1075 #[cfg(feature = "sqlite-sessions")]
1076 {
1077 let session = Self::open_sqlite(&path).await?;
1078 return Ok((session, SessionOpenDiagnostics::default()));
1079 }
1080
1081 #[cfg(not(feature = "sqlite-sessions"))]
1082 {
1083 return Err(Error::session(
1084 "SQLite session files require building with `--features sqlite-sessions`",
1085 ));
1086 }
1087 }
1088
1089 if session_store_v2::has_v2_sidecar(&path) {
1091 let is_stale = (|| -> Option<bool> {
1092 let jsonl_meta = std::fs::metadata(&path).ok()?;
1093
1094 let v2_root = session_store_v2::v2_sidecar_path(&path);
1095 let v2_index = v2_root.join("index").join("offsets.jsonl");
1096 let v2_manifest = v2_root.join("manifest.json");
1097
1098 let v2_meta = std::fs::metadata(&v2_index)
1100 .or_else(|_| std::fs::metadata(&v2_manifest))
1101 .ok()?;
1102
1103 let jsonl_mtime = jsonl_meta.modified().ok()?;
1104 let v2_mtime = v2_meta.modified().ok()?;
1105 Some(jsonl_mtime > v2_mtime)
1106 })()
1107 .unwrap_or(true); if is_stale {
1110 tracing::warn!(
1111 path = %path.display(),
1112 "V2 sidecar is stale (source JSONL newer); skipping V2 resume"
1113 );
1114 } else {
1115 match Self::open_v2_with_diagnostics(&path).await {
1116 Ok(result) => return Ok(result),
1117 Err(e) => {
1118 tracing::warn!(
1119 path = %path.display(),
1120 error = %e,
1121 "V2 sidecar resume failed, falling back to full JSONL parse"
1122 );
1123 }
1124 }
1125 }
1126 }
1127
1128 Self::open_jsonl_with_diagnostics(&path).await
1129 }
1130
1131 pub fn open_from_v2(
1133 store: &SessionStoreV2,
1134 header: SessionHeader,
1135 mode: V2OpenMode,
1136 ) -> Result<(Self, SessionOpenDiagnostics)> {
1137 let frames = match mode {
1138 V2OpenMode::Full => store.read_all_entries()?,
1139 V2OpenMode::ActivePath => match store.head() {
1140 Some(head) => store.read_active_path(&head.entry_id)?,
1141 None => Vec::new(),
1142 },
1143 V2OpenMode::Tail(count) => store.read_tail_entries(count)?,
1144 };
1145
1146 let mut diagnostics = SessionOpenDiagnostics::default();
1147 let mut entries = Vec::with_capacity(frames.len());
1148 for frame in &frames {
1149 match session_store_v2::frame_to_session_entry(frame) {
1150 Ok(entry) => entries.push(entry),
1151 Err(e) => {
1152 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
1153 line_number: usize::try_from(frame.entry_seq).unwrap_or(0),
1154 error: e.to_string(),
1155 });
1156 }
1157 }
1158 }
1159
1160 let finalized = finalize_loaded_entries(&mut entries);
1161 for orphan in &finalized.orphans {
1162 diagnostics
1163 .orphaned_parent_links
1164 .push(SessionOpenOrphanedParentLink {
1165 entry_id: orphan.0.clone(),
1166 missing_parent_id: orphan.1.clone(),
1167 });
1168 }
1169
1170 let mut v2_message_count_offset = 0;
1171 if matches!(mode, V2OpenMode::Tail(_)) {
1172 if let Ok(Some(manifest)) = store.read_manifest() {
1173 let total = manifest.counters.messages_total;
1174 let loaded = finalized.message_count;
1175 v2_message_count_offset = total.saturating_sub(loaded);
1176 }
1177 }
1178
1179 let entry_count = entries.len();
1180 Ok((
1181 Self {
1182 header,
1183 entries,
1184 path: None,
1185 leaf_id: finalized.leaf_id,
1186 session_dir: None,
1187 store_kind: SessionStoreKind::Jsonl,
1188 entry_ids: finalized.entry_ids,
1189 is_linear: finalized.is_linear,
1190 entry_index: finalized.entry_index,
1191 cached_message_count: finalized
1192 .message_count
1193 .saturating_add(v2_message_count_offset),
1194 cached_name: finalized.name,
1195 autosave_queue: AutosaveQueue::new(),
1196 autosave_durability: AutosaveDurabilityMode::from_env(),
1197 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1198 header_dirty: false,
1199 appends_since_checkpoint: 0,
1200 v2_sidecar_root: None,
1201 v2_partial_hydration: !matches!(mode, V2OpenMode::Full),
1202 v2_resume_mode: Some(mode),
1203 v2_message_count_offset,
1204 },
1205 diagnostics,
1206 ))
1207 }
1208
1209 async fn open_v2_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1211 let path_buf = path.to_path_buf();
1212 let (tx, rx) = oneshot::channel();
1213
1214 thread::spawn(move || {
1215 let res = crate::session::open_from_v2_store_blocking(path_buf);
1216 let cx = AgentCx::for_request();
1217 let _ = tx.send(cx.cx(), res);
1218 });
1219
1220 let cx = AgentCx::for_request();
1221 rx.recv(cx.cx())
1222 .await
1223 .map_err(|_| crate::Error::session("V2 open task cancelled"))?
1224 }
1225
1226 async fn open_jsonl_with_diagnostics(path: &Path) -> Result<(Self, SessionOpenDiagnostics)> {
1227 let path_buf = path.to_path_buf();
1228 let (tx, rx) = oneshot::channel();
1229
1230 thread::spawn(move || {
1231 let res = open_jsonl_blocking(path_buf);
1232 let cx = AgentCx::for_request();
1233 let _ = tx.send(cx.cx(), res);
1234 });
1235
1236 let cx = AgentCx::for_request();
1237 rx.recv(cx.cx())
1238 .await
1239 .map_err(|_| crate::Error::session("Open task cancelled"))?
1240 }
1241
1242 #[cfg(feature = "sqlite-sessions")]
1243 async fn open_sqlite(path: &Path) -> Result<Self> {
1244 let (header, mut entries) = crate::session_sqlite::load_session(path).await?;
1245 let finalized = finalize_loaded_entries(&mut entries);
1246 let entry_count = entries.len();
1247
1248 Ok(Self {
1249 header,
1250 entries,
1251 path: Some(path.to_path_buf()),
1252 leaf_id: finalized.leaf_id,
1253 session_dir: None,
1254 store_kind: SessionStoreKind::Sqlite,
1255 entry_ids: finalized.entry_ids,
1256 is_linear: finalized.is_linear,
1257 entry_index: finalized.entry_index,
1258 cached_message_count: finalized.message_count,
1259 cached_name: finalized.name,
1260 autosave_queue: AutosaveQueue::new(),
1261 autosave_durability: AutosaveDurabilityMode::from_env(),
1262 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
1263 header_dirty: false,
1264 appends_since_checkpoint: 0,
1265 v2_sidecar_root: None,
1266 v2_partial_hydration: false,
1267 v2_resume_mode: None,
1268 v2_message_count_offset: 0,
1269 })
1270 }
1271
1272 pub async fn continue_recent_in_dir(
1274 override_dir: Option<&Path>,
1275 config: &Config,
1276 ) -> Result<Self> {
1277 let store_kind = SessionStoreKind::from_config(config);
1278 let base_dir = override_dir.map_or_else(Config::sessions_dir, PathBuf::from);
1279 let cwd = std::env::current_dir()?;
1280 let cwd_display = cwd.display().to_string();
1281 let encoded_cwd = encode_cwd(&cwd);
1282 let project_session_dir = base_dir.join(&encoded_cwd);
1283
1284 if !project_session_dir.exists() {
1285 return Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind));
1286 }
1287
1288 let base_dir_clone = base_dir.clone();
1290 let cwd_display_clone = cwd_display.clone();
1291 let (tx, rx) = oneshot::channel();
1292
1293 thread::spawn(move || {
1294 let index = SessionIndex::for_sessions_root(&base_dir_clone);
1295 let mut indexed_sessions: Vec<SessionPickEntry> = index
1296 .list_sessions(Some(&cwd_display_clone))
1297 .map(|list| {
1298 list.into_iter()
1299 .filter_map(SessionPickEntry::from_meta)
1300 .collect()
1301 })
1302 .unwrap_or_default();
1303
1304 if indexed_sessions.is_empty() && index.reindex_all().is_ok() {
1305 indexed_sessions = index
1306 .list_sessions(Some(&cwd_display_clone))
1307 .map(|list| {
1308 list.into_iter()
1309 .filter_map(SessionPickEntry::from_meta)
1310 .collect()
1311 })
1312 .unwrap_or_default();
1313 }
1314 let cx = AgentCx::for_request();
1315 let _ = tx.send(cx.cx(), indexed_sessions);
1316 });
1317
1318 let cx = AgentCx::for_request();
1319 let indexed_sessions = rx.recv(cx.cx()).await.unwrap_or_default();
1320
1321 let scanned = scan_sessions_on_disk(&project_session_dir, indexed_sessions.clone()).await?;
1322
1323 let mut by_path: HashMap<PathBuf, SessionPickEntry> = HashMap::new();
1324 for entry in indexed_sessions.into_iter().chain(scanned.into_iter()) {
1325 by_path
1326 .entry(entry.path.clone())
1327 .and_modify(|existing| {
1328 if entry.last_modified_ms > existing.last_modified_ms {
1329 *existing = entry.clone();
1330 }
1331 })
1332 .or_insert(entry);
1333 }
1334
1335 let mut candidates = by_path.into_values().collect::<Vec<_>>();
1336 candidates.sort_by_key(|entry| std::cmp::Reverse(entry.last_modified_ms));
1337
1338 for entry in &candidates {
1339 match Self::open(entry.path.to_string_lossy().as_ref()).await {
1340 Ok(mut session) => {
1341 session.session_dir = Some(base_dir.clone());
1342 return Ok(session);
1343 }
1344 Err(err) => {
1345 tracing::warn!(
1346 path = %entry.path.display(),
1347 error = %err,
1348 "Skipping unreadable session candidate while continuing"
1349 );
1350 }
1351 }
1352 }
1353
1354 Ok(Self::create_with_dir_and_store(Some(base_dir), store_kind))
1355 }
1356
1357 pub async fn save(&mut self) -> Result<()> {
1359 let ticket = self
1360 .autosave_queue
1361 .begin_flush(AutosaveFlushTrigger::Manual);
1362 let result = self.save_inner().await;
1363 if let Some(ticket) = ticket {
1364 self.autosave_queue.finish_flush(ticket, result.is_ok());
1365 }
1366 result
1367 }
1368
1369 pub async fn flush_autosave(&mut self, trigger: AutosaveFlushTrigger) -> Result<()> {
1375 let Some(ticket) = self.autosave_queue.begin_flush(trigger) else {
1376 return Ok(());
1377 };
1378 let result = self.save_inner().await;
1379 self.autosave_queue.finish_flush(ticket, result.is_ok());
1380 result
1381 }
1382
1383 pub async fn flush_autosave_on_shutdown(&mut self) -> Result<()> {
1385 if !self.autosave_durability.should_flush_on_shutdown() {
1386 return Ok(());
1387 }
1388 let result = self.flush_autosave(AutosaveFlushTrigger::Shutdown).await;
1389 if result.is_err() && self.autosave_durability.best_effort_on_shutdown() {
1390 if let Err(err) = &result {
1391 tracing::warn!(error = %err, "best-effort autosave flush failed during shutdown");
1392 }
1393 return Ok(());
1394 }
1395 result
1396 }
1397
1398 pub const fn autosave_metrics(&self) -> AutosaveQueueMetrics {
1400 self.autosave_queue.metrics()
1401 }
1402
1403 pub const fn autosave_durability_mode(&self) -> AutosaveDurabilityMode {
1404 self.autosave_durability
1405 }
1406
1407 pub const fn set_autosave_durability_mode(&mut self, mode: AutosaveDurabilityMode) {
1408 self.autosave_durability = mode;
1409 }
1410
1411 #[cfg(test)]
1412 fn set_autosave_queue_limit_for_test(&mut self, max_pending_mutations: usize) {
1413 self.autosave_queue = AutosaveQueue::with_limit(max_pending_mutations);
1414 }
1415
1416 #[cfg(test)]
1417 const fn set_autosave_durability_for_test(&mut self, mode: AutosaveDurabilityMode) {
1418 self.autosave_durability = mode;
1419 }
1420
1421 fn ensure_full_v2_hydration_before_save(&mut self) -> Result<()> {
1427 if !self.v2_partial_hydration {
1428 return Ok(());
1429 }
1430
1431 let Some(v2_root) = self.v2_sidecar_root.clone() else {
1432 tracing::warn!(
1433 "session marked as partially hydrated from V2 but sidecar root is unavailable; disabling partial flag"
1434 );
1435 self.v2_partial_hydration = false;
1436 return Ok(());
1437 };
1438
1439 let pending_start = self
1440 .persisted_entry_count
1441 .load(Ordering::SeqCst)
1442 .min(self.entries.len());
1443 let previous_mode = self.v2_resume_mode;
1444
1445 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
1446 let (fully_hydrated, diagnostics) =
1447 Self::open_from_v2(&store, self.header.clone(), V2OpenMode::Full)?;
1448 if !diagnostics.skipped_entries.is_empty() || !diagnostics.orphaned_parent_links.is_empty()
1449 {
1450 tracing::error!(
1451 skipped_entries = diagnostics.skipped_entries.len(),
1452 orphaned_parent_links = diagnostics.orphaned_parent_links.len(),
1453 "full V2 rehydration before save failed integrity check; aborting save to prevent data loss"
1454 );
1455 return Err(Error::session(format!(
1456 "V2 rehydration failed with {} skipped entries and {} orphaned links",
1457 diagnostics.skipped_entries.len(),
1458 diagnostics.orphaned_parent_links.len()
1459 )));
1460 }
1461
1462 let pending_entries = if pending_start >= self.entries.len() {
1466 Vec::new()
1467 } else {
1468 self.entries.split_off(pending_start)
1469 };
1470
1471 let persisted_entry_count = fully_hydrated.entries.len();
1472 let mut merged_entries = fully_hydrated.entries;
1473 merged_entries.extend(pending_entries);
1474
1475 let finalized = finalize_loaded_entries(&mut merged_entries);
1476 self.entries = merged_entries;
1477 self.leaf_id = finalized.leaf_id;
1478 self.entry_ids = finalized.entry_ids;
1479 self.is_linear = finalized.is_linear;
1480 self.entry_index = finalized.entry_index;
1481 self.cached_message_count = finalized.message_count;
1482 self.cached_name = finalized.name;
1483 self.persisted_entry_count
1484 .store(persisted_entry_count, Ordering::SeqCst);
1485 self.v2_partial_hydration = false;
1486 self.v2_resume_mode = Some(V2OpenMode::Full);
1487 self.v2_message_count_offset = 0;
1488
1489 tracing::debug!(
1490 previous_mode = ?previous_mode,
1491 persisted_entry_count,
1492 pending_entries = self.entries.len().saturating_sub(persisted_entry_count),
1493 "fully rehydrated V2 session before save"
1494 );
1495
1496 Ok(())
1497 }
1498
1499 fn should_full_rewrite(&self) -> bool {
1501 let persisted_count = self.persisted_entry_count.load(Ordering::SeqCst);
1502
1503 if persisted_count == 0 {
1505 return true;
1506 }
1507 if self.header_dirty {
1509 return true;
1510 }
1511 if self.appends_since_checkpoint >= compaction_checkpoint_interval() {
1513 return true;
1514 }
1515 if persisted_count > self.entries.len() {
1517 return true;
1518 }
1519 false
1520 }
1521
1522 #[allow(clippy::too_many_lines)]
1524 async fn save_inner(&mut self) -> Result<()> {
1525 self.ensure_entry_ids();
1526
1527 let store_kind = match self
1528 .path
1529 .as_ref()
1530 .and_then(|path| path.extension().and_then(|ext| ext.to_str()))
1531 {
1532 Some("jsonl") => SessionStoreKind::Jsonl,
1533 Some("sqlite") => {
1534 #[cfg(feature = "sqlite-sessions")]
1535 {
1536 SessionStoreKind::Sqlite
1537 }
1538
1539 #[cfg(not(feature = "sqlite-sessions"))]
1540 {
1541 return Err(Error::session(
1542 "SQLite session files require building with `--features sqlite-sessions`",
1543 ));
1544 }
1545 }
1546 _ => self.store_kind,
1547 };
1548
1549 if self.path.is_none() {
1550 let base_dir = self
1552 .session_dir
1553 .clone()
1554 .unwrap_or_else(Config::sessions_dir);
1555 let cwd = std::env::current_dir()?;
1556 let encoded_cwd = encode_cwd(&cwd);
1557 let project_session_dir = base_dir.join(&encoded_cwd);
1558
1559 asupersync::fs::create_dir_all(&project_session_dir).await?;
1560
1561 let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H-%M-%S%.3fZ");
1562 let short_id = {
1564 let prefix: String = self
1565 .header
1566 .id
1567 .chars()
1568 .take(8)
1569 .map(|ch| {
1570 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
1571 ch
1572 } else {
1573 '_'
1574 }
1575 })
1576 .collect();
1577 if prefix.trim_matches('_').is_empty() {
1578 "session".to_string()
1579 } else {
1580 prefix
1581 }
1582 };
1583 let filename = format!("{}_{}.{}", timestamp, short_id, store_kind.extension());
1584 self.path = Some(project_session_dir.join(filename));
1585 }
1586
1587 let session_dir_clone = self.session_dir.clone();
1588 let path = self.path.clone().unwrap();
1589 let path_clone = path.clone();
1590
1591 match store_kind {
1592 SessionStoreKind::Jsonl => {
1593 let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
1594
1595 if self.should_full_rewrite() {
1596 if self.v2_partial_hydration {
1597 self.ensure_full_v2_hydration_before_save()?;
1598 }
1599 let message_count = self.cached_message_count;
1601
1602 let session_name = self.cached_name.clone();
1603 let (tx, rx) = oneshot::channel::<JsonlSaveResult>();
1605
1606 let header_snapshot = self.header.clone();
1607 let entries_to_save = std::mem::take(&mut self.entries);
1608
1609 let path_for_thread = path_clone.clone();
1610 let handle = thread::spawn(move || {
1611 let entries = entries_to_save;
1612 let res = (|| -> Result<()> {
1613 let parent = path_for_thread.parent().unwrap_or_else(|| Path::new("."));
1614 let temp_file = tempfile::NamedTempFile::new_in(parent)?;
1615 {
1616 let mut writer =
1617 std::io::BufWriter::with_capacity(1 << 20, temp_file.as_file());
1618 serde_json::to_writer(&mut writer, &header_snapshot)?;
1619 writer.write_all(b"\n")?;
1620 for entry in &entries {
1621 serde_json::to_writer(&mut writer, entry)?;
1622 writer.write_all(b"\n")?;
1623 }
1624 writer.flush()?;
1625 }
1626 temp_file
1627 .persist(&path_for_thread)
1628 .map_err(|e| crate::Error::Io(Box::new(e.error)))?;
1629
1630 enqueue_session_index_snapshot_update(
1631 &sessions_root,
1632 &path_for_thread,
1633 &header_snapshot,
1634 message_count,
1635 session_name,
1636 );
1637 Ok(())
1638 })();
1639 let cx = AgentCx::for_request();
1640 if tx
1641 .send(
1642 cx.cx(),
1643 match res {
1644 Ok(()) => Ok(entries),
1645 Err(err) => Err((err, entries)),
1646 },
1647 )
1648 .is_err()
1649 {
1650 tracing::debug!(
1651 "Session save task completed but receiver dropped (cancelled)"
1652 );
1653 }
1654 });
1655
1656 let cx = AgentCx::for_request();
1657 let result = rx
1658 .recv(cx.cx())
1659 .await
1660 .map_err(|_| crate::Error::session("Save task cancelled"))?;
1661
1662 if let Err(e) = handle.join() {
1664 std::panic::resume_unwind(e); }
1666
1667 match result {
1668 Ok(entries) => {
1669 self.entries = entries;
1670 self.persisted_entry_count
1672 .store(self.entries.len(), Ordering::SeqCst);
1673 self.header_dirty = false;
1674 self.appends_since_checkpoint = 0;
1675 Ok(())
1676 }
1677 Err((err, entries)) => {
1678 self.entries = entries;
1679 Err(err)
1680 }
1681 }?;
1682 } else {
1683 let message_count = self.cached_message_count;
1684 let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
1686 if new_start < self.entries.len() {
1687 let session_name = self.cached_name.clone();
1688 let new_entries = &self.entries[new_start..];
1690 let estimated_entry_bytes = std::fs::metadata(&path_clone)
1693 .ok()
1694 .and_then(|meta| usize::try_from(meta.len()).ok())
1695 .map_or(512, |file_bytes| {
1696 let avg = file_bytes / new_start.max(1);
1697 avg.clamp(512, 256 * 1024)
1698 });
1699 let mut serialized_buf = Vec::with_capacity(
1700 new_entries
1701 .len()
1702 .saturating_mul(estimated_entry_bytes.saturating_add(1)),
1703 );
1704 for entry in new_entries {
1705 serde_json::to_writer(&mut serialized_buf, entry)?;
1706 serialized_buf.push(b'\n');
1707 }
1708 let new_count = self.entries.len();
1709
1710 let (tx, rx) = oneshot::channel::<Result<()>>();
1711 let header_snapshot = self.header.clone();
1712
1713 let path_for_thread = path_clone.clone();
1714 let handle = thread::spawn(move || {
1715 let res = (move || -> Result<()> {
1716 let mut file = std::fs::OpenOptions::new()
1717 .append(true)
1718 .open(&path_for_thread)
1719 .map_err(|e| crate::Error::Io(Box::new(e)))?;
1720
1721 file.lock_exclusive()?;
1722 file.write_all(&serialized_buf)?;
1723 FileExt::unlock(&file)?;
1724
1725 enqueue_session_index_snapshot_update(
1726 &sessions_root,
1727 &path_for_thread,
1728 &header_snapshot,
1729 message_count,
1730 session_name,
1731 );
1732 Ok(())
1733 })();
1734 let cx = AgentCx::for_request();
1735 if tx.send(cx.cx(), res).is_err() {
1736 tracing::debug!(
1737 "Session append task completed but receiver dropped (cancelled)"
1738 );
1739 }
1740 });
1741
1742 let cx = AgentCx::for_request();
1743 let result = rx
1744 .recv(cx.cx())
1745 .await
1746 .map_err(|_| crate::Error::session("Append task cancelled"))?;
1747
1748 if let Err(e) = handle.join() {
1750 std::panic::resume_unwind(e); }
1752
1753 if result.is_ok() {
1754 self.persisted_entry_count
1755 .store(new_count, Ordering::SeqCst);
1756 self.appends_since_checkpoint += 1;
1757 }
1758 result?;
1759 }
1760 }
1762 }
1763 #[cfg(feature = "sqlite-sessions")]
1764 SessionStoreKind::Sqlite => {
1765 let message_count = self.cached_message_count;
1766 let session_name = self.cached_name.clone();
1767
1768 if self.should_full_rewrite() {
1769 crate::session_sqlite::save_session(&path_clone, &self.header, &self.entries)
1771 .await?;
1772 self.persisted_entry_count
1773 .store(self.entries.len(), Ordering::SeqCst);
1774 self.header_dirty = false;
1775 self.appends_since_checkpoint = 0;
1776 } else {
1777 let new_start = self.persisted_entry_count.load(Ordering::SeqCst);
1779 if new_start < self.entries.len() {
1780 crate::session_sqlite::append_entries(
1781 &path_clone,
1782 &self.entries[new_start..],
1783 new_start,
1784 message_count,
1785 session_name.as_deref(),
1786 )
1787 .await?;
1788 self.persisted_entry_count
1789 .store(self.entries.len(), Ordering::SeqCst);
1790 self.appends_since_checkpoint += 1;
1791 }
1792 }
1794
1795 let sessions_root = session_dir_clone.unwrap_or_else(Config::sessions_dir);
1796 enqueue_session_index_snapshot_update(
1797 &sessions_root,
1798 &path_clone,
1799 &self.header,
1800 message_count,
1801 session_name,
1802 );
1803 }
1804 }
1805 Ok(())
1806 }
1807
1808 const fn enqueue_autosave_mutation(&mut self, kind: AutosaveMutationKind) {
1809 self.autosave_queue.enqueue_mutation(kind);
1810 }
1811
1812 pub fn append_message(&mut self, message: SessionMessage) -> String {
1814 let id = self.next_entry_id();
1815 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1816 let entry = SessionEntry::Message(MessageEntry { base, message });
1817 self.leaf_id = Some(id.clone());
1818 self.entries.push(entry);
1819 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1820 self.entry_ids.insert(id.clone());
1821 self.cached_message_count += 1;
1822 self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
1823 id
1824 }
1825
1826 pub fn append_model_message(&mut self, message: Message) -> String {
1828 self.append_message(SessionMessage::from(message))
1829 }
1830
1831 pub fn append_model_change(&mut self, provider: String, model_id: String) -> String {
1832 let id = self.next_entry_id();
1833 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1834 let entry = SessionEntry::ModelChange(ModelChangeEntry {
1835 base,
1836 provider,
1837 model_id,
1838 });
1839 self.leaf_id = Some(id.clone());
1840 self.entries.push(entry);
1841 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1842 self.entry_ids.insert(id.clone());
1843 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1844 id
1845 }
1846
1847 pub fn append_thinking_level_change(&mut self, thinking_level: String) -> String {
1848 let id = self.next_entry_id();
1849 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1850 let entry = SessionEntry::ThinkingLevelChange(ThinkingLevelChangeEntry {
1851 base,
1852 thinking_level,
1853 });
1854 self.leaf_id = Some(id.clone());
1855 self.entries.push(entry);
1856 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1857 self.entry_ids.insert(id.clone());
1858 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1859 id
1860 }
1861
1862 pub fn append_session_info(&mut self, name: Option<String>) -> String {
1863 let id = self.next_entry_id();
1864 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1865 if name.is_some() {
1866 self.cached_name.clone_from(&name);
1867 }
1868 let entry = SessionEntry::SessionInfo(SessionInfoEntry { base, name });
1869 self.leaf_id = Some(id.clone());
1870 self.entries.push(entry);
1871 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1872 self.entry_ids.insert(id.clone());
1873 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1874 id
1875 }
1876
1877 pub fn append_custom_entry(
1879 &mut self,
1880 custom_type: String,
1881 data: Option<serde_json::Value>,
1882 ) -> String {
1883 let id = self.next_entry_id();
1884 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1885 let entry = SessionEntry::Custom(CustomEntry {
1886 base,
1887 custom_type,
1888 data,
1889 });
1890 self.leaf_id = Some(id.clone());
1891 self.entries.push(entry);
1892 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1893 self.entry_ids.insert(id.clone());
1894 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1895 id
1896 }
1897
1898 pub fn append_bash_execution(
1899 &mut self,
1900 command: String,
1901 output: String,
1902 exit_code: i32,
1903 cancelled: bool,
1904 truncated: bool,
1905 full_output_path: Option<String>,
1906 ) -> String {
1907 let id = self.next_entry_id();
1908 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1909 let entry = SessionEntry::Message(MessageEntry {
1910 base,
1911 message: SessionMessage::BashExecution {
1912 command,
1913 output,
1914 exit_code,
1915 cancelled: Some(cancelled),
1916 truncated: Some(truncated),
1917 full_output_path,
1918 timestamp: Some(chrono::Utc::now().timestamp_millis()),
1919 extra: HashMap::new(),
1920 },
1921 });
1922 self.leaf_id = Some(id.clone());
1923 self.entries.push(entry);
1924 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1925 self.entry_ids.insert(id.clone());
1926 self.cached_message_count += 1;
1927 self.enqueue_autosave_mutation(AutosaveMutationKind::Message);
1928 id
1929 }
1930
1931 pub fn get_name(&self) -> Option<String> {
1933 self.cached_name.clone()
1934 }
1935
1936 pub fn set_name(&mut self, name: &str) -> String {
1938 self.append_session_info(Some(name.to_string()))
1939 }
1940
1941 pub fn append_compaction(
1942 &mut self,
1943 summary: String,
1944 first_kept_entry_id: String,
1945 tokens_before: u64,
1946 details: Option<Value>,
1947 from_hook: Option<bool>,
1948 ) -> String {
1949 let id = self.next_entry_id();
1950 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1951 let entry = SessionEntry::Compaction(CompactionEntry {
1952 base,
1953 summary,
1954 first_kept_entry_id,
1955 tokens_before,
1956 details,
1957 from_hook,
1958 });
1959 self.leaf_id = Some(id.clone());
1960 self.entries.push(entry);
1961 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1962 self.entry_ids.insert(id.clone());
1963 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1964 id
1965 }
1966
1967 pub fn append_branch_summary(
1968 &mut self,
1969 from_id: String,
1970 summary: String,
1971 details: Option<Value>,
1972 from_hook: Option<bool>,
1973 ) -> String {
1974 let id = self.next_entry_id();
1975 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
1976 let entry = SessionEntry::BranchSummary(BranchSummaryEntry {
1977 base,
1978 from_id,
1979 summary,
1980 details,
1981 from_hook,
1982 });
1983 self.leaf_id = Some(id.clone());
1984 self.entries.push(entry);
1985 self.entry_index.insert(id.clone(), self.entries.len() - 1);
1986 self.entry_ids.insert(id.clone());
1987 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
1988 id
1989 }
1990
1991 pub fn ensure_entry_ids(&mut self) {
1992 self.rebuild_all_caches();
1995 }
1996
1997 fn rebuild_all_caches(&mut self) {
2002 let finalized = finalize_loaded_entries(&mut self.entries);
2003 self.entry_ids = finalized.entry_ids;
2004 self.entry_index = finalized.entry_index;
2005 self.cached_message_count = finalized
2006 .message_count
2007 .saturating_add(self.v2_message_count_offset);
2008 self.cached_name = finalized.name;
2009 self.is_linear = finalized.is_linear && self.leaf_id == finalized.leaf_id;
2014 }
2015
2016 pub fn to_messages(&self) -> Vec<Message> {
2018 let mut messages = Vec::new();
2019 for entry in &self.entries {
2020 if let SessionEntry::Message(msg_entry) = entry {
2021 if let Some(message) = session_message_to_model(&msg_entry.message) {
2022 messages.push(message);
2023 }
2024 }
2025 }
2026 messages
2027 }
2028
2029 pub fn to_html(&self) -> String {
2035 render_session_html(&self.header, &self.entries)
2036 }
2037
2038 pub fn set_model_header(
2040 &mut self,
2041 provider: Option<String>,
2042 model_id: Option<String>,
2043 thinking_level: Option<String>,
2044 ) {
2045 let changed = provider.is_some() || model_id.is_some() || thinking_level.is_some();
2046 if provider.is_some() {
2047 self.header.provider = provider;
2048 }
2049 if model_id.is_some() {
2050 self.header.model_id = model_id;
2051 }
2052 if thinking_level.is_some() {
2053 self.header.thinking_level = thinking_level;
2054 }
2055 if changed {
2056 self.header_dirty = true;
2057 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2058 }
2059 }
2060
2061 pub fn set_branched_from(&mut self, path: Option<String>) {
2062 self.header.parent_session = path;
2063 self.header_dirty = true;
2064 self.enqueue_autosave_mutation(AutosaveMutationKind::Metadata);
2065 }
2066
2067 pub fn export_snapshot(&self) -> ExportSnapshot {
2073 ExportSnapshot {
2074 header: self.header.clone(),
2075 entries: self.entries.clone(),
2076 path: self.path.clone(),
2077 }
2078 }
2079
2080 pub fn plan_fork_from_user_message(&self, entry_id: &str) -> Result<ForkPlan> {
2085 let entry = self
2086 .get_entry(entry_id)
2087 .ok_or_else(|| Error::session(format!("Fork target not found: {entry_id}")))?;
2088
2089 let SessionEntry::Message(message_entry) = entry else {
2090 return Err(Error::session(format!(
2091 "Fork target is not a message entry: {entry_id}"
2092 )));
2093 };
2094
2095 let SessionMessage::User { content, .. } = &message_entry.message else {
2096 return Err(Error::session(format!(
2097 "Fork target is not a user message: {entry_id}"
2098 )));
2099 };
2100
2101 let selected_text = user_content_to_text(content);
2102 let leaf_id = message_entry.base.parent_id.clone();
2103
2104 let entries = if let Some(ref leaf_id) = leaf_id {
2105 if self.is_linear {
2106 let idx = self.entry_index.get(leaf_id).copied().ok_or_else(|| {
2107 Error::session(format!("Failed to build fork: missing entry {leaf_id}"))
2108 })?;
2109 self.entries[..=idx].to_vec()
2110 } else {
2111 let path_ids = self.get_path_to_entry(leaf_id);
2112 let mut entries = Vec::new();
2113 for path_id in path_ids {
2114 let entry = self.get_entry(&path_id).ok_or_else(|| {
2115 Error::session(format!("Failed to build fork: missing entry {path_id}"))
2116 })?;
2117 entries.push(entry.clone());
2118 }
2119 entries
2120 }
2121 } else {
2122 Vec::new()
2123 };
2124
2125 Ok(ForkPlan {
2126 entries,
2127 leaf_id,
2128 selected_text,
2129 })
2130 }
2131
2132 fn next_entry_id(&self) -> String {
2133 let use_entry_id_cache = session_entry_id_cache_enabled();
2134
2135 if use_entry_id_cache {
2136 generate_entry_id(&self.entry_ids)
2139 } else {
2140 let existing = entry_id_set(&self.entries);
2143 generate_entry_id(&existing)
2144 }
2145 }
2146
2147 fn build_children_map(&self) -> HashMap<Option<String>, Vec<String>> {
2153 let mut children: HashMap<Option<String>, Vec<String>> =
2154 HashMap::with_capacity(self.entries.len());
2155 for entry in &self.entries {
2156 if let Some(id) = entry.base_id() {
2157 children
2158 .entry(entry.base().parent_id.clone())
2159 .or_default()
2160 .push(id.clone());
2161 }
2162 }
2163 children
2164 }
2165
2166 pub fn get_path_to_entry(&self, entry_id: &str) -> Vec<String> {
2169 if self.is_linear {
2171 if let Some(&idx) = self.entry_index.get(entry_id) {
2172 let mut path = Vec::with_capacity(idx + 1);
2173 for entry in &self.entries[..=idx] {
2174 if let Some(id) = entry.base_id() {
2175 path.push(id.clone());
2176 }
2177 }
2178 return path;
2179 }
2180 }
2181
2182 let mut path = Vec::new();
2183 let mut visited = std::collections::HashSet::with_capacity(self.entries.len().min(128));
2184 let mut current = Some(entry_id.to_string());
2185
2186 while let Some(id) = current {
2187 if !visited.insert(id.clone()) {
2188 tracing::warn!(
2189 "Cycle detected in session tree while building ancestor path at entry: {id}"
2190 );
2191 break;
2192 }
2193 path.push(id.clone());
2194 current = self
2195 .get_entry(&id)
2196 .and_then(|entry| entry.base().parent_id.clone());
2197 }
2198
2199 path.reverse();
2200 path
2201 }
2202
2203 pub fn get_children(&self, entry_id: Option<&str>) -> Vec<String> {
2205 self.entries
2206 .iter()
2207 .filter_map(|entry| {
2208 let id = entry.base_id()?;
2209 if entry.base().parent_id.as_deref() == entry_id {
2210 Some(id.clone())
2211 } else {
2212 None
2213 }
2214 })
2215 .collect()
2216 }
2217
2218 pub fn list_leaves(&self) -> Vec<String> {
2220 let mut has_children: HashSet<&str> = HashSet::with_capacity(self.entries.len());
2221 for entry in &self.entries {
2222 if let Some(parent_id) = entry.base().parent_id.as_deref() {
2223 has_children.insert(parent_id);
2224 }
2225 }
2226
2227 self.entries
2228 .iter()
2229 .filter_map(|e| {
2230 let id = e.base_id()?;
2231 if has_children.contains(id.as_str()) {
2232 None
2233 } else {
2234 Some(id.clone())
2235 }
2236 })
2237 .collect()
2238 }
2239
2240 pub fn navigate_to(&mut self, entry_id: &str) -> bool {
2243 let exists = self.entry_index.contains_key(entry_id);
2245 if exists {
2246 let is_tip = self
2248 .entries
2249 .last()
2250 .and_then(|e| e.base_id())
2251 .is_some_and(|id| id == entry_id);
2252 if !is_tip {
2253 self.is_linear = false;
2254 }
2255 self.leaf_id = Some(entry_id.to_string());
2256 true
2257 } else {
2258 false
2259 }
2260 }
2261
2262 pub fn reset_leaf(&mut self) {
2268 self.leaf_id = None;
2269 self.is_linear = false;
2270 }
2271
2272 pub fn create_branch_from(&mut self, entry_id: &str) -> bool {
2276 self.navigate_to(entry_id)
2277 }
2278
2279 pub fn get_entry(&self, entry_id: &str) -> Option<&SessionEntry> {
2281 self.entry_index
2282 .get(entry_id)
2283 .and_then(|&idx| self.entries.get(idx))
2284 }
2285
2286 pub fn get_entry_mut(&mut self, entry_id: &str) -> Option<&mut SessionEntry> {
2288 self.entry_index
2289 .get(entry_id)
2290 .copied()
2291 .and_then(|idx| self.entries.get_mut(idx))
2292 }
2293
2294 pub fn entries_for_current_path(&self) -> Vec<&SessionEntry> {
2300 let Some(leaf_id) = &self.leaf_id else {
2301 return Vec::new();
2302 };
2303
2304 if self.is_linear {
2306 return self.entries.iter().collect();
2307 }
2308
2309 let mut path_indices = Vec::with_capacity(16);
2310 let mut visited = HashSet::with_capacity(self.entries.len().min(128));
2311 let mut current = Some(leaf_id.clone());
2312
2313 while let Some(id) = current.as_ref() {
2314 if !visited.insert(id.clone()) {
2315 tracing::warn!(
2316 "Cycle detected in session tree while collecting current path entries at: {id}"
2317 );
2318 break;
2319 }
2320 let Some(&idx) = self.entry_index.get(id.as_str()) else {
2321 break;
2322 };
2323 let Some(entry) = self.entries.get(idx) else {
2324 break;
2325 };
2326 path_indices.push(idx);
2327 current.clone_from(&entry.base().parent_id);
2328 }
2329
2330 path_indices.reverse();
2331 path_indices
2332 .into_iter()
2333 .filter_map(|idx| self.entries.get(idx))
2334 .collect()
2335 }
2336
2337 pub fn to_messages_for_current_path(&self) -> Vec<Message> {
2340 if self.leaf_id.is_none() {
2341 return Vec::new();
2342 }
2343
2344 if self.is_linear {
2345 return Self::to_messages_from_path(self.entries.len(), |idx| &self.entries[idx]);
2346 }
2347
2348 let path_entries = self.entries_for_current_path();
2349 Self::to_messages_from_path(path_entries.len(), |idx| path_entries[idx])
2350 }
2351
2352 fn append_model_message_for_entry(messages: &mut Vec<Message>, entry: &SessionEntry) {
2353 match entry {
2354 SessionEntry::Message(msg_entry) => {
2355 if let Some(message) = session_message_to_model(&msg_entry.message) {
2356 messages.push(message);
2357 }
2358 }
2359 SessionEntry::BranchSummary(summary) => {
2360 let summary_message = SessionMessage::BranchSummary {
2361 summary: summary.summary.clone(),
2362 from_id: summary.from_id.clone(),
2363 };
2364 if let Some(message) = session_message_to_model(&summary_message) {
2365 messages.push(message);
2366 }
2367 }
2368 _ => {}
2369 }
2370 }
2371
2372 fn to_messages_from_path<'a, F>(path_len: usize, entry_at: F) -> Vec<Message>
2373 where
2374 F: Fn(usize) -> &'a SessionEntry,
2375 {
2376 let mut last_compaction = None;
2377 for idx in (0..path_len).rev() {
2378 if let SessionEntry::Compaction(compaction) = entry_at(idx) {
2379 last_compaction = Some((idx, compaction));
2380 break;
2381 }
2382 }
2383
2384 if let Some((compaction_idx, compaction)) = last_compaction {
2385 let mut messages = Vec::with_capacity(path_len);
2386 let summary_message = SessionMessage::CompactionSummary {
2387 summary: compaction.summary.clone(),
2388 tokens_before: compaction.tokens_before,
2389 };
2390 if let Some(message) = session_message_to_model(&summary_message) {
2391 messages.push(message);
2392 }
2393
2394 let has_kept_entry = (0..path_len).any(|idx| {
2395 entry_at(idx)
2396 .base_id()
2397 .is_some_and(|id| id == &compaction.first_kept_entry_id)
2398 });
2399
2400 let mut keep = false;
2401 let mut past_compaction = false;
2402 for idx in 0..path_len {
2403 let entry = entry_at(idx);
2404 if idx == compaction_idx {
2405 past_compaction = true;
2406 }
2407 if !keep {
2408 if has_kept_entry {
2409 if entry
2410 .base_id()
2411 .is_some_and(|id| id == &compaction.first_kept_entry_id)
2412 {
2413 keep = true;
2414 } else {
2415 continue;
2416 }
2417 } else if past_compaction {
2418 tracing::warn!(
2419 first_kept_entry_id = %compaction.first_kept_entry_id,
2420 "Compaction references missing entry; including all post-compaction entries"
2421 );
2422 keep = true;
2423 } else {
2424 continue;
2425 }
2426 }
2427 Self::append_model_message_for_entry(&mut messages, entry);
2428 }
2429
2430 return messages;
2431 }
2432
2433 let mut messages = Vec::with_capacity(path_len);
2434 for idx in 0..path_len {
2435 Self::append_model_message_for_entry(&mut messages, entry_at(idx));
2436 }
2437 messages
2438 }
2439
2440 pub fn sibling_branches(&self) -> Option<(Option<String>, Vec<SiblingBranch>)> {
2448 let children_map = self.build_children_map();
2449 let leaf_id = self.leaf_id.as_ref()?;
2450 let path = self.get_path_to_entry(leaf_id);
2451 if path.is_empty() {
2452 return None;
2453 }
2454
2455 for (idx, entry_id) in path.iter().enumerate().rev() {
2460 let parent_of_entry = self
2461 .get_entry(entry_id)
2462 .and_then(|e| e.base().parent_id.clone());
2463
2464 let Some(siblings_at_parent) = children_map.get(&parent_of_entry) else {
2465 continue;
2466 };
2467
2468 if siblings_at_parent.len() > 1 {
2469 let mut branches = Vec::new();
2471 let current_branch_ids: HashSet<&str> =
2472 path[idx..].iter().map(String::as_str).collect();
2473 for sibling_root in siblings_at_parent {
2474 let leaf = Self::deepest_leaf_from(&children_map, sibling_root);
2475 let (preview, msg_count) = self.path_preview_and_message_count(&leaf);
2476 let is_current = current_branch_ids.contains(sibling_root.as_str());
2477 branches.push(SiblingBranch {
2478 root_id: sibling_root.clone(),
2479 leaf_id: leaf,
2480 preview,
2481 message_count: msg_count,
2482 is_current,
2483 });
2484 }
2485 return Some((parent_of_entry, branches));
2486 }
2487 }
2488
2489 None
2490 }
2491
2492 fn deepest_leaf_from(
2494 children_map: &HashMap<Option<String>, Vec<String>>,
2495 start_id: &str,
2496 ) -> String {
2497 let mut current = start_id.to_string();
2498 let mut visited = HashSet::new();
2499 loop {
2500 if !visited.insert(current.clone()) {
2501 tracing::warn!("Cycle detected in session tree at entry: {current}");
2502 return current;
2503 }
2504 let children = children_map.get(&Some(current.clone()));
2505 match children.and_then(|c| c.first()) {
2506 Some(child) => current.clone_from(child),
2507 None => return current,
2508 }
2509 }
2510 }
2511
2512 fn path_preview_and_message_count(&self, leaf_id: &str) -> (String, usize) {
2515 let mut visited = HashSet::with_capacity(self.entries.len().min(128));
2516 let mut current = Some(leaf_id.to_string());
2517 let mut preview = None;
2518 let mut count = 0usize;
2519
2520 while let Some(id) = current.as_ref() {
2521 if !visited.insert(id.clone()) {
2522 tracing::warn!("Cycle detected in session tree while collecting path stats: {id}");
2523 break;
2524 }
2525 let Some(entry) = self.get_entry(id.as_str()) else {
2526 break;
2527 };
2528 if matches!(entry, SessionEntry::Message(_)) {
2529 count = count.saturating_add(1);
2530 }
2531 if let SessionEntry::Message(msg) = entry {
2532 if let SessionMessage::User { content, .. } = &msg.message {
2533 let text = user_content_to_text(content);
2534 let trimmed = text.trim();
2535 if !trimmed.is_empty() {
2536 preview = Some(if trimmed.chars().count() > 60 {
2537 let truncated: String = trimmed.chars().take(57).collect();
2538 format!("{truncated}...")
2539 } else {
2540 trimmed.to_string()
2541 });
2542 }
2543 }
2544 }
2545 current.clone_from(&entry.base().parent_id);
2546 }
2547
2548 (preview.unwrap_or_else(|| String::from("(empty)")), count)
2549 }
2550
2551 pub fn branch_summary(&self) -> BranchInfo {
2553 let leaves = self.list_leaves();
2554 let children_map = self.build_children_map();
2555
2556 let branch_points: Vec<String> = self
2558 .entries
2559 .iter()
2560 .filter_map(|e| {
2561 let id = e.base_id()?;
2562 let children = children_map.get(&Some(id.clone()))?;
2563 if children.len() > 1 {
2564 Some(id.clone())
2565 } else {
2566 None
2567 }
2568 })
2569 .collect();
2570
2571 BranchInfo {
2572 total_entries: self.entries.len(),
2573 leaf_count: leaves.len(),
2574 branch_point_count: branch_points.len(),
2575 current_leaf: self.leaf_id.clone(),
2576 leaves,
2577 branch_points,
2578 }
2579 }
2580
2581 pub fn add_label(&mut self, target_id: &str, label: Option<String>) -> Option<String> {
2583 self.get_entry(target_id)?;
2585
2586 let id = self.next_entry_id();
2587 let base = EntryBase::new(self.leaf_id.clone(), id.clone());
2588 let entry = SessionEntry::Label(LabelEntry {
2589 base,
2590 target_id: target_id.to_string(),
2591 label,
2592 });
2593 self.leaf_id = Some(id.clone());
2594 self.entries.push(entry);
2595 self.entry_index.insert(id.clone(), self.entries.len() - 1);
2596 self.entry_ids.insert(id.clone());
2597 self.enqueue_autosave_mutation(AutosaveMutationKind::Label);
2598 Some(id)
2599 }
2600}
2601
2602#[derive(Debug, Clone)]
2604pub struct BranchInfo {
2605 pub total_entries: usize,
2606 pub leaf_count: usize,
2607 pub branch_point_count: usize,
2608 pub current_leaf: Option<String>,
2609 pub leaves: Vec<String>,
2610 pub branch_points: Vec<String>,
2611}
2612
2613#[derive(Debug, Clone)]
2615pub struct SiblingBranch {
2616 pub root_id: String,
2618 pub leaf_id: String,
2620 pub preview: String,
2622 pub message_count: usize,
2624 pub is_current: bool,
2626}
2627
2628#[derive(Debug, Clone)]
2629struct SessionPickEntry {
2630 path: PathBuf,
2631 id: String,
2632 timestamp: String,
2633 message_count: u64,
2634 name: Option<String>,
2635 last_modified_ms: i64,
2636 size_bytes: u64,
2637}
2638
2639impl SessionPickEntry {
2640 fn from_meta(meta: crate::session_index::SessionMeta) -> Option<Self> {
2641 let path = PathBuf::from(meta.path);
2642 if !path.exists() {
2643 return None;
2644 }
2645 Some(Self {
2646 path,
2647 id: meta.id,
2648 timestamp: meta.timestamp,
2649 message_count: meta.message_count,
2650 name: meta.name,
2651 last_modified_ms: meta.last_modified_ms,
2652 size_bytes: meta.size_bytes,
2653 })
2654 }
2655}
2656
2657const fn can_reuse_known_entry(
2658 known_entry: &SessionPickEntry,
2659 disk_ms: i64,
2660 disk_size: u64,
2661) -> bool {
2662 known_entry.last_modified_ms == disk_ms && known_entry.size_bytes == disk_size
2663}
2664
2665async fn scan_sessions_on_disk(
2666 project_session_dir: &Path,
2667 known: Vec<SessionPickEntry>,
2668) -> Result<Vec<SessionPickEntry>> {
2669 let path_buf = project_session_dir.to_path_buf();
2670 let (tx, rx) = oneshot::channel();
2671
2672 thread::Builder::new()
2673 .name("session-scan".to_string())
2674 .spawn(move || {
2675 let res = (|| -> Result<Vec<SessionPickEntry>> {
2676 let mut entries = Vec::new();
2677 let dir_entries = std::fs::read_dir(&path_buf)
2678 .map_err(|e| Error::session(format!("Failed to read sessions: {e}")))?;
2679
2680 let known_map: HashMap<PathBuf, SessionPickEntry> =
2681 known.into_iter().map(|e| (e.path.clone(), e)).collect();
2682
2683 for entry in dir_entries {
2684 let entry =
2685 entry.map_err(|e| Error::session(format!("Read dir entry: {e}")))?;
2686 let path = entry.path();
2687 if is_session_file_path(&path) {
2688 if let Ok(metadata) = std::fs::metadata(&path) {
2691 let disk_size = metadata.len();
2692 if let Ok(modified) = metadata.modified() {
2693 #[allow(clippy::cast_possible_truncation)]
2694 let disk_ms = modified
2695 .duration_since(UNIX_EPOCH)
2696 .unwrap_or_default()
2697 .as_millis()
2698 as i64;
2699
2700 if let Some(known_entry) = known_map.get(&path) {
2701 if can_reuse_known_entry(known_entry, disk_ms, disk_size) {
2702 entries.push(known_entry.clone());
2703 continue;
2704 }
2705 }
2706 }
2707 }
2708
2709 if let Ok(meta) = load_session_meta(&path) {
2710 entries.push(meta);
2711 }
2712 }
2713 }
2714 Ok(entries)
2715 })();
2716 let cx = AgentCx::for_request();
2717 let _ = tx.send(cx.cx(), res);
2718 })
2719 .map_err(|e| Error::session(format!("Failed to spawn session scan thread: {e}")))?;
2720
2721 let cx = AgentCx::for_request();
2722 rx.recv(cx.cx())
2723 .await
2724 .map_err(|_| Error::session("Scan task cancelled"))?
2725}
2726
2727fn is_session_file_path(path: &Path) -> bool {
2728 match path.extension().and_then(|ext| ext.to_str()) {
2729 Some("jsonl") => true,
2730 #[cfg(feature = "sqlite-sessions")]
2731 Some("sqlite") => true,
2732 _ => false,
2733 }
2734}
2735
2736fn load_session_meta(path: &Path) -> Result<SessionPickEntry> {
2737 match path.extension().and_then(|ext| ext.to_str()) {
2738 Some("jsonl") => load_session_meta_jsonl(path),
2739 #[cfg(feature = "sqlite-sessions")]
2740 Some("sqlite") => load_session_meta_sqlite(path),
2741 _ => Err(Error::session(format!(
2742 "Unsupported session file extension: {}",
2743 path.display()
2744 ))),
2745 }
2746}
2747
2748#[derive(Deserialize)]
2749struct PartialEntry {
2750 #[serde(default)]
2751 r#type: String,
2752 #[serde(default)]
2753 name: Option<String>,
2754}
2755
2756fn load_session_meta_jsonl(path: &Path) -> Result<SessionPickEntry> {
2757 let file = std::fs::File::open(path)
2758 .map_err(|e| Error::session(format!("Failed to read session: {e}")))?;
2759 let reader = BufReader::new(file);
2760 let mut lines = reader.lines();
2761
2762 let header_line = lines
2763 .next()
2764 .ok_or_else(|| Error::session("Empty session file"))?
2765 .map_err(|e| Error::session(format!("Failed to read header: {e}")))?;
2766
2767 let header: SessionHeader =
2768 serde_json::from_str(&header_line).map_err(|e| Error::session(format!("{e}")))?;
2769
2770 let mut message_count = 0u64;
2771 let mut name = None;
2772
2773 for line_content in lines.map_while(std::result::Result::ok) {
2774 if let Ok(entry) = serde_json::from_str::<PartialEntry>(&line_content) {
2775 match entry.r#type.as_str() {
2776 "message" => message_count += 1,
2777 "session_info" => {
2778 if entry.name.is_some() {
2779 name = entry.name;
2780 }
2781 }
2782 _ => {}
2783 }
2784 }
2785 }
2786
2787 let metadata = std::fs::metadata(path)
2788 .map_err(|e| Error::session(format!("Failed to stat session: {e}")))?;
2789 let size_bytes = metadata.len();
2790 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
2791 #[allow(clippy::cast_possible_truncation)]
2792 let last_modified_ms = modified
2793 .duration_since(UNIX_EPOCH)
2794 .unwrap_or_default()
2795 .as_millis() as i64; Ok(SessionPickEntry {
2798 path: path.to_path_buf(),
2799 id: header.id,
2800 timestamp: header.timestamp,
2801 message_count,
2802 name,
2803 last_modified_ms,
2804 size_bytes,
2805 })
2806}
2807
2808#[cfg(feature = "sqlite-sessions")]
2809fn load_session_meta_sqlite(path: &Path) -> Result<SessionPickEntry> {
2810 let meta = futures::executor::block_on(async {
2811 crate::session_sqlite::load_session_meta(path).await
2812 })?;
2813 let header = meta.header;
2814
2815 let metadata = std::fs::metadata(path)
2816 .map_err(|e| Error::session(format!("Failed to stat session: {e}")))?;
2817 let size_bytes = metadata.len();
2818 let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
2819 #[allow(clippy::cast_possible_truncation)]
2820 let last_modified_ms = modified
2821 .duration_since(UNIX_EPOCH)
2822 .unwrap_or_default()
2823 .as_millis() as i64; Ok(SessionPickEntry {
2826 path: path.to_path_buf(),
2827 id: header.id,
2828 timestamp: header.timestamp,
2829 message_count: meta.message_count,
2830 name: meta.name,
2831 last_modified_ms,
2832 size_bytes,
2833 })
2834}
2835
2836#[derive(Debug, Clone, Serialize, Deserialize)]
2842#[serde(rename_all = "camelCase")]
2843pub struct SessionHeader {
2844 pub r#type: String,
2845 #[serde(skip_serializing_if = "Option::is_none")]
2846 pub version: Option<u8>,
2847 pub id: String,
2848 pub timestamp: String,
2849 pub cwd: String,
2850 #[serde(skip_serializing_if = "Option::is_none")]
2851 pub provider: Option<String>,
2852 #[serde(skip_serializing_if = "Option::is_none")]
2853 pub model_id: Option<String>,
2854 #[serde(skip_serializing_if = "Option::is_none")]
2855 pub thinking_level: Option<String>,
2856 #[serde(
2857 skip_serializing_if = "Option::is_none",
2858 rename = "branchedFrom",
2859 alias = "parentSession"
2860 )]
2861 pub parent_session: Option<String>,
2862}
2863
2864impl SessionHeader {
2865 pub fn new() -> Self {
2866 let now = chrono::Utc::now();
2867 Self {
2868 r#type: "session".to_string(),
2869 version: Some(SESSION_VERSION),
2870 id: uuid::Uuid::new_v4().to_string(),
2871 timestamp: now.to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2872 cwd: std::env::current_dir()
2873 .map(|p| p.display().to_string())
2874 .unwrap_or_default(),
2875 provider: None,
2876 model_id: None,
2877 thinking_level: None,
2878 parent_session: None,
2879 }
2880 }
2881}
2882
2883impl Default for SessionHeader {
2884 fn default() -> Self {
2885 Self::new()
2886 }
2887}
2888
2889#[derive(Debug, Clone, Serialize, Deserialize)]
2895#[serde(tag = "type", rename_all = "snake_case")]
2896pub enum SessionEntry {
2897 Message(MessageEntry),
2898 ModelChange(ModelChangeEntry),
2899 ThinkingLevelChange(ThinkingLevelChangeEntry),
2900 Compaction(CompactionEntry),
2901 BranchSummary(BranchSummaryEntry),
2902 Label(LabelEntry),
2903 SessionInfo(SessionInfoEntry),
2904 Custom(CustomEntry),
2905}
2906
2907impl SessionEntry {
2908 pub const fn base(&self) -> &EntryBase {
2909 match self {
2910 Self::Message(e) => &e.base,
2911 Self::ModelChange(e) => &e.base,
2912 Self::ThinkingLevelChange(e) => &e.base,
2913 Self::Compaction(e) => &e.base,
2914 Self::BranchSummary(e) => &e.base,
2915 Self::Label(e) => &e.base,
2916 Self::SessionInfo(e) => &e.base,
2917 Self::Custom(e) => &e.base,
2918 }
2919 }
2920
2921 pub const fn base_mut(&mut self) -> &mut EntryBase {
2922 match self {
2923 Self::Message(e) => &mut e.base,
2924 Self::ModelChange(e) => &mut e.base,
2925 Self::ThinkingLevelChange(e) => &mut e.base,
2926 Self::Compaction(e) => &mut e.base,
2927 Self::BranchSummary(e) => &mut e.base,
2928 Self::Label(e) => &mut e.base,
2929 Self::SessionInfo(e) => &mut e.base,
2930 Self::Custom(e) => &mut e.base,
2931 }
2932 }
2933
2934 pub const fn base_id(&self) -> Option<&String> {
2935 self.base().id.as_ref()
2936 }
2937}
2938
2939#[derive(Debug, Clone, Serialize, Deserialize)]
2941#[serde(rename_all = "camelCase")]
2942pub struct EntryBase {
2943 #[serde(skip_serializing_if = "Option::is_none")]
2944 pub id: Option<String>,
2945 #[serde(skip_serializing_if = "Option::is_none")]
2946 pub parent_id: Option<String>,
2947 pub timestamp: String,
2948}
2949
2950impl EntryBase {
2951 pub fn new(parent_id: Option<String>, id: String) -> Self {
2952 Self {
2953 id: Some(id),
2954 parent_id,
2955 timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
2956 }
2957 }
2958}
2959
2960#[derive(Debug, Clone, Serialize, Deserialize)]
2962#[serde(rename_all = "camelCase")]
2963pub struct MessageEntry {
2964 #[serde(flatten)]
2965 pub base: EntryBase,
2966 pub message: SessionMessage,
2967}
2968
2969#[derive(Debug, Clone, Serialize, Deserialize)]
2971#[serde(
2972 tag = "role",
2973 rename_all = "camelCase",
2974 rename_all_fields = "camelCase"
2975)]
2976pub enum SessionMessage {
2977 User {
2978 content: UserContent,
2979 #[serde(skip_serializing_if = "Option::is_none")]
2980 timestamp: Option<i64>,
2981 },
2982 Assistant {
2983 #[serde(flatten)]
2984 message: AssistantMessage,
2985 },
2986 ToolResult {
2987 tool_call_id: String,
2988 tool_name: String,
2989 content: Vec<ContentBlock>,
2990 #[serde(skip_serializing_if = "Option::is_none")]
2991 details: Option<Value>,
2992 #[serde(default)]
2993 is_error: bool,
2994 #[serde(skip_serializing_if = "Option::is_none")]
2995 timestamp: Option<i64>,
2996 },
2997 Custom {
2998 custom_type: String,
2999 content: String,
3000 #[serde(default)]
3001 display: bool,
3002 #[serde(skip_serializing_if = "Option::is_none")]
3003 details: Option<Value>,
3004 #[serde(skip_serializing_if = "Option::is_none")]
3005 timestamp: Option<i64>,
3006 },
3007 BashExecution {
3008 command: String,
3009 output: String,
3010 exit_code: i32,
3011 #[serde(skip_serializing_if = "Option::is_none")]
3012 cancelled: Option<bool>,
3013 #[serde(skip_serializing_if = "Option::is_none")]
3014 truncated: Option<bool>,
3015 #[serde(skip_serializing_if = "Option::is_none")]
3016 full_output_path: Option<String>,
3017 #[serde(skip_serializing_if = "Option::is_none")]
3018 timestamp: Option<i64>,
3019 #[serde(flatten)]
3020 extra: HashMap<String, Value>,
3021 },
3022 BranchSummary {
3023 summary: String,
3024 from_id: String,
3025 },
3026 CompactionSummary {
3027 summary: String,
3028 tokens_before: u64,
3029 },
3030}
3031
3032impl From<Message> for SessionMessage {
3033 fn from(message: Message) -> Self {
3034 match message {
3035 Message::User(user) => Self::User {
3036 content: user.content,
3037 timestamp: Some(user.timestamp),
3038 },
3039 Message::Assistant(assistant) => Self::Assistant {
3040 message: Arc::try_unwrap(assistant).unwrap_or_else(|a| (*a).clone()),
3041 },
3042 Message::ToolResult(result) => {
3043 let result = Arc::try_unwrap(result).unwrap_or_else(|a| (*a).clone());
3044 Self::ToolResult {
3045 tool_call_id: result.tool_call_id,
3046 tool_name: result.tool_name,
3047 content: result.content,
3048 details: result.details,
3049 is_error: result.is_error,
3050 timestamp: Some(result.timestamp),
3051 }
3052 }
3053 Message::Custom(custom) => Self::Custom {
3054 custom_type: custom.custom_type,
3055 content: custom.content,
3056 display: custom.display,
3057 details: custom.details,
3058 timestamp: Some(custom.timestamp),
3059 },
3060 }
3061 }
3062}
3063
3064#[derive(Debug, Clone, Serialize, Deserialize)]
3066#[serde(rename_all = "camelCase")]
3067pub struct ModelChangeEntry {
3068 #[serde(flatten)]
3069 pub base: EntryBase,
3070 pub provider: String,
3071 pub model_id: String,
3072}
3073
3074#[derive(Debug, Clone, Serialize, Deserialize)]
3076#[serde(rename_all = "camelCase")]
3077pub struct ThinkingLevelChangeEntry {
3078 #[serde(flatten)]
3079 pub base: EntryBase,
3080 pub thinking_level: String,
3081}
3082
3083#[derive(Debug, Clone, Serialize, Deserialize)]
3085#[serde(rename_all = "camelCase")]
3086pub struct CompactionEntry {
3087 #[serde(flatten)]
3088 pub base: EntryBase,
3089 pub summary: String,
3090 pub first_kept_entry_id: String,
3091 pub tokens_before: u64,
3092 #[serde(skip_serializing_if = "Option::is_none")]
3093 pub details: Option<serde_json::Value>,
3094 #[serde(skip_serializing_if = "Option::is_none")]
3095 pub from_hook: Option<bool>,
3096}
3097
3098#[derive(Debug, Clone, Serialize, Deserialize)]
3100#[serde(rename_all = "camelCase")]
3101pub struct BranchSummaryEntry {
3102 #[serde(flatten)]
3103 pub base: EntryBase,
3104 pub from_id: String,
3105 pub summary: String,
3106 #[serde(skip_serializing_if = "Option::is_none")]
3107 pub details: Option<serde_json::Value>,
3108 #[serde(skip_serializing_if = "Option::is_none")]
3109 pub from_hook: Option<bool>,
3110}
3111
3112#[derive(Debug, Clone, Serialize, Deserialize)]
3114#[serde(rename_all = "camelCase")]
3115pub struct LabelEntry {
3116 #[serde(flatten)]
3117 pub base: EntryBase,
3118 pub target_id: String,
3119 #[serde(skip_serializing_if = "Option::is_none")]
3120 pub label: Option<String>,
3121}
3122
3123#[derive(Debug, Clone, Serialize, Deserialize)]
3125#[serde(rename_all = "camelCase")]
3126pub struct SessionInfoEntry {
3127 #[serde(flatten)]
3128 pub base: EntryBase,
3129 #[serde(skip_serializing_if = "Option::is_none")]
3130 pub name: Option<String>,
3131}
3132
3133#[derive(Debug, Clone, Serialize, Deserialize)]
3135#[serde(rename_all = "camelCase")]
3136pub struct CustomEntry {
3137 #[serde(flatten)]
3138 pub base: EntryBase,
3139 pub custom_type: String,
3140 #[serde(skip_serializing_if = "Option::is_none")]
3141 pub data: Option<serde_json::Value>,
3142}
3143
3144pub fn encode_cwd(path: &std::path::Path) -> String {
3150 let s = path.display().to_string();
3151 let s = s.trim_start_matches(['/', '\\']);
3152 let s = s.replace(['/', '\\', ':'], "-");
3153 format!("--{s}--")
3154}
3155
3156pub(crate) fn session_message_to_model(message: &SessionMessage) -> Option<Message> {
3157 match message {
3158 SessionMessage::User { content, timestamp } => Some(Message::User(UserMessage {
3159 content: content.clone(),
3160 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3161 })),
3162 SessionMessage::Assistant { message } => Some(Message::assistant(message.clone())),
3163 SessionMessage::ToolResult {
3164 tool_call_id,
3165 tool_name,
3166 content,
3167 details,
3168 is_error,
3169 timestamp,
3170 } => Some(Message::tool_result(ToolResultMessage {
3171 tool_call_id: tool_call_id.clone(),
3172 tool_name: tool_name.clone(),
3173 content: content.clone(),
3174 details: details.clone(),
3175 is_error: *is_error,
3176 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3177 })),
3178 SessionMessage::Custom {
3179 custom_type,
3180 content,
3181 display,
3182 details,
3183 timestamp,
3184 } => Some(Message::Custom(crate::model::CustomMessage {
3185 content: content.clone(),
3186 custom_type: custom_type.clone(),
3187 display: *display,
3188 details: details.clone(),
3189 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3190 })),
3191 SessionMessage::BashExecution {
3192 command,
3193 output,
3194 exit_code,
3195 cancelled,
3196 truncated,
3197 full_output_path,
3198 timestamp,
3199 extra,
3200 } => {
3201 if extra
3202 .get("excludeFromContext")
3203 .and_then(Value::as_bool)
3204 .is_some_and(|v| v)
3205 {
3206 return None;
3207 }
3208 let text = bash_execution_to_text(
3209 command,
3210 output,
3211 *exit_code,
3212 cancelled.unwrap_or(false),
3213 truncated.unwrap_or(false),
3214 full_output_path.as_deref(),
3215 );
3216 Some(Message::User(UserMessage {
3217 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(text))]),
3218 timestamp: timestamp.unwrap_or_else(|| chrono::Utc::now().timestamp_millis()),
3219 }))
3220 }
3221 SessionMessage::BranchSummary { summary, .. } => Some(Message::User(UserMessage {
3222 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3223 "{BRANCH_SUMMARY_PREFIX}{summary}{BRANCH_SUMMARY_SUFFIX}"
3224 )))]),
3225 timestamp: chrono::Utc::now().timestamp_millis(),
3226 })),
3227 SessionMessage::CompactionSummary { summary, .. } => Some(Message::User(UserMessage {
3228 content: UserContent::Blocks(vec![ContentBlock::Text(TextContent::new(format!(
3229 "{COMPACTION_SUMMARY_PREFIX}{summary}{COMPACTION_SUMMARY_SUFFIX}"
3230 )))]),
3231 timestamp: chrono::Utc::now().timestamp_millis(),
3232 })),
3233 }
3234}
3235
3236const COMPACTION_SUMMARY_PREFIX: &str = "The conversation history before this point was compacted into the following summary:\n\n<summary>\n";
3237const COMPACTION_SUMMARY_SUFFIX: &str = "\n</summary>";
3238
3239const BRANCH_SUMMARY_PREFIX: &str =
3240 "The following is a summary of a branch that this conversation came back from:\n\n<summary>\n";
3241const BRANCH_SUMMARY_SUFFIX: &str = "</summary>";
3242
3243pub(crate) fn bash_execution_to_text(
3244 command: &str,
3245 output: &str,
3246 exit_code: i32,
3247 cancelled: bool,
3248 truncated: bool,
3249 full_output_path: Option<&str>,
3250) -> String {
3251 let mut text = format!("Ran `{command}`\n");
3252 if output.is_empty() {
3253 text.push_str("(no output)");
3254 } else {
3255 text.push_str("```\n");
3256 text.push_str(output);
3257 if !output.ends_with('\n') {
3258 text.push('\n');
3259 }
3260 text.push_str("```");
3261 }
3262
3263 if cancelled {
3264 text.push_str("\n\n(command cancelled)");
3265 } else if exit_code != 0 {
3266 let _ = write!(text, "\n\nCommand exited with code {exit_code}");
3267 }
3268
3269 if truncated {
3270 if let Some(path) = full_output_path {
3271 let _ = write!(text, "\n\n[Output truncated. Full output: {path}]");
3272 }
3273 }
3274
3275 text
3276}
3277
3278#[allow(clippy::too_many_lines)]
3283fn render_session_html(header: &SessionHeader, entries: &[SessionEntry]) -> String {
3284 let mut html = String::new();
3285 html.push_str("<!doctype html><html><head><meta charset=\"utf-8\">");
3286 html.push_str("<title>Pi Session</title>");
3287 html.push_str("<style>");
3288 html.push_str(
3289 "body{font-family:system-ui,-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;margin:24px;background:#0b0c10;color:#e6e6e6;}
3290 h1{margin:0 0 8px 0;}
3291 .meta{color:#9aa0a6;margin-bottom:24px;font-size:14px;}
3292 .msg{padding:16px 18px;margin:12px 0;border-radius:8px;background:#14161b;}
3293 .msg.user{border-left:4px solid #4fc3f7;}
3294 .msg.assistant{border-left:4px solid #81c784;}
3295 .msg.tool{border-left:4px solid #ffb74d;}
3296 .msg.system{border-left:4px solid #ef9a9a;}
3297 .role{font-weight:600;margin-bottom:8px;}
3298 pre{white-space:pre-wrap;background:#0f1115;padding:12px;border-radius:6px;overflow:auto;}
3299 .thinking summary{cursor:pointer;}
3300 img{max-width:100%;height:auto;border-radius:6px;margin-top:8px;}
3301 .note{color:#9aa0a6;font-size:13px;margin:6px 0;}
3302 ",
3303 );
3304 html.push_str("</style></head><body>");
3305
3306 let _ = write!(
3307 html,
3308 "<h1>Pi Session</h1><div class=\"meta\">Session {} • {} • cwd: {}</div>",
3309 escape_html(&header.id),
3310 escape_html(&header.timestamp),
3311 escape_html(&header.cwd)
3312 );
3313
3314 for entry in entries {
3315 match entry {
3316 SessionEntry::Message(message) => {
3317 html.push_str(&render_session_message(&message.message));
3318 }
3319 SessionEntry::ModelChange(change) => {
3320 let _ = write!(
3321 html,
3322 "<div class=\"msg system\"><div class=\"role\">Model</div><div class=\"note\">{} / {}</div></div>",
3323 escape_html(&change.provider),
3324 escape_html(&change.model_id)
3325 );
3326 }
3327 SessionEntry::ThinkingLevelChange(change) => {
3328 let _ = write!(
3329 html,
3330 "<div class=\"msg system\"><div class=\"role\">Thinking</div><div class=\"note\">{}</div></div>",
3331 escape_html(&change.thinking_level)
3332 );
3333 }
3334 SessionEntry::Compaction(compaction) => {
3335 let _ = write!(
3336 html,
3337 "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
3338 escape_html(&compaction.summary)
3339 );
3340 }
3341 SessionEntry::BranchSummary(summary) => {
3342 let _ = write!(
3343 html,
3344 "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
3345 escape_html(&summary.summary)
3346 );
3347 }
3348 SessionEntry::SessionInfo(info) => {
3349 if let Some(name) = &info.name {
3350 let _ = write!(
3351 html,
3352 "<div class=\"msg system\"><div class=\"role\">Session Name</div><div class=\"note\">{}</div></div>",
3353 escape_html(name)
3354 );
3355 }
3356 }
3357 SessionEntry::Custom(custom) => {
3358 let _ = write!(
3359 html,
3360 "<div class=\"msg system\"><div class=\"role\">{}</div></div>",
3361 escape_html(&custom.custom_type)
3362 );
3363 }
3364 SessionEntry::Label(_) => {}
3365 }
3366 }
3367
3368 html.push_str("</body></html>");
3369 html
3370}
3371
3372fn render_session_message(message: &SessionMessage) -> String {
3373 match message {
3374 SessionMessage::User { content, .. } => {
3375 let mut html = String::new();
3376 html.push_str("<div class=\"msg user\"><div class=\"role\">User</div>");
3377 html.push_str(&render_user_content(content));
3378 html.push_str("</div>");
3379 html
3380 }
3381 SessionMessage::Assistant { message } => {
3382 let mut html = String::new();
3383 html.push_str("<div class=\"msg assistant\"><div class=\"role\">Assistant</div>");
3384 html.push_str(&render_blocks(&message.content));
3385 html.push_str("</div>");
3386 html
3387 }
3388 SessionMessage::ToolResult {
3389 tool_name,
3390 content,
3391 is_error,
3392 details,
3393 ..
3394 } => {
3395 let mut html = String::new();
3396 let role = if *is_error { "Tool Error" } else { "Tool" };
3397 let _ = write!(
3398 html,
3399 "<div class=\"msg tool\"><div class=\"role\">{}: {}</div>",
3400 role,
3401 escape_html(tool_name)
3402 );
3403 html.push_str(&render_blocks(content));
3404 if let Some(details) = details {
3405 let details_str =
3406 serde_json::to_string_pretty(details).unwrap_or_else(|_| details.to_string());
3407 let _ = write!(html, "<pre>{}</pre>", escape_html(&details_str));
3408 }
3409 html.push_str("</div>");
3410 html
3411 }
3412 SessionMessage::Custom {
3413 custom_type,
3414 content,
3415 ..
3416 } => {
3417 let mut html = String::new();
3418 let _ = write!(
3419 html,
3420 "<div class=\"msg system\"><div class=\"role\">{}</div><pre>{}</pre></div>",
3421 escape_html(custom_type),
3422 escape_html(content)
3423 );
3424 html
3425 }
3426 SessionMessage::BashExecution {
3427 command,
3428 output,
3429 exit_code,
3430 ..
3431 } => {
3432 let mut html = String::new();
3433 let _ = write!(
3434 html,
3435 "<div class=\"msg tool\"><div class=\"role\">Bash (exit {exit_code})</div><pre>{}</pre><pre>{}</pre></div>",
3436 escape_html(command),
3437 escape_html(output)
3438 );
3439 html
3440 }
3441 SessionMessage::BranchSummary { summary, .. } => {
3442 format!(
3443 "<div class=\"msg system\"><div class=\"role\">Branch Summary</div><pre>{}</pre></div>",
3444 escape_html(summary)
3445 )
3446 }
3447 SessionMessage::CompactionSummary { summary, .. } => {
3448 format!(
3449 "<div class=\"msg system\"><div class=\"role\">Compaction</div><pre>{}</pre></div>",
3450 escape_html(summary)
3451 )
3452 }
3453 }
3454}
3455
3456fn render_user_content(content: &UserContent) -> String {
3457 match content {
3458 UserContent::Text(text) => format!("<pre>{}</pre>", escape_html(text)),
3459 UserContent::Blocks(blocks) => render_blocks(blocks),
3460 }
3461}
3462
3463fn render_blocks(blocks: &[ContentBlock]) -> String {
3464 let mut html = String::new();
3465 for block in blocks {
3466 match block {
3467 ContentBlock::Text(text) => {
3468 let _ = write!(html, "<pre>{}</pre>", escape_html(&text.text));
3469 }
3470 ContentBlock::Thinking(thinking) => {
3471 let _ = write!(
3472 html,
3473 "<details class=\"thinking\"><summary>Thinking</summary><pre>{}</pre></details>",
3474 escape_html(&thinking.thinking)
3475 );
3476 }
3477 ContentBlock::Image(image) => {
3478 let _ = write!(
3479 html,
3480 "<img src=\"data:{};base64,{}\" alt=\"image\"/>",
3481 escape_html(&image.mime_type),
3482 escape_html(&image.data)
3483 );
3484 }
3485 ContentBlock::ToolCall(tool_call) => {
3486 let args = serde_json::to_string_pretty(&tool_call.arguments)
3487 .unwrap_or_else(|_| tool_call.arguments.to_string());
3488 let _ = write!(
3489 html,
3490 "<div class=\"note\">Tool call: {}</div><pre>{}</pre>",
3491 escape_html(&tool_call.name),
3492 escape_html(&args)
3493 );
3494 }
3495 }
3496 }
3497 html
3498}
3499
3500fn escape_html(input: &str) -> String {
3501 let mut escaped = String::with_capacity(input.len());
3502 for ch in input.chars() {
3503 match ch {
3504 '&' => escaped.push_str("&"),
3505 '<' => escaped.push_str("<"),
3506 '>' => escaped.push_str(">"),
3507 '"' => escaped.push_str("""),
3508 '\'' => escaped.push_str("'"),
3509 _ => escaped.push(ch),
3510 }
3511 }
3512 escaped
3513}
3514
3515fn user_content_to_text(content: &UserContent) -> String {
3516 match content {
3517 UserContent::Text(text) => text.clone(),
3518 UserContent::Blocks(blocks) => content_blocks_to_text(blocks),
3519 }
3520}
3521
3522fn content_blocks_to_text(blocks: &[ContentBlock]) -> String {
3523 let mut output = String::new();
3524 for block in blocks {
3525 match block {
3526 ContentBlock::Text(text_block) => push_line(&mut output, &text_block.text),
3527 ContentBlock::Image(image) => {
3528 push_line(&mut output, &format!("[image: {}]", image.mime_type));
3529 }
3530 ContentBlock::Thinking(thinking_block) => {
3531 push_line(&mut output, &thinking_block.thinking);
3532 }
3533 ContentBlock::ToolCall(call) => {
3534 push_line(&mut output, &format!("[tool call: {}]", call.name));
3535 }
3536 }
3537 }
3538 output
3539}
3540
3541fn push_line(out: &mut String, line: &str) {
3542 if !out.is_empty() {
3543 out.push('\n');
3544 }
3545 out.push_str(line);
3546}
3547
3548fn entry_id_set(entries: &[SessionEntry]) -> HashSet<String> {
3549 entries
3550 .iter()
3551 .filter_map(|e| e.base_id().cloned())
3552 .collect()
3553}
3554
3555fn session_entry_stats(entries: &[SessionEntry]) -> (u64, Option<String>) {
3556 let mut message_count = 0u64;
3557 let mut name = None;
3558 for entry in entries {
3559 match entry {
3560 SessionEntry::Message(_) => message_count += 1,
3561 SessionEntry::SessionInfo(info) => {
3562 if info.name.is_some() {
3563 name.clone_from(&info.name);
3564 }
3565 }
3566 _ => {}
3567 }
3568 }
3569 (message_count, name)
3570}
3571
3572const PARALLEL_THRESHOLD: usize = 512;
3574const JSONL_PARSE_BATCH_SIZE: usize = 8192;
3576
3577#[allow(clippy::too_many_lines)]
3582fn open_jsonl_blocking(path_buf: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
3583 use std::io::BufRead;
3584
3585 let file = std::fs::File::open(&path_buf).map_err(|e| crate::Error::Io(Box::new(e)))?;
3586 let mut reader = std::io::BufReader::new(file);
3587
3588 let mut header_line = String::new();
3589 reader
3590 .read_line(&mut header_line)
3591 .map_err(|e| crate::Error::Io(Box::new(e)))?;
3592
3593 if header_line.trim().is_empty() {
3594 return Err(crate::Error::session("Empty session file"));
3595 }
3596
3597 let header: SessionHeader = serde_json::from_str(&header_line)
3599 .map_err(|e| crate::Error::session(format!("Invalid header: {e}")))?;
3600
3601 let mut entries = Vec::new();
3602 let mut diagnostics = SessionOpenDiagnostics::default();
3603
3604 let num_threads = std::thread::available_parallelism().map_or(4, |n| n.get().min(8));
3607
3608 let mut line_batch: Vec<(usize, String)> = Vec::with_capacity(JSONL_PARSE_BATCH_SIZE);
3609 let mut current_line_num = 2; loop {
3612 line_batch.clear();
3613 let mut batch_eof = false;
3614
3615 for _ in 0..JSONL_PARSE_BATCH_SIZE {
3616 let mut line = String::new();
3617 match reader.read_line(&mut line) {
3618 Ok(0) => {
3619 batch_eof = true;
3620 break;
3621 }
3622 Ok(_) => {
3623 if !line.trim().is_empty() {
3624 line_batch.push((current_line_num, line));
3625 }
3626 }
3627 Err(e) => {
3628 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
3629 line_number: current_line_num,
3630 error: format!("IO error reading line: {e}"),
3631 });
3632 }
3633 }
3634 current_line_num += 1;
3635 }
3636
3637 if line_batch.is_empty() {
3638 if batch_eof {
3639 break;
3640 }
3641 continue;
3642 }
3643
3644 if line_batch.len() >= PARALLEL_THRESHOLD && num_threads > 1 {
3645 let chunk_size = (line_batch.len() / num_threads).max(64);
3646
3647 let chunk_results: Result<Vec<(Vec<SessionEntry>, Vec<SessionOpenSkippedEntry>)>> =
3648 std::thread::scope(|s| {
3649 line_batch
3650 .chunks(chunk_size)
3651 .map(|chunk| {
3652 s.spawn(move || {
3653 let mut ok = Vec::with_capacity(chunk.len());
3654 let mut skip = Vec::new();
3655 for (line_num, line) in chunk {
3656 match serde_json::from_str::<SessionEntry>(line) {
3657 Ok(entry) => ok.push(entry),
3658 Err(e) => {
3659 skip.push(SessionOpenSkippedEntry {
3660 line_number: *line_num,
3661 error: e.to_string(),
3662 });
3663 }
3664 }
3665 }
3666 (ok, skip)
3667 })
3668 })
3669 .collect::<Vec<_>>()
3670 .into_iter()
3671 .map(|h| {
3672 h.join().map_err(|panic_payload| {
3673 let panic_message =
3674 panic_payload.downcast_ref::<String>().map_or_else(
3675 || {
3676 panic_payload.downcast_ref::<&str>().map_or_else(
3677 || "unknown panic payload".to_string(),
3678 |message| (*message).to_string(),
3679 )
3680 },
3681 std::clone::Clone::clone,
3682 );
3683 Error::session(format!(
3684 "parallel session parse worker panicked: {panic_message}"
3685 ))
3686 })
3687 })
3688 .collect()
3689 });
3690 let chunk_results = chunk_results?;
3691
3692 for (chunk_entries, chunk_skipped) in chunk_results {
3693 entries.extend(chunk_entries);
3694 diagnostics.skipped_entries.extend(chunk_skipped);
3695 }
3696 } else {
3697 for (line_num, line) in &line_batch {
3699 match serde_json::from_str::<SessionEntry>(line) {
3700 Ok(entry) => entries.push(entry),
3701 Err(e) => {
3702 diagnostics.skipped_entries.push(SessionOpenSkippedEntry {
3703 line_number: *line_num,
3704 error: e.to_string(),
3705 });
3706 }
3707 }
3708 }
3709 }
3710
3711 if batch_eof {
3712 break;
3713 }
3714 }
3715
3716 let finalized = finalize_loaded_entries(&mut entries);
3718 for orphan in &finalized.orphans {
3719 diagnostics
3720 .orphaned_parent_links
3721 .push(SessionOpenOrphanedParentLink {
3722 entry_id: orphan.0.clone(),
3723 missing_parent_id: orphan.1.clone(),
3724 });
3725 }
3726
3727 let entry_count = entries.len();
3728
3729 Ok((
3730 Session {
3731 header,
3732 entries,
3733 path: Some(path_buf),
3734 leaf_id: finalized.leaf_id,
3735 session_dir: None,
3736 store_kind: SessionStoreKind::Jsonl,
3737 entry_ids: finalized.entry_ids,
3738 is_linear: finalized.is_linear,
3739 entry_index: finalized.entry_index,
3740 cached_message_count: finalized.message_count,
3741 cached_name: finalized.name,
3742 autosave_queue: AutosaveQueue::new(),
3743 autosave_durability: AutosaveDurabilityMode::from_env(),
3744 persisted_entry_count: Arc::new(AtomicUsize::new(entry_count)),
3745 header_dirty: false,
3746 appends_since_checkpoint: 0,
3747 v2_sidecar_root: None,
3748 v2_partial_hydration: false,
3749 v2_resume_mode: None,
3750 v2_message_count_offset: 0,
3751 },
3752 diagnostics,
3753 ))
3754}
3755
3756#[allow(clippy::too_many_lines)]
3762fn open_from_v2_store_blocking(jsonl_path: PathBuf) -> Result<(Session, SessionOpenDiagnostics)> {
3763 let file = std::fs::File::open(&jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3765 let mut reader = BufReader::new(file);
3766 let mut header_line = String::new();
3767 reader
3768 .read_line(&mut header_line)
3769 .map_err(|e| crate::Error::Io(Box::new(e)))?;
3770 let header: SessionHeader = serde_json::from_str(header_line.trim())
3771 .map_err(|e| crate::Error::session(format!("Invalid header in JSONL: {e}")))?;
3772
3773 let v2_root = session_store_v2::v2_sidecar_path(&jsonl_path);
3775 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
3776
3777 let mode_override_raw = std::env::var("PI_SESSION_V2_OPEN_MODE").ok();
3781 let threshold_override_raw = std::env::var("PI_SESSION_V2_LAZY_THRESHOLD").ok();
3782 if let Some(raw) = mode_override_raw.as_deref() {
3783 if parse_v2_open_mode(raw).is_none() {
3784 tracing::warn!(
3785 value = %raw,
3786 "invalid PI_SESSION_V2_OPEN_MODE; using automatic hydration mode selection"
3787 );
3788 }
3789 }
3790 if let Some(raw) = threshold_override_raw.as_deref() {
3791 if raw.trim().parse::<u64>().is_err() {
3792 tracing::warn!(
3793 value = %raw,
3794 "invalid PI_SESSION_V2_LAZY_THRESHOLD; using default lazy hydration threshold"
3795 );
3796 }
3797 }
3798
3799 let entry_count = store.entry_count();
3800 let (selected_mode, selection_reason, lazy_threshold) = select_v2_open_mode_for_resume(
3801 entry_count,
3802 mode_override_raw.as_deref(),
3803 threshold_override_raw.as_deref(),
3804 );
3805 let mode = if matches!(selected_mode, V2OpenMode::ActivePath)
3806 && entry_count > 0
3807 && store.head().is_none()
3808 {
3809 tracing::warn!(
3810 entry_count,
3811 "active-path hydration selected but store has no head; falling back to full hydration"
3812 );
3813 V2OpenMode::Full
3814 } else {
3815 selected_mode
3816 };
3817 tracing::debug!(
3818 entry_count,
3819 lazy_threshold,
3820 selection_reason,
3821 ?mode,
3822 "selected V2 resume hydration mode"
3823 );
3824
3825 let (mut session, diagnostics) = Session::open_from_v2(&store, header, mode)?;
3827 session.path = Some(jsonl_path);
3828 session.v2_sidecar_root = Some(v2_root);
3829 session.v2_partial_hydration = !matches!(mode, V2OpenMode::Full);
3830 session.v2_resume_mode = Some(mode);
3831 Ok((session, diagnostics))
3832}
3833
3834pub fn create_v2_sidecar_from_jsonl(jsonl_path: &Path) -> Result<SessionStoreV2> {
3840 use std::io::BufRead;
3841
3842 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3843 let mut reader = std::io::BufReader::new(file);
3844
3845 let mut header_line = String::new();
3846 reader
3847 .read_line(&mut header_line)
3848 .map_err(|e| crate::Error::Io(Box::new(e)))?;
3849
3850 if header_line.trim().is_empty() {
3851 return Err(crate::Error::session("Empty JSONL session file"));
3852 }
3853
3854 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3855 if v2_root.exists() {
3856 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
3857 }
3858 let mut store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024)?;
3859
3860 for line_res in reader.lines() {
3861 let line = line_res.map_err(|e| crate::Error::Io(Box::new(e)))?;
3862 if line.trim().is_empty() {
3863 continue;
3864 }
3865 let entry: SessionEntry = serde_json::from_str(&line)
3866 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
3867 let (entry_id, parent_entry_id, entry_type, payload) =
3868 session_store_v2::session_entry_to_frame_args(&entry)?;
3869 store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
3870 }
3871
3872 Ok(store)
3873}
3874
3875pub fn migrate_jsonl_to_v2(
3881 jsonl_path: &Path,
3882 correlation_id: &str,
3883) -> Result<session_store_v2::MigrationEvent> {
3884 let store = create_v2_sidecar_from_jsonl(jsonl_path)?;
3885
3886 let verification = verify_v2_against_jsonl(jsonl_path, &store)?;
3888
3889 if !(verification.entry_count_match
3890 && verification.hash_chain_match
3891 && verification.index_consistent)
3892 {
3893 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3895 if v2_root.exists() {
3896 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
3897 }
3898 return Err(crate::Error::session(format!(
3899 "V2 migration verification failed: count={} hash={} index={}",
3900 verification.entry_count_match,
3901 verification.hash_chain_match,
3902 verification.index_consistent,
3903 )));
3904 }
3905
3906 let event = session_store_v2::MigrationEvent {
3907 schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
3908 migration_id: uuid::Uuid::new_v4().to_string(),
3909 phase: "forward".to_string(),
3910 at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
3911 source_path: jsonl_path.display().to_string(),
3912 target_path: session_store_v2::v2_sidecar_path(jsonl_path)
3913 .display()
3914 .to_string(),
3915 source_format: "jsonl_v3".to_string(),
3916 target_format: "native_v2".to_string(),
3917 verification,
3918 outcome: "ok".to_string(),
3919 error_class: None,
3920 correlation_id: correlation_id.to_string(),
3921 };
3922 store.append_migration_event(event.clone())?;
3923
3924 Ok(event)
3925}
3926
3927pub fn verify_v2_against_jsonl(
3932 jsonl_path: &Path,
3933 store: &SessionStoreV2,
3934) -> Result<session_store_v2::MigrationVerification> {
3935 use std::io::BufRead;
3936
3937 let file = std::fs::File::open(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
3939 let mut reader = std::io::BufReader::new(file);
3940
3941 let mut header_line = String::new();
3942 reader
3943 .read_line(&mut header_line)
3944 .map_err(|e| crate::Error::Io(Box::new(e)))?;
3945
3946 if header_line.trim().is_empty() {
3947 return Err(crate::Error::session("Empty JSONL session file"));
3948 }
3949
3950 let mut jsonl_ids: Vec<String> = Vec::new();
3951 for line_res in reader.lines() {
3952 let line = line_res.map_err(|e| crate::Error::Io(Box::new(e)))?;
3953 if line.trim().is_empty() {
3954 continue;
3955 }
3956 let entry: SessionEntry = serde_json::from_str(&line)
3957 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
3958 let id = entry
3959 .base_id()
3960 .cloned()
3961 .ok_or_else(|| crate::Error::session("SessionEntry has no id"))?;
3962 jsonl_ids.push(id);
3963 }
3964
3965 let frames = store.read_all_entries()?;
3967 let v2_ids: Vec<String> = frames.iter().map(|f| f.entry_id.clone()).collect();
3968
3969 let entry_count_match = jsonl_ids.len() == v2_ids.len() && jsonl_ids == v2_ids;
3970
3971 let index_consistent = store.validate_integrity().is_ok();
3973
3974 let hash_chain_match = index_consistent;
3976
3977 Ok(session_store_v2::MigrationVerification {
3978 entry_count_match,
3979 hash_chain_match,
3980 index_consistent,
3981 })
3982}
3983
3984pub fn rollback_v2_sidecar(jsonl_path: &Path, correlation_id: &str) -> Result<()> {
3989 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
3990 if !v2_root.exists() {
3991 return Ok(());
3992 }
3993
3994 if let Ok(store) = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
3996 let event = session_store_v2::MigrationEvent {
3997 schema: session_store_v2::MIGRATION_EVENT_SCHEMA.to_string(),
3998 migration_id: uuid::Uuid::new_v4().to_string(),
3999 phase: "rollback_to_jsonl".to_string(),
4000 at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true),
4001 source_path: v2_root.display().to_string(),
4002 target_path: jsonl_path.display().to_string(),
4003 source_format: "native_v2".to_string(),
4004 target_format: "jsonl_v3".to_string(),
4005 verification: session_store_v2::MigrationVerification {
4006 entry_count_match: true,
4007 hash_chain_match: true,
4008 index_consistent: true,
4009 },
4010 outcome: "ok".to_string(),
4011 error_class: None,
4012 correlation_id: correlation_id.to_string(),
4013 };
4014 let _ = store.append_migration_event(event);
4015 }
4016
4017 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4018 Ok(())
4019}
4020
4021#[derive(Debug, Clone, PartialEq, Eq)]
4023pub enum MigrationState {
4024 Unmigrated,
4026 Migrated,
4028 Corrupt { error: String },
4030 Partial,
4032}
4033
4034pub fn migration_status(jsonl_path: &Path) -> MigrationState {
4036 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4037 if !v2_root.exists() {
4038 return MigrationState::Unmigrated;
4039 }
4040
4041 let segments_dir = v2_root.join("segments");
4042 if !segments_dir.exists() {
4043 return MigrationState::Partial;
4044 }
4045
4046 let index_path = v2_root.join("index").join("offsets.jsonl");
4047 if !index_path.exists() {
4048 match jsonl_has_entry_lines(jsonl_path) {
4049 Ok(true) => return MigrationState::Partial,
4050 Ok(false) => {}
4051 Err(e) => {
4052 return MigrationState::Corrupt {
4053 error: e.to_string(),
4054 };
4055 }
4056 }
4057 }
4058
4059 match SessionStoreV2::create(&v2_root, 64 * 1024 * 1024) {
4061 Ok(store) => match store.validate_integrity() {
4062 Ok(()) => MigrationState::Migrated,
4063 Err(e) => MigrationState::Corrupt {
4064 error: e.to_string(),
4065 },
4066 },
4067 Err(e) => MigrationState::Corrupt {
4068 error: e.to_string(),
4069 },
4070 }
4071}
4072
4073pub fn migrate_dry_run(jsonl_path: &Path) -> Result<session_store_v2::MigrationVerification> {
4079 let tmp_dir =
4080 tempfile::tempdir().map_err(|e| crate::Error::session(format!("tempdir: {e}")))?;
4081 let tmp_v2_root = tmp_dir.path().join("dry_run.v2");
4082
4083 let contents =
4085 std::fs::read_to_string(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4086 let mut lines = contents.lines();
4087 let _header = lines
4088 .next()
4089 .ok_or_else(|| crate::Error::session("Empty JSONL session file"))?;
4090
4091 let mut store = SessionStoreV2::create(&tmp_v2_root, 64 * 1024 * 1024)?;
4092 for line in lines {
4093 if line.trim().is_empty() {
4094 continue;
4095 }
4096 let entry: SessionEntry = serde_json::from_str(line)
4097 .map_err(|e| crate::Error::session(format!("Bad JSONL entry: {e}")))?;
4098 let (entry_id, parent_entry_id, entry_type, payload) =
4099 session_store_v2::session_entry_to_frame_args(&entry)?;
4100 store.append_entry(entry_id, parent_entry_id, entry_type, payload)?;
4101 }
4102
4103 verify_v2_against_jsonl(jsonl_path, &store)
4105 }
4107
4108pub fn recover_partial_migration(
4113 jsonl_path: &Path,
4114 correlation_id: &str,
4115 re_migrate: bool,
4116) -> Result<MigrationState> {
4117 let status = migration_status(jsonl_path);
4118 match &status {
4119 MigrationState::Unmigrated | MigrationState::Migrated => Ok(status),
4120 MigrationState::Partial | MigrationState::Corrupt { .. } => {
4121 let v2_root = session_store_v2::v2_sidecar_path(jsonl_path);
4123 if v2_root.exists() {
4124 std::fs::remove_dir_all(&v2_root).map_err(|e| crate::Error::Io(Box::new(e)))?;
4125 }
4126
4127 if re_migrate {
4128 migrate_jsonl_to_v2(jsonl_path, correlation_id)?;
4129 Ok(MigrationState::Migrated)
4130 } else {
4131 Ok(MigrationState::Unmigrated)
4132 }
4133 }
4134 }
4135}
4136
4137fn jsonl_has_entry_lines(jsonl_path: &Path) -> Result<bool> {
4138 let contents =
4139 std::fs::read_to_string(jsonl_path).map_err(|e| crate::Error::Io(Box::new(e)))?;
4140 let mut lines = contents.lines();
4141 if lines.next().is_none() {
4142 return Err(crate::Error::session("Empty JSONL session file"));
4143 }
4144 Ok(lines.any(|line| !line.trim().is_empty()))
4145}
4146
4147struct LoadFinalization {
4153 leaf_id: Option<String>,
4154 entry_ids: HashSet<String>,
4155 entry_index: HashMap<String, usize>,
4156 message_count: u64,
4157 name: Option<String>,
4158 is_linear: bool,
4159 orphans: Vec<(String, String)>,
4160}
4161
4162fn finalize_loaded_entries(entries: &mut [SessionEntry]) -> LoadFinalization {
4170 let mut entry_ids: HashSet<String> = entries
4172 .iter()
4173 .filter_map(|e| e.base_id().cloned())
4174 .collect();
4175 for entry in entries.iter_mut() {
4176 if entry.base().id.is_none() {
4177 let id = generate_entry_id(&entry_ids);
4178 entry.base_mut().id = Some(id.clone());
4179 entry_ids.insert(id);
4180 }
4181 }
4182
4183 let mut entry_index = HashMap::with_capacity(entries.len());
4185 let mut message_count = 0u64;
4186 let mut name: Option<String> = None;
4187 let mut leaf_id: Option<String> = None;
4188 let mut orphans = Vec::new();
4189 let mut parent_id_child_count: HashMap<Option<&str>, u32> = HashMap::new();
4191 let mut has_branching = false;
4192
4193 for (idx, entry) in entries.iter().enumerate() {
4194 let Some(id) = entry.base_id() else {
4195 continue;
4196 };
4197 entry_index.insert(id.clone(), idx);
4198 leaf_id = Some(id.clone());
4199
4200 if let Some(parent_id) = entry.base().parent_id.as_ref() {
4202 if !entry_ids.contains(parent_id) {
4203 orphans.push((id.clone(), parent_id.clone()));
4204 }
4205 }
4206
4207 if !has_branching {
4209 let parent_key = entry.base().parent_id.as_deref();
4210 let count = parent_id_child_count.entry(parent_key).or_insert(0);
4211 *count += 1;
4212 if *count > 1 {
4213 has_branching = true;
4214 }
4215 }
4216
4217 match entry {
4219 SessionEntry::Message(_) => message_count += 1,
4220 SessionEntry::SessionInfo(info) => {
4221 if info.name.is_some() {
4222 name.clone_from(&info.name);
4223 }
4224 }
4225 _ => {}
4226 }
4227 }
4228
4229 let is_linear = !has_branching;
4233
4234 LoadFinalization {
4235 leaf_id,
4236 entry_ids,
4237 entry_index,
4238 message_count,
4239 name,
4240 is_linear,
4241 orphans,
4242 }
4243}
4244
4245fn parse_env_bool(value: &str) -> bool {
4246 matches!(
4247 value.trim().to_ascii_lowercase().as_str(),
4248 "1" | "true" | "yes" | "on"
4249 )
4250}
4251
4252fn session_entry_id_cache_enabled() -> bool {
4253 static ENABLED: OnceLock<bool> = OnceLock::new();
4254 *ENABLED.get_or_init(|| {
4255 std::env::var("PI_SESSION_ENTRY_ID_CACHE").map_or(true, |value| parse_env_bool(&value))
4256 })
4257}
4258
4259fn ensure_entry_ids(entries: &mut [SessionEntry]) {
4260 let mut existing = entry_id_set(entries);
4261 for entry in entries.iter_mut() {
4262 if entry.base().id.is_none() {
4263 let id = generate_entry_id(&existing);
4264 entry.base_mut().id = Some(id.clone());
4265 existing.insert(id);
4266 }
4267 }
4268}
4269
4270fn generate_entry_id(existing: &HashSet<String>) -> String {
4272 for _ in 0..100 {
4273 let uuid = uuid::Uuid::new_v4();
4274 let id = uuid.simple().to_string()[..8].to_string();
4275 if !existing.contains(&id) {
4276 return id;
4277 }
4278 }
4279 uuid::Uuid::new_v4().to_string()
4280}
4281
4282#[cfg(test)]
4283mod tests {
4284 use super::*;
4285 use crate::model::{Cost, StopReason, Usage};
4286 use asupersync::runtime::RuntimeBuilder;
4287 use clap::Parser;
4288 use std::future::Future;
4289
4290 fn make_test_message(text: &str) -> SessionMessage {
4291 SessionMessage::User {
4292 content: UserContent::Text(text.to_string()),
4293 timestamp: Some(0),
4294 }
4295 }
4296
4297 fn run_async<T>(future: impl Future<Output = T>) -> T {
4298 let runtime = RuntimeBuilder::current_thread()
4299 .build()
4300 .expect("build runtime");
4301 runtime.block_on(future)
4302 }
4303
4304 #[test]
4305 fn v2_open_mode_parser_supports_expected_values() {
4306 assert_eq!(parse_v2_open_mode("full"), Some(V2OpenMode::Full));
4307 assert_eq!(parse_v2_open_mode("active"), Some(V2OpenMode::ActivePath));
4308 assert_eq!(
4309 parse_v2_open_mode("active_path"),
4310 Some(V2OpenMode::ActivePath)
4311 );
4312 assert_eq!(
4313 parse_v2_open_mode("active-path"),
4314 Some(V2OpenMode::ActivePath)
4315 );
4316 assert_eq!(
4317 parse_v2_open_mode("tail"),
4318 Some(V2OpenMode::Tail(DEFAULT_V2_TAIL_HYDRATION_COUNT))
4319 );
4320 assert_eq!(parse_v2_open_mode("tail:42"), Some(V2OpenMode::Tail(42)));
4321 assert_eq!(parse_v2_open_mode("tail:0"), Some(V2OpenMode::Tail(0)));
4322 assert_eq!(parse_v2_open_mode("bad-mode"), None);
4323 assert_eq!(parse_v2_open_mode("tail:not-a-number"), None);
4324 }
4325
4326 #[test]
4327 fn v2_open_mode_selection_prefers_env_override_then_threshold() {
4328 let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, Some("full"), None);
4329 assert_eq!(mode, V2OpenMode::Full);
4330 assert_eq!(reason, "env_override");
4331 assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
4332
4333 let (mode, reason, threshold) =
4334 select_v2_open_mode_for_resume(50_000, None, Some("not-a-number"));
4335 assert_eq!(
4336 mode,
4337 V2OpenMode::ActivePath,
4338 "invalid threshold falls back to default threshold"
4339 );
4340 assert_eq!(reason, "entry_count_above_lazy_threshold");
4341 assert_eq!(threshold, DEFAULT_V2_LAZY_HYDRATION_THRESHOLD);
4342
4343 let (mode, reason, threshold) = select_v2_open_mode_for_resume(50_000, None, Some("500"));
4344 assert_eq!(mode, V2OpenMode::ActivePath);
4345 assert_eq!(reason, "entry_count_above_lazy_threshold");
4346 assert_eq!(threshold, 500);
4347
4348 let (mode, reason, threshold) = select_v2_open_mode_for_resume(100, None, Some("500"));
4349 assert_eq!(mode, V2OpenMode::Full);
4350 assert_eq!(reason, "default_full");
4351 assert_eq!(threshold, 500);
4352 }
4353
4354 #[test]
4355 fn v2_partial_hydration_rehydrates_before_header_rewrite_save() {
4356 let temp_dir = tempfile::tempdir().unwrap();
4357 let path = temp_dir.path().join("lazy_hydration_branching.jsonl");
4358
4359 let mut seed = Session::create();
4363 seed.path = Some(path.clone());
4364 let _id_root = seed.append_message(make_test_message("root"));
4365 let id_a = seed.append_message(make_test_message("a"));
4366 let id_b = seed.append_message(make_test_message("main-branch"));
4367 assert!(seed.create_branch_from(&id_a));
4368 let id_c = seed.append_message(make_test_message("side-branch"));
4369 run_async(async { seed.save().await }).unwrap();
4370
4371 create_v2_sidecar_from_jsonl(&path).unwrap();
4373 let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
4374 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
4375 let (mut loaded, _) =
4376 Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
4377 loaded.path = Some(path.clone());
4378 loaded.v2_sidecar_root = Some(v2_root);
4379 loaded.v2_partial_hydration = true;
4380 loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
4381
4382 let active_ids: Vec<String> = loaded
4383 .entries
4384 .iter()
4385 .filter_map(|entry| entry.base().id.clone())
4386 .collect();
4387 assert!(
4388 !active_ids.contains(&id_b),
4389 "active path intentionally excludes non-leaf sibling branch"
4390 );
4391 assert!(active_ids.contains(&id_c));
4392
4393 loaded.set_model_header(Some("provider-updated".to_string()), None, None);
4395 run_async(async { loaded.save().await }).unwrap();
4396
4397 let (reopened, _) =
4398 run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
4399 let reopened_ids: Vec<String> = reopened
4400 .entries
4401 .iter()
4402 .filter_map(|entry| entry.base().id.clone())
4403 .collect();
4404 assert!(
4405 reopened_ids.contains(&id_b),
4406 "non-active branch entry must survive full rewrite after lazy hydration"
4407 );
4408 assert!(reopened_ids.contains(&id_c));
4409 assert_eq!(reopened_ids.len(), 4);
4410 }
4411
4412 #[test]
4413 fn v2_partial_hydration_save_keeps_pending_entries_after_rehydrate() {
4414 let temp_dir = tempfile::tempdir().unwrap();
4415 let path = temp_dir.path().join("lazy_hydration_pending_merge.jsonl");
4416
4417 let mut seed = Session::create();
4418 seed.path = Some(path.clone());
4419 let _id_root = seed.append_message(make_test_message("root"));
4420 let id_a = seed.append_message(make_test_message("a"));
4421 let id_b = seed.append_message(make_test_message("main-branch"));
4422 assert!(seed.create_branch_from(&id_a));
4423 let _id_c = seed.append_message(make_test_message("side-branch"));
4424 run_async(async { seed.save().await }).unwrap();
4425
4426 create_v2_sidecar_from_jsonl(&path).unwrap();
4427 let v2_root = crate::session_store_v2::v2_sidecar_path(&path);
4428 let store = SessionStoreV2::create(&v2_root, 64 * 1024 * 1024).unwrap();
4429 let (mut loaded, _) =
4430 Session::open_from_v2(&store, seed.header.clone(), V2OpenMode::ActivePath).unwrap();
4431 loaded.path = Some(path.clone());
4432 loaded.v2_sidecar_root = Some(v2_root);
4433 loaded.v2_partial_hydration = true;
4434 loaded.v2_resume_mode = Some(V2OpenMode::ActivePath);
4435
4436 let new_id = loaded.append_message(make_test_message("new-on-active-leaf"));
4437 loaded.set_model_header(Some("provider-updated".to_string()), None, None);
4438 run_async(async { loaded.save().await }).unwrap();
4439
4440 let (reopened, _) =
4441 run_async(async { Session::open_jsonl_with_diagnostics(&path).await }).unwrap();
4442 let reopened_ids: Vec<String> = reopened
4443 .entries
4444 .iter()
4445 .filter_map(|entry| entry.base().id.clone())
4446 .collect();
4447 assert!(
4448 reopened_ids.contains(&id_b),
4449 "non-active branch entry must survive rehydration+save"
4450 );
4451 assert!(
4452 reopened_ids.contains(&new_id),
4453 "pending entry appended on partial session must be preserved"
4454 );
4455 assert_eq!(reopened_ids.len(), 5);
4456 }
4457
4458 #[test]
4459 fn test_session_handle_mutations_defer_persistence_side_effects() {
4460 let temp_dir = tempfile::tempdir().expect("temp dir");
4461 let mut session = Session::create();
4462 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
4463 session.path = Some(temp_dir.path().to_path_buf());
4465 let handle = SessionHandle(Arc::new(Mutex::new(session)));
4466
4467 run_async(async { handle.set_name("deferred-save".to_string()).await })
4468 .expect("set_name should not trigger immediate save");
4469 run_async(async { handle.append_message(make_test_message("hello")).await })
4470 .expect("append_message should not trigger immediate save");
4471 run_async(async {
4472 handle
4473 .append_custom_entry(
4474 "marker".to_string(),
4475 Some(serde_json::json!({ "value": 42 })),
4476 )
4477 .await
4478 })
4479 .expect("append_custom_entry should not trigger immediate save");
4480 run_async(async {
4481 handle
4482 .set_model("prov".to_string(), "model".to_string())
4483 .await
4484 })
4485 .expect("set_model should not trigger immediate save");
4486 run_async(async { handle.set_thinking_level("high".to_string()).await })
4487 .expect("set_thinking_level should not trigger immediate save");
4488
4489 let branch = run_async(async { handle.get_branch().await });
4490 let message_id = branch
4491 .iter()
4492 .find_map(|entry| {
4493 if entry.get("type").and_then(Value::as_str) == Some("message") {
4494 entry
4495 .get("id")
4496 .and_then(Value::as_str)
4497 .map(ToString::to_string)
4498 } else {
4499 None
4500 }
4501 })
4502 .expect("message entry id in branch");
4503 run_async(async {
4504 handle
4505 .set_label(message_id, Some("hot-path".to_string()))
4506 .await
4507 })
4508 .expect("set_label should not trigger immediate save");
4509
4510 let state = run_async(async { handle.get_state().await });
4511 assert_eq!(
4512 state.get("sessionName").and_then(Value::as_str),
4513 Some("deferred-save")
4514 );
4515 assert_eq!(
4516 state.get("thinkingLevel").and_then(Value::as_str),
4517 Some("high")
4518 );
4519 assert_eq!(
4520 state.get("durabilityMode").and_then(Value::as_str),
4521 Some("throughput")
4522 );
4523 assert_eq!(state.get("messageCount").and_then(Value::as_u64), Some(1));
4524 assert_eq!(
4525 state
4526 .get("model")
4527 .and_then(|model| model.get("provider"))
4528 .and_then(Value::as_str),
4529 Some("prov")
4530 );
4531 assert_eq!(
4532 state
4533 .get("model")
4534 .and_then(|model| model.get("id"))
4535 .and_then(Value::as_str),
4536 Some("model")
4537 );
4538
4539 let (provider, model_id) = run_async(async { handle.get_model().await });
4540 assert_eq!(provider.as_deref(), Some("prov"));
4541 assert_eq!(model_id.as_deref(), Some("model"));
4542 }
4543
4544 #[test]
4545 fn test_autosave_queue_coalesces_mutations_per_flush() {
4546 let temp_dir = tempfile::tempdir().expect("temp dir");
4547 let mut session = Session::create();
4548 session.path = Some(temp_dir.path().join("autosave-coalesce.jsonl"));
4549
4550 session.append_message(make_test_message("one"));
4551 session.append_custom_entry("marker".to_string(), None);
4552 session.append_message(make_test_message("two"));
4553
4554 let before = session.autosave_metrics();
4555 assert_eq!(before.pending_mutations, 3);
4556 assert!(before.coalesced_mutations >= 2);
4557 assert_eq!(before.flush_succeeded, 0);
4558
4559 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await })
4560 .expect("periodic flush");
4561
4562 let after = session.autosave_metrics();
4563 assert_eq!(after.pending_mutations, 0);
4564 assert_eq!(after.flush_started, 1);
4565 assert_eq!(after.flush_succeeded, 1);
4566 assert_eq!(after.last_flush_batch_size, 3);
4567 assert_eq!(
4568 after.last_flush_trigger,
4569 Some(AutosaveFlushTrigger::Periodic)
4570 );
4571 }
4572
4573 #[test]
4574 fn test_autosave_queue_backpressure_is_bounded() {
4575 let mut session = Session::create();
4576 session.set_autosave_queue_limit_for_test(2);
4577
4578 for i in 0..5 {
4579 session.append_message(make_test_message(&format!("message-{i}")));
4580 }
4581
4582 let metrics = session.autosave_metrics();
4583 assert_eq!(metrics.max_pending_mutations, 2);
4584 assert_eq!(metrics.pending_mutations, 2);
4585 assert_eq!(metrics.backpressure_events, 3);
4586 assert!(metrics.coalesced_mutations >= 4);
4587 }
4588
4589 #[test]
4590 fn test_autosave_shutdown_flush_semantics_follow_durability_mode() {
4591 let temp_dir = tempfile::tempdir().expect("temp dir");
4592
4593 let mut strict = Session::create();
4594 strict.path = Some(temp_dir.path().to_path_buf());
4596 strict.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
4597 strict.append_message(make_test_message("strict"));
4598
4599 run_async(async { strict.flush_autosave_on_shutdown().await })
4600 .expect_err("strict mode should propagate shutdown flush failure");
4601 let strict_metrics = strict.autosave_metrics();
4602 assert_eq!(strict_metrics.flush_failed, 1);
4603 assert!(strict_metrics.pending_mutations > 0);
4604
4605 let mut throughput = Session::create();
4606 throughput.path = Some(temp_dir.path().to_path_buf());
4607 throughput.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
4608 throughput.append_message(make_test_message("throughput"));
4609
4610 run_async(async { throughput.flush_autosave_on_shutdown().await })
4611 .expect("throughput mode skips shutdown flush");
4612 let throughput_metrics = throughput.autosave_metrics();
4613 assert_eq!(throughput_metrics.flush_started, 0);
4614 assert_eq!(throughput_metrics.pending_mutations, 1);
4615 }
4616
4617 #[test]
4618 fn test_session_new_prefers_cli_durability_mode_over_config() {
4619 let cli =
4620 crate::cli::Cli::parse_from(["pi", "--no-session", "--session-durability", "strict"]);
4621 let config: Config =
4622 serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
4623 let session =
4624 run_async(async { Session::new(&cli, &config).await }).expect("create session");
4625 assert_eq!(
4626 session.autosave_durability_mode(),
4627 AutosaveDurabilityMode::Strict
4628 );
4629 }
4630
4631 #[test]
4632 fn test_session_new_uses_config_durability_mode_when_cli_unset() {
4633 let cli = crate::cli::Cli::parse_from(["pi", "--no-session"]);
4634 let config: Config =
4635 serde_json::from_str(r#"{ "sessionDurability": "throughput" }"#).expect("config parse");
4636 let session =
4637 run_async(async { Session::new(&cli, &config).await }).expect("create session");
4638 assert_eq!(
4639 session.autosave_durability_mode(),
4640 AutosaveDurabilityMode::Throughput
4641 );
4642 }
4643
4644 #[test]
4645 fn test_resolve_autosave_durability_mode_precedence() {
4646 assert_eq!(
4647 resolve_autosave_durability_mode(Some("strict"), Some("throughput"), Some("balanced")),
4648 AutosaveDurabilityMode::Strict
4649 );
4650 assert_eq!(
4651 resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
4652 AutosaveDurabilityMode::Throughput
4653 );
4654 assert_eq!(
4655 resolve_autosave_durability_mode(None, None, Some("strict")),
4656 AutosaveDurabilityMode::Strict
4657 );
4658 assert_eq!(
4659 resolve_autosave_durability_mode(None, None, None),
4660 AutosaveDurabilityMode::Balanced
4661 );
4662 }
4663
4664 #[test]
4665 fn test_resolve_autosave_durability_mode_ignores_invalid_values() {
4666 assert_eq!(
4667 resolve_autosave_durability_mode(Some("bad"), Some("throughput"), Some("strict")),
4668 AutosaveDurabilityMode::Throughput
4669 );
4670 assert_eq!(
4671 resolve_autosave_durability_mode(None, Some("bad"), Some("strict")),
4672 AutosaveDurabilityMode::Strict
4673 );
4674 assert_eq!(
4675 resolve_autosave_durability_mode(None, None, Some("bad")),
4676 AutosaveDurabilityMode::Balanced
4677 );
4678 }
4679
4680 #[test]
4681 fn test_get_share_viewer_url_matches_legacy() {
4682 assert_eq!(
4683 build_share_viewer_url(None, "gist-123"),
4684 "https://buildwithpi.ai/session/#gist-123"
4685 );
4686 assert_eq!(
4687 build_share_viewer_url(Some("https://example.com/session/"), "gist-123"),
4688 "https://example.com/session/#gist-123"
4689 );
4690 assert_eq!(
4691 build_share_viewer_url(Some("https://example.com/session"), "gist-123"),
4692 "https://example.com/session#gist-123"
4693 );
4694 assert_eq!(
4697 build_share_viewer_url(Some(""), "gist-123"),
4698 "https://buildwithpi.ai/session/#gist-123"
4699 );
4700 }
4701
4702 #[test]
4703 fn test_session_linear_history() {
4704 let mut session = Session::in_memory();
4705
4706 let id1 = session.append_message(make_test_message("Hello"));
4707 let id2 = session.append_message(make_test_message("World"));
4708 let id3 = session.append_message(make_test_message("Test"));
4709
4710 assert_eq!(session.leaf_id.as_deref(), Some(id3.as_str()));
4712
4713 let path = session.get_path_to_entry(&id3);
4715 assert_eq!(path, vec![id1.as_str(), id2.as_str(), id3.as_str()]);
4716
4717 let leaves = session.list_leaves();
4719 assert_eq!(leaves.len(), 1);
4720 assert_eq!(leaves[0], id3);
4721 }
4722
4723 #[test]
4724 fn test_session_branching() {
4725 let mut session = Session::in_memory();
4726
4727 let id_a = session.append_message(make_test_message("A"));
4729 let id_b = session.append_message(make_test_message("B"));
4730 let id_c = session.append_message(make_test_message("C"));
4731
4732 assert!(session.create_branch_from(&id_b));
4734 let id_d = session.append_message(make_test_message("D"));
4735
4736 let leaves = session.list_leaves();
4738 assert_eq!(leaves.len(), 2);
4739 assert!(leaves.contains(&id_c));
4740 assert!(leaves.contains(&id_d));
4741
4742 let path_to_d = session.get_path_to_entry(&id_d);
4744 assert_eq!(path_to_d, vec![id_a.as_str(), id_b.as_str(), id_d.as_str()]);
4745
4746 let path_to_c = session.get_path_to_entry(&id_c);
4748 assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
4749 }
4750
4751 #[test]
4752 fn test_session_navigation() {
4753 let mut session = Session::in_memory();
4754
4755 let id1 = session.append_message(make_test_message("First"));
4756 let id2 = session.append_message(make_test_message("Second"));
4757
4758 assert!(session.navigate_to(&id1));
4760 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
4761
4762 assert!(!session.navigate_to("nonexistent"));
4764 assert_eq!(session.leaf_id.as_deref(), Some(id1.as_str()));
4766
4767 assert!(session.navigate_to(&id2));
4769 assert_eq!(session.leaf_id.as_deref(), Some(id2.as_str()));
4770 }
4771
4772 #[test]
4773 fn test_session_get_children() {
4774 let mut session = Session::in_memory();
4775
4776 let id_a = session.append_message(make_test_message("A"));
4779 let id_b = session.append_message(make_test_message("B"));
4780 let _id_c = session.append_message(make_test_message("C"));
4781
4782 session.create_branch_from(&id_a);
4784 let id_d = session.append_message(make_test_message("D"));
4785
4786 let children_a = session.get_children(Some(&id_a));
4788 assert_eq!(children_a.len(), 2);
4789 assert!(children_a.contains(&id_b));
4790 assert!(children_a.contains(&id_d));
4791
4792 let root_children = session.get_children(None);
4794 assert_eq!(root_children.len(), 1);
4795 assert_eq!(root_children[0], id_a);
4796 }
4797
4798 #[test]
4799 fn test_branch_summary() {
4800 let mut session = Session::in_memory();
4801
4802 let id_a = session.append_message(make_test_message("A"));
4804 let id_b = session.append_message(make_test_message("B"));
4805
4806 let info = session.branch_summary();
4807 assert_eq!(info.total_entries, 2);
4808 assert_eq!(info.leaf_count, 1);
4809 assert_eq!(info.branch_point_count, 0);
4810
4811 session.create_branch_from(&id_a);
4813 let _id_c = session.append_message(make_test_message("C"));
4814
4815 let info = session.branch_summary();
4816 assert_eq!(info.total_entries, 3);
4817 assert_eq!(info.leaf_count, 2);
4818 assert_eq!(info.branch_point_count, 1);
4819 assert!(info.branch_points.contains(&id_a));
4820 assert!(info.leaves.contains(&id_b));
4821 }
4822
4823 #[test]
4824 fn test_session_jsonl_serialization() {
4825 let temp = tempfile::tempdir().unwrap();
4826 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4827 session.header.provider = Some("anthropic".to_string());
4828 session.header.model_id = Some("claude-test".to_string());
4829 session.header.thinking_level = Some("medium".to_string());
4830
4831 let user_id = session.append_message(make_test_message("Hello"));
4832 let assistant = AssistantMessage {
4833 content: vec![ContentBlock::Text(TextContent::new("Hi!"))],
4834 api: "anthropic".to_string(),
4835 provider: "anthropic".to_string(),
4836 model: "claude-test".to_string(),
4837 usage: Usage::default(),
4838 stop_reason: StopReason::Stop,
4839 error_message: None,
4840 timestamp: 0,
4841 };
4842 session.append_message(SessionMessage::Assistant { message: assistant });
4843 session.append_model_change("anthropic".to_string(), "claude-test".to_string());
4844 session.append_thinking_level_change("high".to_string());
4845 session.append_compaction("summary".to_string(), user_id.clone(), 123, None, None);
4846 session.append_branch_summary(user_id, "branch".to_string(), None, None);
4847 session.append_session_info(Some("my-session".to_string()));
4848
4849 run_async(async { session.save().await }).unwrap();
4850
4851 let path = session.path.clone().unwrap();
4852 let contents = std::fs::read_to_string(path).unwrap();
4853 let mut lines = contents.lines();
4854
4855 let header: serde_json::Value = serde_json::from_str(lines.next().unwrap()).unwrap();
4856 assert_eq!(header["type"], "session");
4857 assert_eq!(header["version"], SESSION_VERSION);
4858
4859 let mut types = Vec::new();
4860 for line in lines {
4861 let value: serde_json::Value = serde_json::from_str(line).unwrap();
4862 let entry_type = value["type"].as_str().unwrap_or_default().to_string();
4863 types.push(entry_type);
4864 }
4865
4866 assert!(types.contains(&"message".to_string()));
4867 assert!(types.contains(&"model_change".to_string()));
4868 assert!(types.contains(&"thinking_level_change".to_string()));
4869 assert!(types.contains(&"compaction".to_string()));
4870 assert!(types.contains(&"branch_summary".to_string()));
4871 assert!(types.contains(&"session_info".to_string()));
4872 }
4873
4874 #[test]
4875 fn test_save_handles_short_or_empty_session_id() {
4876 let temp = tempfile::tempdir().unwrap();
4877
4878 let mut short_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4879 short_id_session.header.id = "x".to_string();
4880 run_async(async { short_id_session.save().await }).expect("save with short id");
4881 let short_name = short_id_session
4882 .path
4883 .as_ref()
4884 .and_then(|p| p.file_name())
4885 .and_then(|n| n.to_str())
4886 .expect("short id filename");
4887 assert!(short_name.contains("_x."));
4888
4889 let mut empty_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4890 empty_id_session.header.id.clear();
4891 run_async(async { empty_id_session.save().await }).expect("save with empty id");
4892 let empty_name = empty_id_session
4893 .path
4894 .as_ref()
4895 .and_then(|p| p.file_name())
4896 .and_then(|n| n.to_str())
4897 .expect("empty id filename");
4898 assert!(empty_name.contains("_session."));
4899
4900 let mut unsafe_id_session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4901 unsafe_id_session.header.id = "../etc/passwd".to_string();
4902 run_async(async { unsafe_id_session.save().await }).expect("save with unsafe id");
4903 let unsafe_path = unsafe_id_session.path.as_ref().expect("unsafe id path");
4904 let unsafe_name = unsafe_path
4905 .file_name()
4906 .and_then(|n| n.to_str())
4907 .expect("unsafe id filename");
4908 assert!(unsafe_name.contains("____etc_p."));
4909 let expected_dir = temp
4910 .path()
4911 .join(encode_cwd(&std::env::current_dir().unwrap()));
4912 assert_eq!(
4913 unsafe_path.parent().expect("unsafe id parent"),
4914 expected_dir.as_path()
4915 );
4916 }
4917
4918 #[test]
4919 fn test_open_with_diagnostics_skips_corrupted_last_entry_and_recovers_leaf() {
4920 let temp = tempfile::tempdir().unwrap();
4921 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4922
4923 let first_id = session.append_message(make_test_message("Hello"));
4924 let second_id = session.append_message(make_test_message("World"));
4925 assert_eq!(session.leaf_id.as_deref(), Some(second_id.as_str()));
4926
4927 run_async(async { session.save().await }).unwrap();
4928 let path = session.path.clone().expect("session path set");
4929
4930 let mut lines = std::fs::read_to_string(&path)
4931 .expect("read session")
4932 .lines()
4933 .map(str::to_string)
4934 .collect::<Vec<_>>();
4935 assert!(lines.len() >= 3, "expected header + 2 entries");
4936
4937 let corrupted_line_number = lines.len(); let last_index = lines.len() - 1;
4939 lines[last_index] = "{ this is not json }".to_string();
4940
4941 let corrupted_path = temp.path().join("corrupted.jsonl");
4942 std::fs::write(&corrupted_path, format!("{}\n", lines.join("\n")))
4943 .expect("write corrupted session");
4944
4945 let (loaded, diagnostics) = run_async(async {
4946 Session::open_with_diagnostics(corrupted_path.to_string_lossy().as_ref()).await
4947 })
4948 .expect("open corrupted session");
4949
4950 assert_eq!(diagnostics.skipped_entries.len(), 1);
4951 assert_eq!(
4952 diagnostics.skipped_entries[0].line_number,
4953 corrupted_line_number
4954 );
4955
4956 let warnings = diagnostics.warning_lines();
4957 assert_eq!(warnings.len(), 2, "expected per-line warning + summary");
4958 assert!(
4959 warnings[0].starts_with(&format!(
4960 "Warning: Skipping corrupted entry at line {corrupted_line_number} in session file:"
4961 )),
4962 "unexpected warning: {}",
4963 warnings[0]
4964 );
4965 assert_eq!(
4966 warnings[1],
4967 "Warning: Skipped 1 corrupted entries while loading session"
4968 );
4969
4970 assert_eq!(
4971 loaded.entries.len(),
4972 session.entries.len() - 1,
4973 "expected last entry to be dropped"
4974 );
4975 assert_eq!(loaded.leaf_id.as_deref(), Some(first_id.as_str()));
4976 }
4977
4978 #[test]
4979 fn test_save_and_open_round_trip_preserves_compaction_and_branch_summary() {
4980 let temp = tempfile::tempdir().unwrap();
4981 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
4982
4983 let root_id = session.append_message(make_test_message("Hello"));
4984 session.append_compaction("compacted".to_string(), root_id.clone(), 123, None, None);
4985 session.append_branch_summary(root_id, "branch summary".to_string(), None, None);
4986
4987 run_async(async { session.save().await }).unwrap();
4988 let path = session.path.clone().expect("session path set");
4989
4990 let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
4991 .expect("reopen session");
4992
4993 assert!(loaded.entries.iter().any(|entry| {
4994 matches!(entry, SessionEntry::Compaction(compaction) if compaction.summary == "compacted" && compaction.tokens_before == 123)
4995 }));
4996 assert!(loaded.entries.iter().any(|entry| {
4997 matches!(entry, SessionEntry::BranchSummary(summary) if summary.summary == "branch summary")
4998 }));
4999
5000 let html = loaded.to_html();
5001 assert!(html.contains("compacted"));
5002 assert!(html.contains("branch summary"));
5003 }
5004
5005 #[test]
5006 fn test_concurrent_saves_do_not_corrupt_session_file_unit() {
5007 let temp = tempfile::tempdir().unwrap();
5008 let base_dir = temp.path().join("sessions");
5009
5010 let mut session = Session::create_with_dir(Some(base_dir));
5011 session.append_message(make_test_message("Hello"));
5012
5013 run_async(async { session.save().await }).expect("initial save");
5014 let path = session.path.clone().expect("session path set");
5015
5016 let path1 = path.clone();
5017 let path2 = path.clone();
5018
5019 let t1 = std::thread::spawn(move || {
5020 let runtime = RuntimeBuilder::current_thread()
5021 .build()
5022 .expect("build runtime");
5023 runtime.block_on(async move {
5024 let mut s = Session::open(path1.to_string_lossy().as_ref())
5025 .await
5026 .expect("open session");
5027 s.append_message(make_test_message("From thread 1"));
5028 s.save().await
5029 })
5030 });
5031
5032 let t2 = std::thread::spawn(move || {
5033 let runtime = RuntimeBuilder::current_thread()
5034 .build()
5035 .expect("build runtime");
5036 runtime.block_on(async move {
5037 let mut s = Session::open(path2.to_string_lossy().as_ref())
5038 .await
5039 .expect("open session");
5040 s.append_message(make_test_message("From thread 2"));
5041 s.save().await
5042 })
5043 });
5044
5045 let r1 = t1.join().expect("thread 1 join");
5046 let r2 = t2.join().expect("thread 2 join");
5047 assert!(
5048 r1.is_ok() || r2.is_ok(),
5049 "Expected at least one save to succeed: r1={r1:?} r2={r2:?}"
5050 );
5051
5052 let loaded = run_async(async { Session::open(path.to_string_lossy().as_ref()).await })
5053 .expect("open after concurrent saves");
5054 assert!(!loaded.entries.is_empty());
5055 }
5056
5057 #[test]
5058 fn test_to_messages_for_current_path() {
5059 let mut session = Session::in_memory();
5060
5061 let _id_a = session.append_message(make_test_message("A"));
5065 let id_b = session.append_message(make_test_message("B"));
5066 let _id_c = session.append_message(make_test_message("C"));
5067
5068 session.create_branch_from(&id_b);
5070 let id_d = session.append_message(make_test_message("D"));
5071
5072 session.navigate_to(&id_d);
5074 let messages = session.to_messages_for_current_path();
5075 assert_eq!(messages.len(), 3);
5076
5077 if let Message::User(user) = &messages[0] {
5079 if let UserContent::Text(text) = &user.content {
5080 assert_eq!(text, "A");
5081 }
5082 }
5083 if let Message::User(user) = &messages[2] {
5084 if let UserContent::Text(text) = &user.content {
5085 assert_eq!(text, "D");
5086 }
5087 }
5088 }
5089
5090 #[test]
5091 fn test_reset_leaf_produces_empty_current_path() {
5092 let mut session = Session::in_memory();
5093
5094 let _id_a = session.append_message(make_test_message("A"));
5095 let _id_b = session.append_message(make_test_message("B"));
5096
5097 session.reset_leaf();
5098 assert!(session.entries_for_current_path().is_empty());
5099 assert!(session.to_messages_for_current_path().is_empty());
5100
5101 let id_root = session.append_message(make_test_message("Root"));
5103 let entry = session.get_entry(&id_root).expect("entry");
5104 assert!(entry.base().parent_id.is_none());
5105 }
5106
5107 #[test]
5108 fn test_encode_cwd() {
5109 let path = std::path::Path::new("/home/user/project");
5110 let encoded = encode_cwd(path);
5111 assert!(encoded.starts_with("--"));
5112 assert!(encoded.ends_with("--"));
5113 assert!(encoded.contains("home-user-project"));
5114 }
5115
5116 #[test]
5121 fn test_session_header_defaults() {
5122 let header = SessionHeader::new();
5123 assert_eq!(header.r#type, "session");
5124 assert_eq!(header.version, Some(SESSION_VERSION));
5125 assert!(!header.id.is_empty());
5126 assert!(!header.timestamp.is_empty());
5127 assert!(header.provider.is_none());
5128 assert!(header.model_id.is_none());
5129 assert!(header.thinking_level.is_none());
5130 assert!(header.parent_session.is_none());
5131 }
5132
5133 #[test]
5134 fn test_session_create_produces_unique_ids() {
5135 let s1 = Session::create();
5136 let s2 = Session::create();
5137 assert_ne!(s1.header.id, s2.header.id);
5138 }
5139
5140 #[test]
5141 fn test_in_memory_session_has_no_path() {
5142 let session = Session::in_memory();
5143 assert!(session.path.is_none());
5144 assert!(session.leaf_id.is_none());
5145 assert!(session.entries.is_empty());
5146 }
5147
5148 #[test]
5149 fn test_create_with_dir_stores_session_dir() {
5150 let temp = tempfile::tempdir().unwrap();
5151 let session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5152 assert_eq!(session.session_dir, Some(temp.path().to_path_buf()));
5153 }
5154
5155 #[test]
5160 fn test_append_tool_result_message() {
5161 let mut session = Session::in_memory();
5162 let user_id = session.append_message(make_test_message("Hello"));
5163
5164 let tool_msg = SessionMessage::ToolResult {
5165 tool_call_id: "call_123".to_string(),
5166 tool_name: "read".to_string(),
5167 content: vec![ContentBlock::Text(TextContent::new("file contents"))],
5168 details: None,
5169 is_error: false,
5170 timestamp: Some(1000),
5171 };
5172 let tool_id = session.append_message(tool_msg);
5173
5174 let entry = session.get_entry(&tool_id).unwrap();
5176 assert_eq!(entry.base().parent_id.as_deref(), Some(user_id.as_str()));
5177
5178 let messages = session.to_messages();
5180 assert_eq!(messages.len(), 2);
5181 assert!(matches!(&messages[1], Message::ToolResult(tr) if tr.tool_call_id == "call_123"));
5182 }
5183
5184 #[test]
5185 fn test_append_tool_result_error() {
5186 let mut session = Session::in_memory();
5187 session.append_message(make_test_message("Hello"));
5188
5189 let tool_msg = SessionMessage::ToolResult {
5190 tool_call_id: "call_err".to_string(),
5191 tool_name: "bash".to_string(),
5192 content: vec![ContentBlock::Text(TextContent::new("command not found"))],
5193 details: None,
5194 is_error: true,
5195 timestamp: Some(2000),
5196 };
5197 let tool_id = session.append_message(tool_msg);
5198
5199 let entry = session.get_entry(&tool_id).unwrap();
5200 if let SessionEntry::Message(msg) = entry {
5201 if let SessionMessage::ToolResult { is_error, .. } = &msg.message {
5202 assert!(is_error);
5203 } else {
5204 panic!("expected ToolResult");
5205 }
5206 }
5207 }
5208
5209 #[test]
5210 fn test_append_bash_execution() {
5211 let mut session = Session::in_memory();
5212 session.append_message(make_test_message("run something"));
5213
5214 let bash_id = session.append_bash_execution(
5215 "echo hello".to_string(),
5216 "hello\n".to_string(),
5217 0,
5218 false,
5219 false,
5220 None,
5221 );
5222
5223 let entry = session.get_entry(&bash_id).unwrap();
5224 if let SessionEntry::Message(msg) = entry {
5225 if let SessionMessage::BashExecution {
5226 command, exit_code, ..
5227 } = &msg.message
5228 {
5229 assert_eq!(command, "echo hello");
5230 assert_eq!(*exit_code, 0);
5231 } else {
5232 panic!("expected BashExecution");
5233 }
5234 }
5235
5236 let messages = session.to_messages();
5238 assert_eq!(messages.len(), 2);
5239 assert!(matches!(&messages[1], Message::User(_)));
5240 }
5241
5242 #[test]
5243 fn test_bash_execution_exclude_from_context() {
5244 let mut session = Session::in_memory();
5245 session.append_message(make_test_message("run something"));
5246
5247 let id = session.next_entry_id();
5248 let base = EntryBase::new(session.leaf_id.clone(), id.clone());
5249 let mut extra = HashMap::new();
5250 extra.insert("excludeFromContext".to_string(), serde_json::json!(true));
5251 let entry = SessionEntry::Message(MessageEntry {
5252 base,
5253 message: SessionMessage::BashExecution {
5254 command: "secret".to_string(),
5255 output: "hidden".to_string(),
5256 exit_code: 0,
5257 cancelled: None,
5258 truncated: None,
5259 full_output_path: None,
5260 timestamp: Some(0),
5261 extra,
5262 },
5263 });
5264 session.leaf_id = Some(id);
5265 session.entries.push(entry);
5266 session.entry_ids = entry_id_set(&session.entries);
5267
5268 let messages = session.to_messages();
5270 assert_eq!(messages.len(), 1); }
5272
5273 #[test]
5274 fn test_append_custom_message() {
5275 let mut session = Session::in_memory();
5276 session.append_message(make_test_message("Hello"));
5277
5278 let custom_msg = SessionMessage::Custom {
5279 custom_type: "extension_state".to_string(),
5280 content: "some state".to_string(),
5281 display: false,
5282 details: Some(serde_json::json!({"key": "value"})),
5283 timestamp: Some(0),
5284 };
5285 let custom_id = session.append_message(custom_msg);
5286
5287 let entry = session.get_entry(&custom_id).unwrap();
5288 if let SessionEntry::Message(msg) = entry {
5289 if let SessionMessage::Custom {
5290 custom_type,
5291 display,
5292 ..
5293 } = &msg.message
5294 {
5295 assert_eq!(custom_type, "extension_state");
5296 assert!(!display);
5297 } else {
5298 panic!("expected Custom");
5299 }
5300 }
5301 }
5302
5303 #[test]
5304 fn test_append_custom_entry() {
5305 let mut session = Session::in_memory();
5306 let root_id = session.append_message(make_test_message("Hello"));
5307
5308 let custom_id =
5309 session.append_custom_entry("my_type".to_string(), Some(serde_json::json!(42)));
5310
5311 let entry = session.get_entry(&custom_id).unwrap();
5312 if let SessionEntry::Custom(custom) = entry {
5313 assert_eq!(custom.custom_type, "my_type");
5314 assert_eq!(custom.data, Some(serde_json::json!(42)));
5315 assert_eq!(custom.base.parent_id.as_deref(), Some(root_id.as_str()));
5316 } else {
5317 panic!("expected Custom entry");
5318 }
5319 }
5320
5321 #[test]
5326 fn test_parent_linking_chain() {
5327 let mut session = Session::in_memory();
5328
5329 let id1 = session.append_message(make_test_message("A"));
5330 let id2 = session.append_message(make_test_message("B"));
5331 let id3 = session.append_message(make_test_message("C"));
5332
5333 let e1 = session.get_entry(&id1).unwrap();
5335 assert!(e1.base().parent_id.is_none());
5336
5337 let e2 = session.get_entry(&id2).unwrap();
5339 assert_eq!(e2.base().parent_id.as_deref(), Some(id1.as_str()));
5340
5341 let e3 = session.get_entry(&id3).unwrap();
5343 assert_eq!(e3.base().parent_id.as_deref(), Some(id2.as_str()));
5344 }
5345
5346 #[test]
5347 fn test_model_change_updates_leaf() {
5348 let mut session = Session::in_memory();
5349
5350 let msg_id = session.append_message(make_test_message("Hello"));
5351 let change_id = session.append_model_change("openai".to_string(), "gpt-4".to_string());
5352
5353 assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
5354
5355 let entry = session.get_entry(&change_id).unwrap();
5356 assert_eq!(entry.base().parent_id.as_deref(), Some(msg_id.as_str()));
5357
5358 if let SessionEntry::ModelChange(mc) = entry {
5359 assert_eq!(mc.provider, "openai");
5360 assert_eq!(mc.model_id, "gpt-4");
5361 } else {
5362 panic!("expected ModelChange");
5363 }
5364 }
5365
5366 #[test]
5367 fn test_thinking_level_change_updates_leaf() {
5368 let mut session = Session::in_memory();
5369 session.append_message(make_test_message("Hello"));
5370
5371 let change_id = session.append_thinking_level_change("high".to_string());
5372 assert_eq!(session.leaf_id.as_deref(), Some(change_id.as_str()));
5373
5374 let entry = session.get_entry(&change_id).unwrap();
5375 if let SessionEntry::ThinkingLevelChange(tlc) = entry {
5376 assert_eq!(tlc.thinking_level, "high");
5377 } else {
5378 panic!("expected ThinkingLevelChange");
5379 }
5380 }
5381
5382 #[test]
5387 fn test_get_name_returns_latest() {
5388 let mut session = Session::in_memory();
5389
5390 assert!(session.get_name().is_none());
5391
5392 session.set_name("first");
5393 assert_eq!(session.get_name().as_deref(), Some("first"));
5394
5395 session.set_name("second");
5396 assert_eq!(session.get_name().as_deref(), Some("second"));
5397 }
5398
5399 #[test]
5400 fn test_set_name_returns_entry_id() {
5401 let mut session = Session::in_memory();
5402 let id = session.set_name("test-name");
5403 assert!(!id.is_empty());
5404 let entry = session.get_entry(&id).unwrap();
5405 assert!(matches!(entry, SessionEntry::SessionInfo(_)));
5406 }
5407
5408 #[test]
5413 fn test_add_label_to_existing_entry() {
5414 let mut session = Session::in_memory();
5415 let msg_id = session.append_message(make_test_message("Hello"));
5416
5417 let label_id = session.add_label(&msg_id, Some("important".to_string()));
5418 assert!(label_id.is_some());
5419
5420 let entry = session.get_entry(&label_id.unwrap()).unwrap();
5421 if let SessionEntry::Label(label) = entry {
5422 assert_eq!(label.target_id, msg_id);
5423 assert_eq!(label.label.as_deref(), Some("important"));
5424 } else {
5425 panic!("expected Label entry");
5426 }
5427 }
5428
5429 #[test]
5430 fn test_add_label_to_nonexistent_entry_returns_none() {
5431 let mut session = Session::in_memory();
5432 let result = session.add_label("nonexistent", Some("label".to_string()));
5433 assert!(result.is_none());
5434 }
5435
5436 #[test]
5441 fn test_round_trip_preserves_all_message_types() {
5442 let temp = tempfile::tempdir().unwrap();
5443 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5444
5445 session.append_message(make_test_message("user text"));
5447
5448 let assistant = AssistantMessage {
5449 content: vec![ContentBlock::Text(TextContent::new("response"))],
5450 api: "anthropic".to_string(),
5451 provider: "anthropic".to_string(),
5452 model: "claude-test".to_string(),
5453 usage: Usage::default(),
5454 stop_reason: StopReason::Stop,
5455 error_message: None,
5456 timestamp: 0,
5457 };
5458 session.append_message(SessionMessage::Assistant { message: assistant });
5459
5460 session.append_message(SessionMessage::ToolResult {
5461 tool_call_id: "call_1".to_string(),
5462 tool_name: "read".to_string(),
5463 content: vec![ContentBlock::Text(TextContent::new("result"))],
5464 details: None,
5465 is_error: false,
5466 timestamp: Some(100),
5467 });
5468
5469 session.append_bash_execution("ls".to_string(), "files".to_string(), 0, false, false, None);
5470
5471 session.append_custom_entry(
5472 "ext_data".to_string(),
5473 Some(serde_json::json!({"foo": "bar"})),
5474 );
5475
5476 run_async(async { session.save().await }).unwrap();
5477 let path = session.path.clone().unwrap();
5478
5479 let loaded =
5480 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5481
5482 assert_eq!(loaded.entries.len(), session.entries.len());
5483 assert_eq!(loaded.header.id, session.header.id);
5484 assert_eq!(loaded.header.version, Some(SESSION_VERSION));
5485
5486 let has_tool_result = loaded.entries.iter().any(|e| {
5488 matches!(
5489 e,
5490 SessionEntry::Message(m) if matches!(
5491 &m.message,
5492 SessionMessage::ToolResult { tool_name, .. } if tool_name == "read"
5493 )
5494 )
5495 });
5496 assert!(has_tool_result, "tool result should survive round-trip");
5497
5498 let has_bash = loaded.entries.iter().any(|e| {
5499 matches!(
5500 e,
5501 SessionEntry::Message(m) if matches!(
5502 &m.message,
5503 SessionMessage::BashExecution { command, .. } if command == "ls"
5504 )
5505 )
5506 });
5507 assert!(has_bash, "bash execution should survive round-trip");
5508
5509 let has_custom = loaded.entries.iter().any(|e| {
5510 matches!(
5511 e,
5512 SessionEntry::Custom(c) if c.custom_type == "ext_data"
5513 )
5514 });
5515 assert!(has_custom, "custom entry should survive round-trip");
5516 }
5517
5518 #[test]
5519 fn test_round_trip_preserves_leaf_id() {
5520 let temp = tempfile::tempdir().unwrap();
5521 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5522
5523 let _id1 = session.append_message(make_test_message("A"));
5524 let id2 = session.append_message(make_test_message("B"));
5525
5526 run_async(async { session.save().await }).unwrap();
5527 let path = session.path.clone().unwrap();
5528
5529 let loaded =
5530 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5531
5532 assert_eq!(loaded.leaf_id.as_deref(), Some(id2.as_str()));
5533 }
5534
5535 #[test]
5536 fn test_round_trip_preserves_header_fields() {
5537 let temp = tempfile::tempdir().unwrap();
5538 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5539 session.header.provider = Some("anthropic".to_string());
5540 session.header.model_id = Some("claude-opus".to_string());
5541 session.header.thinking_level = Some("high".to_string());
5542 session.header.parent_session = Some("/old/session.jsonl".to_string());
5543
5544 session.append_message(make_test_message("Hello"));
5545 run_async(async { session.save().await }).unwrap();
5546 let path = session.path.clone().unwrap();
5547
5548 let loaded =
5549 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5550
5551 assert_eq!(loaded.header.provider.as_deref(), Some("anthropic"));
5552 assert_eq!(loaded.header.model_id.as_deref(), Some("claude-opus"));
5553 assert_eq!(loaded.header.thinking_level.as_deref(), Some("high"));
5554 assert_eq!(
5555 loaded.header.parent_session.as_deref(),
5556 Some("/old/session.jsonl")
5557 );
5558 }
5559
5560 #[test]
5561 fn test_empty_session_save_and_reload() {
5562 let temp = tempfile::tempdir().unwrap();
5563 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5564
5565 run_async(async { session.save().await }).unwrap();
5566 let path = session.path.clone().unwrap();
5567
5568 let loaded =
5569 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5570
5571 assert!(loaded.entries.is_empty());
5572 assert!(loaded.leaf_id.is_none());
5573 assert_eq!(loaded.header.id, session.header.id);
5574 }
5575
5576 #[test]
5581 fn test_corrupted_middle_entry_preserves_surrounding_entries() {
5582 let temp = tempfile::tempdir().unwrap();
5583 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5584
5585 let id1 = session.append_message(make_test_message("First"));
5586 let id2 = session.append_message(make_test_message("Second"));
5587 let id3 = session.append_message(make_test_message("Third"));
5588
5589 run_async(async { session.save().await }).unwrap();
5590 let path = session.path.clone().unwrap();
5591
5592 let mut lines: Vec<String> = std::fs::read_to_string(&path)
5594 .unwrap()
5595 .lines()
5596 .map(str::to_string)
5597 .collect();
5598 assert!(lines.len() >= 4);
5599 lines[2] = "GARBAGE JSON".to_string();
5600 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
5601
5602 let (loaded, diagnostics) = run_async(async {
5603 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5604 })
5605 .unwrap();
5606
5607 let diag = serde_json::json!({
5608 "fixture_id": "session-corrupted-middle-entry-replay-integrity",
5609 "path": path.display().to_string(),
5610 "seed": "deterministic-static",
5611 "env": {
5612 "os": std::env::consts::OS,
5613 "arch": std::env::consts::ARCH,
5614 },
5615 "expected": {
5616 "skipped_entries": 1,
5617 "orphaned_parent_links": 1,
5618 },
5619 "actual": {
5620 "skipped_entries": diagnostics.skipped_entries.len(),
5621 "orphaned_parent_links": diagnostics.orphaned_parent_links.len(),
5622 "leaf_id": loaded.leaf_id,
5623 },
5624 })
5625 .to_string();
5626
5627 assert_eq!(diagnostics.skipped_entries.len(), 1, "{diag}");
5628 assert_eq!(diagnostics.skipped_entries[0].line_number, 3, "{diag}");
5629 assert_eq!(diagnostics.orphaned_parent_links.len(), 1, "{diag}");
5630 assert_eq!(diagnostics.orphaned_parent_links[0].entry_id, id3, "{diag}");
5631 assert_eq!(
5632 diagnostics.orphaned_parent_links[0].missing_parent_id, id2,
5633 "{diag}"
5634 );
5635 assert!(
5636 diagnostics.warning_lines().iter().any(|line| {
5637 line.contains("references missing parent")
5638 && line.contains(diagnostics.orphaned_parent_links[0].entry_id.as_str())
5639 }),
5640 "{diag}"
5641 );
5642
5643 assert_eq!(loaded.entries.len(), 2, "{diag}");
5645 assert!(loaded.get_entry(&id1).is_some(), "{diag}");
5646 assert!(loaded.get_entry(&id3).is_some(), "{diag}");
5647 }
5648
5649 #[test]
5650 fn test_multiple_corrupted_entries_recovery() {
5651 let temp = tempfile::tempdir().unwrap();
5652 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5653
5654 session.append_message(make_test_message("A"));
5655 session.append_message(make_test_message("B"));
5656 session.append_message(make_test_message("C"));
5657 session.append_message(make_test_message("D"));
5658
5659 run_async(async { session.save().await }).unwrap();
5660 let path = session.path.clone().unwrap();
5661
5662 let mut lines: Vec<String> = std::fs::read_to_string(&path)
5663 .unwrap()
5664 .lines()
5665 .map(str::to_string)
5666 .collect();
5667 lines[2] = "BAD".to_string();
5669 lines[4] = "ALSO BAD".to_string();
5670 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
5671
5672 let (loaded, diagnostics) = run_async(async {
5673 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5674 })
5675 .unwrap();
5676
5677 assert_eq!(diagnostics.skipped_entries.len(), 2);
5678 assert_eq!(loaded.entries.len(), 2); }
5680
5681 #[test]
5682 fn test_corrupted_header_fails_to_open() {
5683 let temp = tempfile::tempdir().unwrap();
5684 let path = temp.path().join("bad_header.jsonl");
5685 std::fs::write(&path, "NOT A VALID HEADER\n{\"type\":\"message\"}\n").unwrap();
5686
5687 let result = run_async(async {
5688 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
5689 });
5690 assert!(
5691 result.is_err(),
5692 "corrupted header should cause open failure"
5693 );
5694 }
5695
5696 #[test]
5701 fn test_create_branch_from_nonexistent_returns_false() {
5702 let mut session = Session::in_memory();
5703 session.append_message(make_test_message("A"));
5704 assert!(!session.create_branch_from("nonexistent"));
5705 }
5706
5707 #[test]
5708 fn test_deep_branching() {
5709 let mut session = Session::in_memory();
5710
5711 let id_a = session.append_message(make_test_message("A"));
5713 let id_b = session.append_message(make_test_message("B"));
5714 let _id_c = session.append_message(make_test_message("C"));
5715
5716 session.create_branch_from(&id_a);
5718 let _id_d = session.append_message(make_test_message("D"));
5719
5720 session.create_branch_from(&id_b);
5722 let id_e = session.append_message(make_test_message("E"));
5723
5724 let leaves = session.list_leaves();
5726 assert_eq!(leaves.len(), 3);
5727
5728 let path = session.get_path_to_entry(&id_e);
5730 assert_eq!(path.len(), 3);
5731 assert_eq!(path[0], id_a);
5732 assert_eq!(path[1], id_b);
5733 assert_eq!(path[2], id_e);
5734 }
5735
5736 #[test]
5737 fn test_sibling_branches_at_fork() {
5738 let mut session = Session::in_memory();
5739
5740 let id_a = session.append_message(make_test_message("A"));
5742 let _id_b = session.append_message(make_test_message("B"));
5743 let _id_c = session.append_message(make_test_message("C"));
5744
5745 session.create_branch_from(&id_a);
5747 let id_d = session.append_message(make_test_message("D"));
5748
5749 session.navigate_to(&id_d);
5751
5752 let siblings = session.sibling_branches();
5753 assert!(siblings.is_some());
5754 let (fork_point, branches) = siblings.unwrap();
5755 assert!(fork_point.is_none() || fork_point.as_deref() == Some(id_a.as_str()));
5756 assert_eq!(branches.len(), 2);
5757
5758 let current_count = branches.iter().filter(|b| b.is_current).count();
5760 assert_eq!(current_count, 1);
5761 }
5762
5763 #[test]
5764 fn test_sibling_branches_no_fork() {
5765 let mut session = Session::in_memory();
5766 session.append_message(make_test_message("A"));
5767 session.append_message(make_test_message("B"));
5768
5769 assert!(session.sibling_branches().is_none());
5771 }
5772
5773 #[test]
5778 fn test_plan_fork_from_user_message() {
5779 let mut session = Session::in_memory();
5780
5781 let _id_a = session.append_message(make_test_message("First question"));
5782 let assistant = AssistantMessage {
5783 content: vec![ContentBlock::Text(TextContent::new("Answer"))],
5784 api: "anthropic".to_string(),
5785 provider: "anthropic".to_string(),
5786 model: "test".to_string(),
5787 usage: Usage::default(),
5788 stop_reason: StopReason::Stop,
5789 error_message: None,
5790 timestamp: 0,
5791 };
5792 let _id_b = session.append_message(SessionMessage::Assistant { message: assistant });
5793 let id_c = session.append_message(make_test_message("Second question"));
5794
5795 let plan = session.plan_fork_from_user_message(&id_c).unwrap();
5797 assert_eq!(plan.selected_text, "Second question");
5798 assert_eq!(plan.entries.len(), 2); }
5801
5802 #[test]
5803 fn test_plan_fork_from_root_message() {
5804 let mut session = Session::in_memory();
5805 let id_a = session.append_message(make_test_message("Root question"));
5806
5807 let plan = session.plan_fork_from_user_message(&id_a).unwrap();
5808 assert_eq!(plan.selected_text, "Root question");
5809 assert!(plan.entries.is_empty()); assert!(plan.leaf_id.is_none());
5811 }
5812
5813 #[test]
5814 fn test_plan_fork_from_nonexistent_fails() {
5815 let session = Session::in_memory();
5816 assert!(session.plan_fork_from_user_message("nonexistent").is_err());
5817 }
5818
5819 #[test]
5820 fn test_plan_fork_from_assistant_message_fails() {
5821 let mut session = Session::in_memory();
5822 session.append_message(make_test_message("Q"));
5823 let assistant = AssistantMessage {
5824 content: vec![ContentBlock::Text(TextContent::new("A"))],
5825 api: "anthropic".to_string(),
5826 provider: "anthropic".to_string(),
5827 model: "test".to_string(),
5828 usage: Usage::default(),
5829 stop_reason: StopReason::Stop,
5830 error_message: None,
5831 timestamp: 0,
5832 };
5833 let asst_id = session.append_message(SessionMessage::Assistant { message: assistant });
5834
5835 assert!(session.plan_fork_from_user_message(&asst_id).is_err());
5836 }
5837
5838 #[test]
5843 fn test_compaction_truncates_model_context() {
5844 let mut session = Session::in_memory();
5845
5846 let _id_a = session.append_message(make_test_message("old message A"));
5847 let _id_b = session.append_message(make_test_message("old message B"));
5848 let id_c = session.append_message(make_test_message("kept message C"));
5849
5850 session.append_compaction(
5852 "Summary of old messages".to_string(),
5853 id_c,
5854 5000,
5855 None,
5856 None,
5857 );
5858
5859 let id_d = session.append_message(make_test_message("new message D"));
5860
5861 session.navigate_to(&id_d);
5863
5864 let messages = session.to_messages_for_current_path();
5865 assert!(messages.len() <= 4); let all_text: String = messages
5871 .iter()
5872 .filter_map(|m| match m {
5873 Message::User(u) => match &u.content {
5874 UserContent::Text(t) => Some(t.clone()),
5875 UserContent::Blocks(blocks) => {
5876 let texts: Vec<String> = blocks
5877 .iter()
5878 .filter_map(|b| {
5879 if let ContentBlock::Text(t) = b {
5880 Some(t.text.clone())
5881 } else {
5882 None
5883 }
5884 })
5885 .collect();
5886 Some(texts.join(" "))
5887 }
5888 },
5889 _ => None,
5890 })
5891 .collect::<Vec<_>>()
5892 .join(" ");
5893
5894 assert!(
5895 !all_text.contains("old message A"),
5896 "compacted message A should not appear in context"
5897 );
5898 assert!(
5899 !all_text.contains("old message B"),
5900 "compacted message B should not appear in context"
5901 );
5902 assert!(
5903 all_text.contains("kept message C") || all_text.contains("new message D"),
5904 "kept messages should appear in context"
5905 );
5906 }
5907
5908 #[test]
5913 fn test_large_session_append_and_path() {
5914 let mut session = Session::in_memory();
5915
5916 let mut last_id = String::new();
5917 for i in 0..500 {
5918 last_id = session.append_message(make_test_message(&format!("msg-{i}")));
5919 }
5920
5921 assert_eq!(session.entries.len(), 500);
5922 assert_eq!(session.leaf_id.as_deref(), Some(last_id.as_str()));
5923
5924 let path = session.get_path_to_entry(&last_id);
5926 assert_eq!(path.len(), 500);
5927
5928 let current = session.entries_for_current_path();
5930 assert_eq!(current.len(), 500);
5931 }
5932
5933 #[test]
5934 fn test_large_session_save_and_reload() {
5935 let temp = tempfile::tempdir().unwrap();
5936 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
5937
5938 for i in 0..200 {
5939 session.append_message(make_test_message(&format!("message {i}")));
5940 }
5941
5942 run_async(async { session.save().await }).unwrap();
5943 let path = session.path.clone().unwrap();
5944
5945 let loaded =
5946 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
5947
5948 assert_eq!(loaded.entries.len(), 200);
5949 assert_eq!(loaded.header.id, session.header.id);
5950 }
5951
5952 #[test]
5957 fn test_ensure_entry_ids_fills_missing() {
5958 let mut entries = vec![
5959 SessionEntry::Message(MessageEntry {
5960 base: EntryBase {
5961 id: None,
5962 parent_id: None,
5963 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
5964 },
5965 message: SessionMessage::User {
5966 content: UserContent::Text("test".to_string()),
5967 timestamp: Some(0),
5968 },
5969 }),
5970 SessionEntry::Message(MessageEntry {
5971 base: EntryBase {
5972 id: Some("existing".to_string()),
5973 parent_id: None,
5974 timestamp: "2025-01-01T00:00:00.000Z".to_string(),
5975 },
5976 message: SessionMessage::User {
5977 content: UserContent::Text("test2".to_string()),
5978 timestamp: Some(0),
5979 },
5980 }),
5981 ];
5982
5983 ensure_entry_ids(&mut entries);
5984
5985 assert!(entries[0].base().id.is_some());
5987 assert_eq!(entries[1].base().id.as_deref(), Some("existing"));
5989 assert_ne!(entries[0].base().id, entries[1].base().id);
5991 }
5992
5993 #[test]
5994 fn test_generate_entry_id_produces_8_char_hex() {
5995 let existing = HashSet::new();
5996 let id = generate_entry_id(&existing);
5997 assert_eq!(id.len(), 8);
5998 assert!(id.chars().all(|c| c.is_ascii_hexdigit()));
5999 }
6000
6001 #[test]
6006 fn test_set_model_header() {
6007 let mut session = Session::in_memory();
6008 session.set_model_header(
6009 Some("anthropic".to_string()),
6010 Some("claude-opus".to_string()),
6011 Some("high".to_string()),
6012 );
6013 assert_eq!(session.header.provider.as_deref(), Some("anthropic"));
6014 assert_eq!(session.header.model_id.as_deref(), Some("claude-opus"));
6015 assert_eq!(session.header.thinking_level.as_deref(), Some("high"));
6016 }
6017
6018 #[test]
6019 fn test_set_branched_from() {
6020 let mut session = Session::in_memory();
6021 assert!(session.header.parent_session.is_none());
6022
6023 session.set_branched_from(Some("/path/to/parent.jsonl".to_string()));
6024 assert_eq!(
6025 session.header.parent_session.as_deref(),
6026 Some("/path/to/parent.jsonl")
6027 );
6028 }
6029
6030 #[test]
6035 fn test_to_html_contains_all_message_types() {
6036 let mut session = Session::in_memory();
6037
6038 session.append_message(make_test_message("user question"));
6039
6040 let assistant = AssistantMessage {
6041 content: vec![ContentBlock::Text(TextContent::new("assistant answer"))],
6042 api: "anthropic".to_string(),
6043 provider: "anthropic".to_string(),
6044 model: "test".to_string(),
6045 usage: Usage::default(),
6046 stop_reason: StopReason::Stop,
6047 error_message: None,
6048 timestamp: 0,
6049 };
6050 session.append_message(SessionMessage::Assistant { message: assistant });
6051 session.append_model_change("anthropic".to_string(), "claude-test".to_string());
6052 session.set_name("test-session-html");
6053
6054 let html = session.to_html();
6055 assert!(html.contains("<!doctype html>"));
6056 assert!(html.contains("user question"));
6057 assert!(html.contains("assistant answer"));
6058 assert!(html.contains("anthropic"));
6059 assert!(html.contains("test-session-html"));
6060 }
6061
6062 #[test]
6067 fn test_to_messages_includes_all_message_entries() {
6068 let mut session = Session::in_memory();
6069
6070 session.append_message(make_test_message("Q1"));
6071 let assistant = AssistantMessage {
6072 content: vec![ContentBlock::Text(TextContent::new("A1"))],
6073 api: "anthropic".to_string(),
6074 provider: "anthropic".to_string(),
6075 model: "test".to_string(),
6076 usage: Usage::default(),
6077 stop_reason: StopReason::Stop,
6078 error_message: None,
6079 timestamp: 0,
6080 };
6081 session.append_message(SessionMessage::Assistant { message: assistant });
6082 session.append_message(SessionMessage::ToolResult {
6083 tool_call_id: "c1".to_string(),
6084 tool_name: "edit".to_string(),
6085 content: vec![ContentBlock::Text(TextContent::new("edited"))],
6086 details: None,
6087 is_error: false,
6088 timestamp: Some(0),
6089 });
6090
6091 session.append_model_change("openai".to_string(), "gpt-4".to_string());
6093 session.append_session_info(Some("name".to_string()));
6094
6095 let messages = session.to_messages();
6096 assert_eq!(messages.len(), 3); }
6098
6099 #[test]
6104 fn test_jsonl_header_is_first_line() {
6105 let temp = tempfile::tempdir().unwrap();
6106 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6107 session.append_message(make_test_message("test"));
6108
6109 run_async(async { session.save().await }).unwrap();
6110 let path = session.path.clone().unwrap();
6111
6112 let contents = std::fs::read_to_string(path).unwrap();
6113 let first_line = contents.lines().next().unwrap();
6114 let header: serde_json::Value = serde_json::from_str(first_line).unwrap();
6115
6116 assert_eq!(header["type"], "session");
6117 assert_eq!(header["version"], SESSION_VERSION);
6118 assert!(!header["id"].as_str().unwrap().is_empty());
6119 assert!(!header["timestamp"].as_str().unwrap().is_empty());
6120 }
6121
6122 #[test]
6123 fn test_jsonl_entries_have_camelcase_fields() {
6124 let temp = tempfile::tempdir().unwrap();
6125 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6126
6127 session.append_message(make_test_message("test"));
6128 session.append_model_change("provider".to_string(), "model".to_string());
6129
6130 run_async(async { session.save().await }).unwrap();
6131 let path = session.path.clone().unwrap();
6132
6133 let contents = std::fs::read_to_string(path).unwrap();
6134 let lines: Vec<&str> = contents.lines().collect();
6135
6136 let msg_value: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
6138 assert!(msg_value.get("parentId").is_some() || msg_value.get("id").is_some());
6139
6140 let mc_value: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
6142 assert!(mc_value.get("modelId").is_some());
6143 }
6144
6145 #[test]
6150 fn test_open_nonexistent_file_returns_error() {
6151 let result =
6152 run_async(async { Session::open("/tmp/nonexistent_session_12345.jsonl").await });
6153 assert!(result.is_err());
6154 }
6155
6156 #[test]
6157 fn test_open_empty_file_returns_error() {
6158 let temp = tempfile::tempdir().unwrap();
6159 let path = temp.path().join("empty.jsonl");
6160 std::fs::write(&path, "").unwrap();
6161
6162 let result = run_async(async { Session::open(path.to_string_lossy().as_ref()).await });
6163 assert!(result.is_err());
6164 }
6165
6166 #[test]
6171 fn test_get_entry_returns_correct_entry() {
6172 let mut session = Session::in_memory();
6173 let id = session.append_message(make_test_message("Hello"));
6174
6175 let entry = session.get_entry(&id);
6176 assert!(entry.is_some());
6177 assert_eq!(entry.unwrap().base().id.as_deref(), Some(id.as_str()));
6178 }
6179
6180 #[test]
6181 fn test_get_entry_mut_allows_modification() {
6182 let mut session = Session::in_memory();
6183 let id = session.append_message(make_test_message("Original"));
6184
6185 let entry = session.get_entry_mut(&id).unwrap();
6186 if let SessionEntry::Message(msg) = entry {
6187 msg.message = SessionMessage::User {
6188 content: UserContent::Text("Modified".to_string()),
6189 timestamp: Some(0),
6190 };
6191 }
6192
6193 let entry = session.get_entry(&id).unwrap();
6195 if let SessionEntry::Message(msg) = entry {
6196 if let SessionMessage::User { content, .. } = &msg.message {
6197 match content {
6198 UserContent::Text(t) => assert_eq!(t, "Modified"),
6199 UserContent::Blocks(_) => panic!("expected Text content"),
6200 }
6201 } else {
6202 panic!("expected user message");
6203 }
6204 }
6205 }
6206
6207 #[test]
6208 fn test_get_entry_nonexistent_returns_none() {
6209 let session = Session::in_memory();
6210 assert!(session.get_entry("nonexistent").is_none());
6211 }
6212
6213 #[test]
6218 fn test_branching_round_trip_preserves_tree_structure() {
6219 let temp = tempfile::tempdir().unwrap();
6220 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6221
6222 let id_a = session.append_message(make_test_message("A"));
6224 let id_b = session.append_message(make_test_message("B"));
6225 let id_c = session.append_message(make_test_message("C"));
6226
6227 session.create_branch_from(&id_a);
6228 let id_d = session.append_message(make_test_message("D"));
6229
6230 let leaves = session.list_leaves();
6232 assert_eq!(leaves.len(), 2);
6233
6234 run_async(async { session.save().await }).unwrap();
6235 let path = session.path.clone().unwrap();
6236
6237 let loaded =
6238 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6239
6240 assert_eq!(loaded.entries.len(), 4);
6242 let loaded_leaves = loaded.list_leaves();
6243 assert_eq!(loaded_leaves.len(), 2);
6244 assert!(loaded_leaves.contains(&id_c));
6245 assert!(loaded_leaves.contains(&id_d));
6246
6247 let path_to_c = loaded.get_path_to_entry(&id_c);
6249 assert_eq!(path_to_c, vec![id_a.as_str(), id_b.as_str(), id_c.as_str()]);
6250
6251 let path_to_d = loaded.get_path_to_entry(&id_d);
6252 assert_eq!(path_to_d, vec![id_a.as_str(), id_d.as_str()]);
6253 }
6254
6255 #[test]
6260 fn test_encode_cwd_strips_leading_separators() {
6261 let path = std::path::Path::new("/home/user/my-project");
6262 let encoded = encode_cwd(path);
6263 assert_eq!(encoded, "--home-user-my-project--");
6264 assert!(!encoded.contains('/'));
6265 }
6266
6267 #[test]
6268 fn test_encode_cwd_handles_deeply_nested_path() {
6269 let path = std::path::Path::new("/a/b/c/d/e/f");
6270 let encoded = encode_cwd(path);
6271 assert_eq!(encoded, "--a-b-c-d-e-f--");
6272 }
6273
6274 #[test]
6275 fn test_save_creates_project_session_dir_from_cwd() {
6276 let temp = tempfile::tempdir().unwrap();
6277 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6278 session.append_message(make_test_message("test"));
6279
6280 run_async(async { session.save().await }).unwrap();
6281 let path = session.path.clone().unwrap();
6282
6283 let parent = path.parent().unwrap();
6285 let dir_name = parent.file_name().unwrap().to_string_lossy();
6286 assert!(
6287 dir_name.starts_with("--"),
6288 "session dir should start with --"
6289 );
6290 assert!(dir_name.ends_with("--"), "session dir should end with --");
6291
6292 assert_eq!(path.extension().unwrap(), "jsonl");
6294 }
6295
6296 #[test]
6297 fn test_can_reuse_known_entry_requires_matching_mtime_and_size() {
6298 let known_entry = SessionPickEntry {
6299 path: PathBuf::from("session.jsonl"),
6300 id: "session-id".to_string(),
6301 timestamp: "2026-01-01T00:00:00.000Z".to_string(),
6302 message_count: 4,
6303 name: Some("cached".to_string()),
6304 last_modified_ms: 1234,
6305 size_bytes: 4096,
6306 };
6307
6308 assert!(can_reuse_known_entry(&known_entry, 1234, 4096));
6309 assert!(!can_reuse_known_entry(&known_entry, 1235, 4096));
6310 assert!(!can_reuse_known_entry(&known_entry, 1234, 4097));
6311 }
6312
6313 #[test]
6314 fn test_scan_sessions_on_disk_ignores_stale_known_entry_when_size_mismatch() {
6315 let temp = tempfile::tempdir().unwrap();
6316 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6317 session.append_message(make_test_message("first"));
6318 session.append_message(make_test_message("second"));
6319
6320 run_async(async { session.save().await }).unwrap();
6321 let path = session.path.clone().expect("session path");
6322 let metadata = std::fs::metadata(&path).expect("session metadata");
6323 let disk_size = metadata.len();
6324 #[allow(clippy::cast_possible_truncation)]
6325 let disk_ms = metadata
6326 .modified()
6327 .unwrap_or(SystemTime::UNIX_EPOCH)
6328 .duration_since(UNIX_EPOCH)
6329 .unwrap_or_default()
6330 .as_millis() as i64;
6331
6332 let stale_known_entry = SessionPickEntry {
6333 path: path.clone(),
6334 id: session.header.id.clone(),
6335 timestamp: session.header.timestamp.clone(),
6336 message_count: 999,
6337 name: Some("stale".to_string()),
6338 last_modified_ms: disk_ms,
6339 size_bytes: disk_size.saturating_add(1),
6340 };
6341
6342 let session_dir = path.parent().expect("session parent").to_path_buf();
6343 let scanned =
6344 run_async(async { scan_sessions_on_disk(&session_dir, vec![stale_known_entry]).await })
6345 .expect("scan sessions");
6346 assert_eq!(scanned.len(), 1);
6347 assert_eq!(scanned[0].path, path);
6348 assert_eq!(scanned[0].message_count, 2);
6349 assert_eq!(scanned[0].size_bytes, disk_size);
6350 }
6351
6352 #[test]
6357 fn test_all_entries_corrupted_produces_empty_session() {
6358 let temp = tempfile::tempdir().unwrap();
6359 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6360 session.append_message(make_test_message("A"));
6361 session.append_message(make_test_message("B"));
6362
6363 run_async(async { session.save().await }).unwrap();
6364 let path = session.path.clone().unwrap();
6365
6366 let mut lines: Vec<String> = std::fs::read_to_string(&path)
6367 .unwrap()
6368 .lines()
6369 .map(str::to_string)
6370 .collect();
6371 for (i, line) in lines.iter_mut().enumerate().skip(1) {
6373 *line = format!("GARBAGE_{i}");
6374 }
6375 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
6376
6377 let (loaded, diagnostics) = run_async(async {
6378 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
6379 })
6380 .unwrap();
6381
6382 assert_eq!(diagnostics.skipped_entries.len(), 2);
6383 assert!(loaded.entries.is_empty());
6384 assert!(loaded.leaf_id.is_none());
6385 assert_eq!(loaded.header.id, session.header.id);
6387 }
6388
6389 #[test]
6394 fn test_unicode_content_round_trip() {
6395 let temp = tempfile::tempdir().unwrap();
6396 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6397
6398 let unicode_texts = [
6399 "Hello \u{1F600} World", "\u{4F60}\u{597D}", "\u{0410}\u{0411}\u{0412}", "caf\u{00E9}", "tab\there\nnewline", "\"quoted\" and \\escaped", ];
6406
6407 for text in &unicode_texts {
6408 session.append_message(make_test_message(text));
6409 }
6410
6411 run_async(async { session.save().await }).unwrap();
6412 let path = session.path.clone().unwrap();
6413
6414 let loaded =
6415 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6416
6417 assert_eq!(loaded.entries.len(), unicode_texts.len());
6418
6419 for (i, entry) in loaded.entries.iter().enumerate() {
6420 if let SessionEntry::Message(msg) = entry {
6421 if let SessionMessage::User { content, .. } = &msg.message {
6422 match content {
6423 UserContent::Text(t) => assert_eq!(t, unicode_texts[i]),
6424 UserContent::Blocks(_) => panic!("expected Text content at index {i}"),
6425 }
6426 }
6427 }
6428 }
6429 }
6430
6431 #[test]
6436 fn test_multiple_compactions_latest_wins() {
6437 let mut session = Session::in_memory();
6438
6439 let _id_a = session.append_message(make_test_message("old A"));
6440 let _id_b = session.append_message(make_test_message("old B"));
6441 let id_c = session.append_message(make_test_message("kept C"));
6442
6443 session.append_compaction("Summary 1".to_string(), id_c, 1000, None, None);
6445
6446 let _id_d = session.append_message(make_test_message("new D"));
6447 let id_e = session.append_message(make_test_message("new E"));
6448
6449 session.append_compaction("Summary 2".to_string(), id_e, 2000, None, None);
6451
6452 let id_f = session.append_message(make_test_message("newest F"));
6453
6454 session.navigate_to(&id_f);
6455 let messages = session.to_messages_for_current_path();
6456
6457 let all_text: String = messages
6459 .iter()
6460 .filter_map(|m| match m {
6461 Message::User(u) => match &u.content {
6462 UserContent::Text(t) => Some(t.clone()),
6463 UserContent::Blocks(_) => None,
6464 },
6465 _ => None,
6466 })
6467 .collect::<Vec<_>>()
6468 .join(" ");
6469
6470 assert!(!all_text.contains("old A"), "A should be compacted away");
6471 assert!(!all_text.contains("old B"), "B should be compacted away");
6472 }
6473
6474 #[test]
6479 fn test_session_with_only_metadata_entries() {
6480 let mut session = Session::in_memory();
6481
6482 session.append_model_change("anthropic".to_string(), "claude-opus".to_string());
6483 session.append_thinking_level_change("high".to_string());
6484 session.set_name("metadata-only");
6485
6486 let messages = session.to_messages();
6488 assert!(messages.is_empty());
6489
6490 let entries = session.entries_for_current_path();
6492 assert_eq!(entries.len(), 3);
6493 }
6494
6495 #[test]
6496 fn test_metadata_only_session_round_trip() {
6497 let temp = tempfile::tempdir().unwrap();
6498 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6499
6500 session.append_model_change("openai".to_string(), "gpt-4o".to_string());
6501 session.append_thinking_level_change("medium".to_string());
6502
6503 run_async(async { session.save().await }).unwrap();
6504 let path = session.path.clone().unwrap();
6505
6506 let loaded =
6507 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6508
6509 assert_eq!(loaded.entries.len(), 2);
6510 assert!(
6511 loaded
6512 .entries
6513 .iter()
6514 .any(|e| matches!(e, SessionEntry::ModelChange(_)))
6515 );
6516 assert!(
6517 loaded
6518 .entries
6519 .iter()
6520 .any(|e| matches!(e, SessionEntry::ThinkingLevelChange(_)))
6521 );
6522 }
6523
6524 #[test]
6529 fn test_session_name_survives_round_trip() {
6530 let temp = tempfile::tempdir().unwrap();
6531 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6532
6533 session.append_message(make_test_message("Hello"));
6534 session.set_name("my-important-session");
6535
6536 run_async(async { session.save().await }).unwrap();
6537 let path = session.path.clone().unwrap();
6538
6539 let loaded =
6540 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6541
6542 assert_eq!(loaded.get_name().as_deref(), Some("my-important-session"));
6543 }
6544
6545 #[test]
6550 fn test_trailing_whitespace_in_jsonl_ignored() {
6551 let temp = tempfile::tempdir().unwrap();
6552 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6553 session.append_message(make_test_message("test"));
6554
6555 run_async(async { session.save().await }).unwrap();
6556 let path = session.path.clone().unwrap();
6557
6558 let mut contents = std::fs::read_to_string(&path).unwrap();
6560 contents.push_str("\n\n\n");
6561 std::fs::write(&path, contents).unwrap();
6562
6563 let loaded =
6564 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6565
6566 assert_eq!(loaded.entries.len(), 1);
6567 }
6568
6569 #[test]
6574 fn test_branching_after_compaction() {
6575 let mut session = Session::in_memory();
6576
6577 let _id_a = session.append_message(make_test_message("old A"));
6578 let id_b = session.append_message(make_test_message("kept B"));
6579
6580 session.append_compaction("Compacted".to_string(), id_b.clone(), 500, None, None);
6581
6582 let id_c = session.append_message(make_test_message("C after compaction"));
6583
6584 session.create_branch_from(&id_b);
6586 let id_d = session.append_message(make_test_message("D branch after compaction"));
6587
6588 let leaves = session.list_leaves();
6589 assert_eq!(leaves.len(), 2);
6590 assert!(leaves.contains(&id_c));
6591 assert!(leaves.contains(&id_d));
6592 }
6593
6594 #[test]
6599 fn test_assistant_with_tool_calls_round_trip() {
6600 let temp = tempfile::tempdir().unwrap();
6601 let mut session = Session::create_with_dir(Some(temp.path().to_path_buf()));
6602
6603 session.append_message(make_test_message("read my file"));
6604
6605 let assistant = AssistantMessage {
6606 content: vec![
6607 ContentBlock::Text(TextContent::new("Let me read that for you.")),
6608 ContentBlock::ToolCall(crate::model::ToolCall {
6609 id: "call_abc".to_string(),
6610 name: "read".to_string(),
6611 arguments: serde_json::json!({"path": "src/main.rs"}),
6612 thought_signature: None,
6613 }),
6614 ],
6615 api: "anthropic".to_string(),
6616 provider: "anthropic".to_string(),
6617 model: "claude-test".to_string(),
6618 usage: Usage {
6619 input: 100,
6620 output: 50,
6621 cache_read: 0,
6622 cache_write: 0,
6623 total_tokens: 150,
6624 cost: Cost::default(),
6625 },
6626 stop_reason: StopReason::ToolUse,
6627 error_message: None,
6628 timestamp: 12345,
6629 };
6630 session.append_message(SessionMessage::Assistant { message: assistant });
6631
6632 session.append_message(SessionMessage::ToolResult {
6633 tool_call_id: "call_abc".to_string(),
6634 tool_name: "read".to_string(),
6635 content: vec![ContentBlock::Text(TextContent::new("fn main() {}"))],
6636 details: Some(serde_json::json!({"lines": 1, "truncated": false})),
6637 is_error: false,
6638 timestamp: Some(12346),
6639 });
6640
6641 run_async(async { session.save().await }).unwrap();
6642 let path = session.path.clone().unwrap();
6643
6644 let loaded =
6645 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
6646
6647 assert_eq!(loaded.entries.len(), 3);
6648
6649 let has_tool_call = loaded.entries.iter().any(|e| {
6651 if let SessionEntry::Message(msg) = e {
6652 if let SessionMessage::Assistant { message } = &msg.message {
6653 return message
6654 .content
6655 .iter()
6656 .any(|c| matches!(c, ContentBlock::ToolCall(tc) if tc.id == "call_abc"));
6657 }
6658 }
6659 false
6660 });
6661 assert!(has_tool_call, "tool call should survive round-trip");
6662
6663 let has_details = loaded.entries.iter().any(|e| {
6665 if let SessionEntry::Message(msg) = e {
6666 if let SessionMessage::ToolResult { details, .. } = &msg.message {
6667 return details.is_some();
6668 }
6669 }
6670 false
6671 });
6672 assert!(has_details, "tool result details should survive round-trip");
6673 }
6674
6675 mod proptest_session {
6680 use super::*;
6681 use proptest::prelude::*;
6682 use serde_json::json;
6683
6684 fn timestamp_strategy() -> impl Strategy<Value = String> {
6686 (
6687 2020u32..2030,
6688 1u32..13,
6689 1u32..29,
6690 0u32..24,
6691 0u32..60,
6692 0u32..60,
6693 )
6694 .prop_map(|(y, mo, d, h, mi, s)| {
6695 format!("{y:04}-{mo:02}-{d:02}T{h:02}:{mi:02}:{s:02}.000Z")
6696 })
6697 }
6698
6699 fn entry_id_strategy() -> impl Strategy<Value = String> {
6701 "[0-9a-f]{8}"
6702 }
6703
6704 fn bounded_json_value(max_depth: u32) -> BoxedStrategy<serde_json::Value> {
6706 if max_depth == 0 {
6707 prop_oneof![
6708 Just(json!(null)),
6709 any::<bool>().prop_map(|b| json!(b)),
6710 any::<i64>().prop_map(|n| json!(n)),
6711 "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
6712 ]
6713 .boxed()
6714 } else {
6715 prop_oneof![
6716 Just(json!(null)),
6717 any::<bool>().prop_map(|b| json!(b)),
6718 any::<i64>().prop_map(|n| json!(n)),
6719 "[a-zA-Z0-9 ]{0,32}".prop_map(|s| json!(s)),
6720 prop::collection::vec(bounded_json_value(max_depth - 1), 0..4)
6721 .prop_map(serde_json::Value::Array),
6722 ]
6723 .boxed()
6724 }
6725 }
6726
6727 #[allow(clippy::too_many_lines)]
6729 fn valid_session_entry_json() -> impl Strategy<Value = serde_json::Value> {
6730 let ts = timestamp_strategy();
6731 let eid = entry_id_strategy();
6732 let parent = prop::option::of(entry_id_strategy());
6733
6734 (ts, eid, parent, 0u8..8).prop_flat_map(|(ts, eid, parent, variant)| {
6735 let base = json!({
6736 "id": eid,
6737 "parentId": parent,
6738 "timestamp": ts,
6739 });
6740
6741 match variant {
6742 0 => {
6743 "[a-zA-Z0-9 ]{1,64}"
6745 .prop_map(move |text| {
6746 let mut v = base.clone();
6747 v["type"] = json!("message");
6748 v["message"] = json!({
6749 "role": "user",
6750 "content": text,
6751 });
6752 v
6753 })
6754 .boxed()
6755 }
6756 1 => {
6757 "[a-zA-Z0-9 ]{1,64}"
6759 .prop_map(move |text| {
6760 let mut v = base.clone();
6761 v["type"] = json!("message");
6762 v["message"] = json!({
6763 "role": "assistant",
6764 "content": [{"type": "text", "text": text}],
6765 "api": "anthropic",
6766 "provider": "anthropic",
6767 "model": "test-model",
6768 "usage": {
6769 "input": 10,
6770 "output": 5,
6771 "cacheRead": 0,
6772 "cacheWrite": 0,
6773 "totalTokens": 15,
6774 "cost": {"input": 0.0, "output": 0.0, "total": 0.0}
6775 },
6776 "stopReason": "end_turn",
6777 "timestamp": 12345,
6778 });
6779 v
6780 })
6781 .boxed()
6782 }
6783 2 => {
6784 ("[a-z]{3,8}", "[a-z0-9-]{5,20}")
6786 .prop_map(move |(provider, model)| {
6787 let mut v = base.clone();
6788 v["type"] = json!("model_change");
6789 v["provider"] = json!(provider);
6790 v["modelId"] = json!(model);
6791 v
6792 })
6793 .boxed()
6794 }
6795 3 => {
6796 prop_oneof![
6798 Just("off".to_string()),
6799 Just("low".to_string()),
6800 Just("medium".to_string()),
6801 Just("high".to_string()),
6802 ]
6803 .prop_map(move |level| {
6804 let mut v = base.clone();
6805 v["type"] = json!("thinking_level_change");
6806 v["thinkingLevel"] = json!(level);
6807 v
6808 })
6809 .boxed()
6810 }
6811 4 => {
6812 ("[a-zA-Z0-9 ]{1,32}", entry_id_strategy(), 100u64..100_000)
6814 .prop_map(move |(summary, kept_id, tokens)| {
6815 let mut v = base.clone();
6816 v["type"] = json!("compaction");
6817 v["summary"] = json!(summary);
6818 v["firstKeptEntryId"] = json!(kept_id);
6819 v["tokensBefore"] = json!(tokens);
6820 v
6821 })
6822 .boxed()
6823 }
6824 5 => {
6825 (entry_id_strategy(), prop::option::of("[a-zA-Z0-9 ]{1,16}"))
6827 .prop_map(move |(target, label)| {
6828 let mut v = base.clone();
6829 v["type"] = json!("label");
6830 v["targetId"] = json!(target);
6831 if let Some(l) = label {
6832 v["label"] = json!(l);
6833 }
6834 v
6835 })
6836 .boxed()
6837 }
6838 6 => {
6839 prop::option::of("[a-zA-Z0-9 ]{1,32}")
6841 .prop_map(move |name| {
6842 let mut v = base.clone();
6843 v["type"] = json!("session_info");
6844 if let Some(n) = name {
6845 v["name"] = json!(n);
6846 }
6847 v
6848 })
6849 .boxed()
6850 }
6851 _ => {
6852 ("[a-z_]{3,12}", bounded_json_value(2))
6854 .prop_map(move |(custom_type, data)| {
6855 let mut v = base.clone();
6856 v["type"] = json!("custom");
6857 v["customType"] = json!(custom_type);
6858 v["data"] = data;
6859 v
6860 })
6861 .boxed()
6862 }
6863 }
6864 })
6865 }
6866
6867 fn corrupted_entry_json() -> impl Strategy<Value = String> {
6869 prop_oneof![
6870 Just(r#"{"id":"aaaaaaaa","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6872 Just(r#"{"type":"unknown_type","id":"bbbbbbbb","timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6874 Just(r"{}".to_string()),
6876 Just(r"[1,2,3]".to_string()),
6878 Just(r"42".to_string()),
6880 Just(r#""just a string""#.to_string()),
6881 Just(r"null".to_string()),
6882 Just(r"true".to_string()),
6883 Just(r#"{"type":"message","id":"cccccccc","timestamp":"2024-01-01T"#.to_string()),
6885 Just(r#"{"type":"message","id":12345,"timestamp":"2024-01-01T00:00:00.000Z"}"#.to_string()),
6887 ]
6888 }
6889
6890 fn build_jsonl(header: &str, entry_lines: &[String]) -> String {
6892 let mut lines = vec![header.to_string()];
6893 lines.extend(entry_lines.iter().cloned());
6894 lines.join("\n")
6895 }
6896
6897 proptest! {
6901 #![proptest_config(ProptestConfig {
6902 cases: 256,
6903 max_shrink_iters: 200,
6904 .. ProptestConfig::default()
6905 })]
6906
6907 #[test]
6908 fn session_entry_deser_never_panics(
6909 entry_json in valid_session_entry_json()
6910 ) {
6911 let json_str = entry_json.to_string();
6912 let _ = serde_json::from_str::<SessionEntry>(&json_str);
6914 }
6915 }
6916
6917 proptest! {
6921 #![proptest_config(ProptestConfig {
6922 cases: 256,
6923 max_shrink_iters: 200,
6924 .. ProptestConfig::default()
6925 })]
6926
6927 #[test]
6928 fn corrupted_entry_deser_never_panics(
6929 line in corrupted_entry_json()
6930 ) {
6931 let _ = serde_json::from_str::<SessionEntry>(&line);
6932 }
6933
6934 #[test]
6935 fn arbitrary_bytes_deser_never_panics(
6936 raw in prop::collection::vec(any::<u8>(), 0..512)
6937 ) {
6938 if let Ok(s) = String::from_utf8(raw) {
6940 let _ = serde_json::from_str::<SessionEntry>(&s);
6941 }
6942 }
6943 }
6944
6945 proptest! {
6949 #![proptest_config(ProptestConfig {
6950 cases: 256,
6951 max_shrink_iters: 200,
6952 .. ProptestConfig::default()
6953 })]
6954
6955 #[test]
6956 fn valid_entry_round_trip(
6957 entry_json in valid_session_entry_json()
6958 ) {
6959 let json_str = entry_json.to_string();
6960 if let Ok(entry) = serde_json::from_str::<SessionEntry>(&json_str) {
6961 let reserialized = serde_json::to_string(&entry).unwrap();
6963 let re_entry = serde_json::from_str::<SessionEntry>(&reserialized).unwrap();
6965 assert_eq!(entry.base_id(), re_entry.base_id());
6967 assert_eq!(
6969 std::mem::discriminant(&entry),
6970 std::mem::discriminant(&re_entry)
6971 );
6972 }
6973 }
6974 }
6975
6976 proptest! {
6981 #![proptest_config(ProptestConfig {
6982 cases: 128,
6983 max_shrink_iters: 100,
6984 .. ProptestConfig::default()
6985 })]
6986
6987 #[test]
6988 fn jsonl_corrupted_recovery(
6989 valid_entries in prop::collection::vec(valid_session_entry_json(), 1..8),
6990 corrupted_lines in prop::collection::vec(corrupted_entry_json(), 0..5),
6991 interleave_seed in any::<u64>(),
6992 ) {
6993 let header_json = json!({
6994 "type": "session",
6995 "version": 3,
6996 "id": "testid01",
6997 "timestamp": "2024-01-01T00:00:00.000Z",
6998 "cwd": "/tmp/test"
6999 }).to_string();
7000
7001 let valid_strs: Vec<String> = valid_entries.iter().map(ToString::to_string).collect();
7003 let total = valid_strs.len() + corrupted_lines.len();
7004 let mut all_lines: Vec<(bool, String)> = Vec::with_capacity(total);
7005 for s in &valid_strs {
7006 all_lines.push((true, s.clone()));
7007 }
7008 for s in &corrupted_lines {
7009 all_lines.push((false, s.clone()));
7010 }
7011
7012 let mut seed = interleave_seed;
7014 for i in (1..all_lines.len()).rev() {
7015 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
7016 let j = (seed >> 33) as usize % (i + 1);
7017 all_lines.swap(i, j);
7018 }
7019
7020 let entry_lines: Vec<String> = all_lines.iter().map(|(_, s)| s.clone()).collect();
7021 let content = build_jsonl(&header_json, &entry_lines);
7022
7023 let temp_dir = tempfile::tempdir().unwrap();
7025 let file_path = temp_dir.path().join("test_session.jsonl");
7026 std::fs::write(&file_path, &content).unwrap();
7027
7028 let (session, diagnostics) = run_async(async {
7029 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7030 }).unwrap();
7031
7032 let total_parsed = session.entries.len();
7034 assert_eq!(
7035 total_parsed + diagnostics.skipped_entries.len(),
7036 total,
7037 "parsed ({total_parsed}) + skipped ({}) should equal total lines ({total})",
7038 diagnostics.skipped_entries.len()
7039 );
7040 }
7041 }
7042
7043 proptest! {
7047 #![proptest_config(ProptestConfig {
7048 cases: 128,
7049 max_shrink_iters: 100,
7050 .. ProptestConfig::default()
7051 })]
7052
7053 #[test]
7054 fn orphaned_parent_links_detected(
7055 n_entries in 2usize..10,
7056 orphan_idx in 0usize..8,
7057 ) {
7058 let orphan_idx = orphan_idx % n_entries;
7059 let header_json = json!({
7060 "type": "session",
7061 "version": 3,
7062 "id": "testid01",
7063 "timestamp": "2024-01-01T00:00:00.000Z",
7064 "cwd": "/tmp/test"
7065 }).to_string();
7066
7067 let mut entry_lines = Vec::new();
7068 let mut prev_id: Option<String> = None;
7069
7070 for i in 0..n_entries {
7071 let eid = format!("{i:08x}");
7072 let parent = if i == orphan_idx {
7073 Some("deadbeef".to_string())
7075 } else {
7076 prev_id.clone()
7077 };
7078
7079 let entry = json!({
7080 "type": "message",
7081 "id": eid,
7082 "parentId": parent,
7083 "timestamp": "2024-01-01T00:00:00.000Z",
7084 "message": {
7085 "role": "user",
7086 "content": format!("msg {i}"),
7087 }
7088 });
7089 entry_lines.push(entry.to_string());
7090 prev_id = Some(eid);
7091 }
7092
7093 let content = build_jsonl(&header_json, &entry_lines);
7094 let temp_dir = tempfile::tempdir().unwrap();
7095 let file_path = temp_dir.path().join("orphan_test.jsonl");
7096 std::fs::write(&file_path, &content).unwrap();
7097
7098 let (_session, diagnostics) = run_async(async {
7099 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7100 }).unwrap();
7101
7102 let has_orphan = diagnostics.orphaned_parent_links.iter().any(|o| {
7104 o.missing_parent_id == "deadbeef"
7105 });
7106 assert!(
7107 has_orphan,
7108 "orphaned parent link to 'deadbeef' should be detected"
7109 );
7110 }
7111 }
7112
7113 proptest! {
7117 #![proptest_config(ProptestConfig {
7118 cases: 128,
7119 max_shrink_iters: 100,
7120 .. ProptestConfig::default()
7121 })]
7122
7123 #[test]
7124 fn ensure_entry_ids_fills_gaps(
7125 n_total in 1usize..20,
7126 missing_mask in prop::collection::vec(any::<bool>(), 1..20),
7127 ) {
7128 let n = n_total.min(missing_mask.len());
7129 let mut entries: Vec<SessionEntry> = (0..n).map(|i| {
7130 let id = if missing_mask[i] {
7131 None
7132 } else {
7133 Some(format!("{i:08x}"))
7134 };
7135 SessionEntry::Message(MessageEntry {
7136 base: EntryBase {
7137 id,
7138 parent_id: None,
7139 timestamp: "2024-01-01T00:00:00.000Z".to_string(),
7140 },
7141 message: SessionMessage::User {
7142 content: UserContent::Text(format!("msg {i}")),
7143 timestamp: Some(0),
7144 },
7145 })
7146 }).collect();
7147
7148 ensure_entry_ids(&mut entries);
7149
7150 for entry in &entries {
7152 assert!(
7153 entry.base_id().is_some(),
7154 "all entries must have IDs after ensure_entry_ids"
7155 );
7156 }
7157
7158 let ids: Vec<&String> = entries.iter().filter_map(|e| e.base_id()).collect();
7160 let unique: std::collections::HashSet<&String> = ids.iter().copied().collect();
7161 assert_eq!(
7162 ids.len(),
7163 unique.len(),
7164 "all entry IDs must be unique"
7165 );
7166 }
7167 }
7168
7169 proptest! {
7173 #![proptest_config(ProptestConfig {
7174 cases: 256,
7175 max_shrink_iters: 200,
7176 .. ProptestConfig::default()
7177 })]
7178
7179 #[test]
7180 fn session_header_deser_never_panics(
7181 version in prop::option::of(0u8..255),
7182 id in "[a-zA-Z0-9-]{0,64}",
7183 ts in timestamp_strategy(),
7184 cwd in "(/[a-zA-Z0-9_]{1,8}){0,5}",
7185 provider in prop::option::of("[a-z]{2,10}"),
7186 model_id in prop::option::of("[a-z0-9-]{2,20}"),
7187 thinking_level in prop::option::of("[a-z]{2,8}"),
7188 ) {
7189 let mut obj = json!({
7190 "type": "session",
7191 "id": id,
7192 "timestamp": ts,
7193 "cwd": cwd,
7194 });
7195 if let Some(v) = version {
7196 obj["version"] = json!(v);
7197 }
7198 if let Some(p) = &provider {
7199 obj["provider"] = json!(p);
7200 }
7201 if let Some(m) = &model_id {
7202 obj["modelId"] = json!(m);
7203 }
7204 if let Some(t) = &thinking_level {
7205 obj["thinkingLevel"] = json!(t);
7206 }
7207 let json_str = obj.to_string();
7208 let _ = serde_json::from_str::<SessionHeader>(&json_str);
7209 }
7210 }
7211
7212 #[test]
7217 fn empty_file_returns_error() {
7218 let temp_dir = tempfile::tempdir().unwrap();
7219 let file_path = temp_dir.path().join("empty.jsonl");
7220 std::fs::write(&file_path, "").unwrap();
7221
7222 let result = run_async(async {
7223 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7224 });
7225 assert!(result.is_err(), "empty file should return error");
7226 }
7227
7228 #[test]
7229 fn header_only_file_produces_empty_session() {
7230 let header = json!({
7231 "type": "session",
7232 "version": 3,
7233 "id": "testid01",
7234 "timestamp": "2024-01-01T00:00:00.000Z",
7235 "cwd": "/tmp/test"
7236 })
7237 .to_string();
7238
7239 let temp_dir = tempfile::tempdir().unwrap();
7240 let file_path = temp_dir.path().join("header_only.jsonl");
7241 std::fs::write(&file_path, &header).unwrap();
7242
7243 let (session, diagnostics) = run_async(async {
7244 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7245 })
7246 .unwrap();
7247
7248 assert!(
7249 session.entries.is_empty(),
7250 "header-only file should have no entries"
7251 );
7252 assert!(diagnostics.skipped_entries.is_empty(), "no lines to skip");
7253 }
7254
7255 #[test]
7256 fn file_with_only_invalid_lines_has_diagnostics() {
7257 let header = json!({
7258 "type": "session",
7259 "version": 3,
7260 "id": "testid01",
7261 "timestamp": "2024-01-01T00:00:00.000Z",
7262 "cwd": "/tmp/test"
7263 })
7264 .to_string();
7265
7266 let content = format!(
7267 "{}\n{}\n{}\n{}",
7268 header,
7269 r#"{"bad":"json","no":"type"}"#,
7270 r"not json at all",
7271 r#"{"type":"nonexistent_type","id":"aaa","timestamp":"2024-01-01T00:00:00.000Z"}"#,
7272 );
7273
7274 let temp_dir = tempfile::tempdir().unwrap();
7275 let file_path = temp_dir.path().join("all_invalid.jsonl");
7276 std::fs::write(&file_path, &content).unwrap();
7277
7278 let (session, diagnostics) = run_async(async {
7279 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7280 })
7281 .unwrap();
7282
7283 assert!(
7284 session.entries.is_empty(),
7285 "all-invalid file should have no entries"
7286 );
7287 assert_eq!(
7288 diagnostics.skipped_entries.len(),
7289 3,
7290 "should have 3 skipped entries"
7291 );
7292 }
7293
7294 #[test]
7295 fn duplicate_entry_ids_are_loaded_without_panic() {
7296 let header = json!({
7297 "type": "session",
7298 "version": 3,
7299 "id": "testid01",
7300 "timestamp": "2024-01-01T00:00:00.000Z",
7301 "cwd": "/tmp/test"
7302 })
7303 .to_string();
7304
7305 let entry1 = json!({
7306 "type": "message",
7307 "id": "deadbeef",
7308 "timestamp": "2024-01-01T00:00:00.000Z",
7309 "message": {"role": "user", "content": "first"}
7310 })
7311 .to_string();
7312
7313 let entry2 = json!({
7314 "type": "message",
7315 "id": "deadbeef",
7316 "timestamp": "2024-01-01T00:00:01.000Z",
7317 "message": {"role": "user", "content": "second (duplicate id)"}
7318 })
7319 .to_string();
7320
7321 let content = format!("{header}\n{entry1}\n{entry2}");
7322
7323 let temp_dir = tempfile::tempdir().unwrap();
7324 let file_path = temp_dir.path().join("dup_ids.jsonl");
7325 std::fs::write(&file_path, &content).unwrap();
7326
7327 let (session, _diagnostics) = run_async(async {
7329 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7330 })
7331 .unwrap();
7332
7333 assert_eq!(session.entries.len(), 2, "both entries should be loaded");
7334 }
7335 }
7336
7337 #[test]
7342 fn test_incremental_append_writes_only_new_entries() {
7343 let temp_dir = tempfile::tempdir().expect("temp dir");
7344 let mut session = Session::create();
7345 session.session_dir = Some(temp_dir.path().to_path_buf());
7346
7347 session.append_message(make_test_message("msg A"));
7349 session.append_message(make_test_message("msg B"));
7350 run_async(async { session.save().await }).unwrap();
7351
7352 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7353 assert_eq!(session.appends_since_checkpoint, 0);
7354
7355 let path = session.path.clone().unwrap();
7356 let lines_after_first = std::fs::read_to_string(&path).unwrap().lines().count();
7357 assert_eq!(lines_after_first, 3);
7359
7360 session.append_message(make_test_message("msg C"));
7362 run_async(async { session.save().await }).unwrap();
7363
7364 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
7365 assert_eq!(session.appends_since_checkpoint, 1);
7366
7367 let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
7368 assert_eq!(lines_after_second, 4);
7370 }
7371
7372 #[test]
7373 fn test_header_change_forces_full_rewrite() {
7374 let temp_dir = tempfile::tempdir().expect("temp dir");
7375 let mut session = Session::create();
7376 session.session_dir = Some(temp_dir.path().to_path_buf());
7377
7378 session.append_message(make_test_message("msg A"));
7379 run_async(async { session.save().await }).unwrap();
7380 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7381 assert!(!session.header_dirty);
7382
7383 session.set_model_header(Some("new-provider".to_string()), None, None);
7385 assert!(session.header_dirty);
7386
7387 session.append_message(make_test_message("msg B"));
7388 run_async(async { session.save().await }).unwrap();
7389
7390 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7392 assert!(!session.header_dirty);
7393 assert_eq!(session.appends_since_checkpoint, 0);
7394
7395 let path = session.path.clone().unwrap();
7397 let first_line = std::fs::read_to_string(&path)
7398 .unwrap()
7399 .lines()
7400 .next()
7401 .unwrap()
7402 .to_string();
7403 let header: serde_json::Value = serde_json::from_str(&first_line).unwrap();
7404 assert_eq!(header["provider"], "new-provider");
7405 }
7406
7407 #[test]
7408 fn test_compaction_entry_uses_incremental_append() {
7409 let temp_dir = tempfile::tempdir().expect("temp dir");
7410 let mut session = Session::create();
7411 session.session_dir = Some(temp_dir.path().to_path_buf());
7412
7413 let id_a = session.append_message(make_test_message("msg A"));
7414 run_async(async { session.save().await }).unwrap();
7415 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7416
7417 session.append_compaction("summary".to_string(), id_a, 100, None, None);
7421 session.append_message(make_test_message("msg B"));
7422
7423 run_async(async { session.save().await }).unwrap();
7424
7425 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 3);
7427 assert_eq!(session.appends_since_checkpoint, 1);
7428
7429 let path = session.path.clone().unwrap();
7430 let lines_after_second = std::fs::read_to_string(&path).unwrap().lines().count();
7431 assert_eq!(lines_after_second, 4);
7433 }
7434
7435 #[test]
7436 fn test_checkpoint_interval_forces_full_rewrite() {
7437 let temp_dir = tempfile::tempdir().expect("temp dir");
7438 let mut session = Session::create();
7439 session.session_dir = Some(temp_dir.path().to_path_buf());
7440
7441 session.append_message(make_test_message("initial"));
7443 run_async(async { session.save().await }).unwrap();
7444
7445 let interval = compaction_checkpoint_interval();
7447 session.appends_since_checkpoint = interval;
7448
7449 session.append_message(make_test_message("triggers checkpoint"));
7451 run_async(async { session.save().await }).unwrap();
7452
7453 assert_eq!(session.appends_since_checkpoint, 0);
7455 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
7456 }
7457
7458 #[test]
7459 fn test_incremental_append_load_round_trip() {
7460 let temp_dir = tempfile::tempdir().expect("temp dir");
7461 let mut session = Session::create();
7462 session.session_dir = Some(temp_dir.path().to_path_buf());
7463
7464 session.append_message(make_test_message("msg A"));
7466 session.append_message(make_test_message("msg B"));
7467 run_async(async { session.save().await }).unwrap();
7468
7469 session.append_message(make_test_message("msg C"));
7471 run_async(async { session.save().await }).unwrap();
7472
7473 let path = session.path.clone().unwrap();
7474
7475 let loaded =
7477 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7478
7479 assert_eq!(loaded.entries.len(), 3);
7480 let texts: Vec<&str> = loaded
7482 .entries
7483 .iter()
7484 .filter_map(|e| match e {
7485 SessionEntry::Message(m) => match &m.message {
7486 SessionMessage::User {
7487 content: UserContent::Text(t),
7488 ..
7489 } => Some(t.as_str()),
7490 _ => None,
7491 },
7492 _ => None,
7493 })
7494 .collect();
7495 assert_eq!(texts, vec!["msg A", "msg B", "msg C"]);
7496 }
7497
7498 #[test]
7499 fn test_persisted_entry_count_set_on_open() {
7500 let temp_dir = tempfile::tempdir().expect("temp dir");
7501 let mut session = Session::create();
7502 session.session_dir = Some(temp_dir.path().to_path_buf());
7503
7504 session.append_message(make_test_message("msg A"));
7505 session.append_message(make_test_message("msg B"));
7506 session.append_message(make_test_message("msg C"));
7507 run_async(async { session.save().await }).unwrap();
7508
7509 let path = session.path.clone().unwrap();
7510 let loaded =
7511 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7512
7513 assert_eq!(loaded.persisted_entry_count.load(Ordering::SeqCst), 3);
7514 assert!(!loaded.header_dirty);
7515 assert_eq!(loaded.appends_since_checkpoint, 0);
7516 }
7517
7518 #[test]
7519 fn test_no_new_entries_is_noop() {
7520 let temp_dir = tempfile::tempdir().expect("temp dir");
7521 let mut session = Session::create();
7522 session.session_dir = Some(temp_dir.path().to_path_buf());
7523
7524 session.append_message(make_test_message("msg A"));
7525 run_async(async { session.save().await }).unwrap();
7526
7527 let path = session.path.clone().unwrap();
7528 let mtime_before = std::fs::metadata(&path).unwrap().modified().unwrap();
7529
7530 std::thread::sleep(std::time::Duration::from_millis(50));
7532
7533 run_async(async { session.save().await }).unwrap();
7535
7536 let mtime_after = std::fs::metadata(&path).unwrap().modified().unwrap();
7537 assert_eq!(
7538 mtime_before, mtime_after,
7539 "file should not be modified on no-op save"
7540 );
7541 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
7542 }
7543
7544 #[test]
7545 fn test_incremental_append_caches_stay_valid() {
7546 let temp_dir = tempfile::tempdir().expect("temp dir");
7547 let mut session = Session::create();
7548 session.session_dir = Some(temp_dir.path().to_path_buf());
7549
7550 session.append_message(make_test_message("msg A"));
7551 run_async(async { session.save().await }).unwrap();
7552
7553 assert_eq!(session.entry_index.len(), 1);
7555
7556 let id_b = session.append_message(make_test_message("msg B"));
7558 let id_c = session.append_message(make_test_message("msg C"));
7559 run_async(async { session.save().await }).unwrap();
7560
7561 assert_eq!(session.entry_index.len(), 3);
7563 assert!(session.entry_index.contains_key(&id_b));
7564 assert!(session.entry_index.contains_key(&id_c));
7565 assert_eq!(session.cached_message_count, 3);
7566 }
7567
7568 #[test]
7569 fn test_set_branched_from_marks_header_dirty() {
7570 let mut session = Session::create();
7571 assert!(!session.header_dirty);
7572
7573 session.set_branched_from(Some("/some/path".to_string()));
7574 assert!(session.header_dirty);
7575 }
7576
7577 fn build_crash_test_session_file(num_entries: usize) -> String {
7583 let header = serde_json::json!({
7584 "type": "session",
7585 "version": 3,
7586 "id": "crash-test",
7587 "timestamp": "2024-06-01T00:00:00.000Z",
7588 "cwd": "/tmp/test"
7589 });
7590 let mut lines = vec![serde_json::to_string(&header).unwrap()];
7591 for i in 0..num_entries {
7592 let entry = serde_json::json!({
7593 "type": "message",
7594 "id": format!("entry-{i}"),
7595 "timestamp": "2024-06-01T00:00:00.000Z",
7596 "message": {"role": "user", "content": format!("message {i}")}
7597 });
7598 lines.push(serde_json::to_string(&entry).unwrap());
7599 }
7600 lines.join("\n")
7601 }
7602
7603 #[test]
7604 fn crash_empty_file_returns_error() {
7605 let temp_dir = tempfile::tempdir().unwrap();
7606 let file_path = temp_dir.path().join("empty.jsonl");
7607 std::fs::write(&file_path, "").unwrap();
7608
7609 let result = run_async(async {
7610 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7611 });
7612 assert!(result.is_err(), "empty file should fail to open");
7613 }
7614
7615 #[test]
7616 fn crash_corrupted_header_returns_error() {
7617 let temp_dir = tempfile::tempdir().unwrap();
7618 let file_path = temp_dir.path().join("bad_header.jsonl");
7619 std::fs::write(&file_path, "NOT VALID JSON\n").unwrap();
7620
7621 let result = run_async(async {
7622 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7623 });
7624 assert!(result.is_err(), "corrupted header should fail");
7625 }
7626
7627 #[test]
7628 fn crash_header_only_loads_empty() {
7629 let temp_dir = tempfile::tempdir().unwrap();
7630 let file_path = temp_dir.path().join("header_only.jsonl");
7631 let header = serde_json::json!({
7632 "type": "session",
7633 "version": 3,
7634 "id": "hdr-only",
7635 "timestamp": "2024-06-01T00:00:00.000Z",
7636 "cwd": "/tmp/test"
7637 });
7638 std::fs::write(
7639 &file_path,
7640 format!("{}\n", serde_json::to_string(&header).unwrap()),
7641 )
7642 .unwrap();
7643
7644 let (session, diagnostics) = run_async(async {
7645 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7646 })
7647 .unwrap();
7648
7649 assert!(session.entries.is_empty());
7650 assert!(diagnostics.skipped_entries.is_empty());
7651 }
7652
7653 #[test]
7654 fn crash_truncated_last_entry_recovers_preceding() {
7655 let temp_dir = tempfile::tempdir().unwrap();
7656 let file_path = temp_dir.path().join("truncated.jsonl");
7657
7658 let mut content = build_crash_test_session_file(3);
7659 let truncation_point = content.rfind('\n').unwrap();
7660 content.truncate(truncation_point);
7661 content.push_str("\n{\"type\":\"message\",\"id\":\"partial");
7662
7663 std::fs::write(&file_path, &content).unwrap();
7664
7665 let (session, diagnostics) = run_async(async {
7666 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7667 })
7668 .unwrap();
7669
7670 assert_eq!(session.entries.len(), 2);
7671 assert_eq!(diagnostics.skipped_entries.len(), 1);
7672 }
7673
7674 #[test]
7675 fn crash_multiple_corrupted_entries_recovers_valid() {
7676 let temp_dir = tempfile::tempdir().unwrap();
7677 let file_path = temp_dir.path().join("multi_corrupt.jsonl");
7678
7679 let header = serde_json::json!({
7680 "type": "session",
7681 "version": 3,
7682 "id": "multi-corrupt",
7683 "timestamp": "2024-06-01T00:00:00.000Z",
7684 "cwd": "/tmp/test"
7685 });
7686
7687 let valid_entry = |id: &str, text: &str| {
7688 serde_json::json!({
7689 "type": "message",
7690 "id": id,
7691 "timestamp": "2024-06-01T00:00:00.000Z",
7692 "message": {"role": "user", "content": text}
7693 })
7694 .to_string()
7695 };
7696
7697 let lines = [
7698 serde_json::to_string(&header).unwrap(),
7699 valid_entry("v1", "first"),
7700 "GARBAGE LINE 1".to_string(),
7701 valid_entry("v2", "second"),
7702 "{incomplete json".to_string(),
7703 valid_entry("v3", "third"),
7704 ];
7705
7706 std::fs::write(&file_path, lines.join("\n")).unwrap();
7707
7708 let (session, diagnostics) = run_async(async {
7709 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7710 })
7711 .unwrap();
7712
7713 assert_eq!(session.entries.len(), 3, "3 valid entries survive");
7714 assert_eq!(diagnostics.skipped_entries.len(), 2);
7715 }
7716
7717 #[test]
7718 fn crash_incremental_append_survives_partial_write() {
7719 use std::io::Write;
7720
7721 let temp_dir = tempfile::tempdir().unwrap();
7722 let mut session = Session::create();
7723 session.session_dir = Some(temp_dir.path().to_path_buf());
7724
7725 session.append_message(make_test_message("msg A"));
7726 session.append_message(make_test_message("msg B"));
7727 run_async(async { session.save().await }).unwrap();
7728 let path = session.path.clone().unwrap();
7729
7730 let mut file = std::fs::OpenOptions::new()
7732 .append(true)
7733 .open(&path)
7734 .unwrap();
7735 write!(
7736 file,
7737 "\n{{\"type\":\"message\",\"id\":\"crash-entry\",\"timestamp\":\"2024-06-01"
7738 )
7739 .unwrap();
7740 drop(file);
7741
7742 let (loaded, diagnostics) = run_async(async {
7743 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7744 })
7745 .unwrap();
7746
7747 assert_eq!(loaded.entries.len(), 2, "original entries recovered");
7748 assert_eq!(diagnostics.skipped_entries.len(), 1);
7749 }
7750
7751 #[test]
7752 fn crash_full_rewrite_atomic_persist() {
7753 let temp_dir = tempfile::tempdir().unwrap();
7754 let mut session = Session::create();
7755 session.session_dir = Some(temp_dir.path().to_path_buf());
7756
7757 session.append_message(make_test_message("original"));
7758 run_async(async { session.save().await }).unwrap();
7759 let path = session.path.clone().unwrap();
7760
7761 let original_content = std::fs::read_to_string(&path).unwrap();
7762
7763 session.set_model_header(Some("new-provider".to_string()), None, None);
7764 session.append_message(make_test_message("second"));
7765 run_async(async { session.save().await }).unwrap();
7766
7767 let new_content = std::fs::read_to_string(&path).unwrap();
7768 assert_ne!(original_content, new_content);
7769
7770 let loaded =
7771 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7772 assert_eq!(loaded.entries.len(), 2);
7773 }
7774
7775 #[test]
7776 fn crash_flush_failure_restores_pending_mutations() {
7777 let mut queue = AutosaveQueue::with_limit(10);
7778
7779 queue.enqueue_mutation(AutosaveMutationKind::Message);
7780 queue.enqueue_mutation(AutosaveMutationKind::Message);
7781 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
7782 assert_eq!(queue.pending_mutations, 3);
7783
7784 let ticket = queue
7785 .begin_flush(AutosaveFlushTrigger::Periodic)
7786 .expect("should have ticket");
7787 assert_eq!(queue.pending_mutations, 0);
7788
7789 queue.finish_flush(ticket, false);
7790 assert_eq!(queue.pending_mutations, 3, "mutations restored");
7791 assert_eq!(queue.flush_failed, 1);
7792 }
7793
7794 #[test]
7795 fn crash_flush_failure_respects_queue_capacity() {
7796 let mut queue = AutosaveQueue::with_limit(3);
7797
7798 for _ in 0..3 {
7799 queue.enqueue_mutation(AutosaveMutationKind::Message);
7800 }
7801 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
7802
7803 queue.enqueue_mutation(AutosaveMutationKind::Message);
7804 queue.enqueue_mutation(AutosaveMutationKind::Message);
7805 assert_eq!(queue.pending_mutations, 2);
7806
7807 queue.finish_flush(ticket, false);
7808 assert_eq!(queue.pending_mutations, 3, "capped at max");
7809 assert!(queue.backpressure_events >= 2);
7810 }
7811
7812 #[test]
7813 fn crash_shutdown_strict_propagates_error() {
7814 let temp_dir = tempfile::tempdir().unwrap();
7815 let mut session = Session::create();
7816 session.path = Some(
7817 temp_dir
7818 .path()
7819 .join("nonexistent_dir")
7820 .join("session.jsonl"),
7821 );
7822 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Strict);
7823 session.append_message(make_test_message("must save"));
7824 session
7825 .autosave_queue
7826 .enqueue_mutation(AutosaveMutationKind::Message);
7827
7828 let result = run_async(async { session.flush_autosave_on_shutdown().await });
7829 assert!(result.is_err(), "strict mode propagates errors");
7830 }
7831
7832 #[test]
7833 fn crash_shutdown_balanced_swallows_error() {
7834 let temp_dir = tempfile::tempdir().unwrap();
7835 let mut session = Session::create();
7836 session.path = Some(
7837 temp_dir
7838 .path()
7839 .join("nonexistent_dir")
7840 .join("session.jsonl"),
7841 );
7842 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
7843 session.append_message(make_test_message("best effort"));
7844 session
7845 .autosave_queue
7846 .enqueue_mutation(AutosaveMutationKind::Message);
7847
7848 let result = run_async(async { session.flush_autosave_on_shutdown().await });
7849 assert!(result.is_ok(), "balanced mode swallows errors");
7850 }
7851
7852 #[test]
7853 fn crash_shutdown_throughput_skips_flush() {
7854 let temp_dir = tempfile::tempdir().unwrap();
7855 let mut session = Session::create();
7856 session.path = Some(
7857 temp_dir
7858 .path()
7859 .join("nonexistent_dir")
7860 .join("session.jsonl"),
7861 );
7862 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
7863 session.append_message(make_test_message("no flush"));
7864 session
7865 .autosave_queue
7866 .enqueue_mutation(AutosaveMutationKind::Message);
7867
7868 let result = run_async(async { session.flush_autosave_on_shutdown().await });
7869 assert!(result.is_ok());
7870 assert!(session.autosave_queue.pending_mutations > 0);
7871 }
7872
7873 #[test]
7874 fn crash_save_reload_preserves_all_entry_types() {
7875 let temp_dir = tempfile::tempdir().unwrap();
7876 let mut session = Session::create();
7877 session.session_dir = Some(temp_dir.path().to_path_buf());
7878
7879 let id_a = session.append_message(make_test_message("msg A"));
7880 session.append_model_change("provider-x".to_string(), "model-y".to_string());
7881 session.append_thinking_level_change("high".to_string());
7882 session.append_compaction("summary".to_string(), id_a, 500, None, None);
7883 session.append_message(make_test_message("msg B"));
7884
7885 run_async(async { session.save().await }).unwrap();
7886 let path = session.path.clone().unwrap();
7887
7888 let loaded =
7889 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7890 assert_eq!(loaded.entries.len(), session.entries.len());
7891 }
7892
7893 #[test]
7894 fn crash_checkpoint_rewrite_cleans_corruption() {
7895 let temp_dir = tempfile::tempdir().unwrap();
7896 let mut session = Session::create();
7897 session.session_dir = Some(temp_dir.path().to_path_buf());
7898
7899 session.append_message(make_test_message("initial"));
7900 run_async(async { session.save().await }).unwrap();
7901 let path = session.path.clone().unwrap();
7902
7903 for i in 0..5 {
7904 session.append_message(make_test_message(&format!("msg {i}")));
7905 run_async(async { session.save().await }).unwrap();
7906 }
7907
7908 let content = std::fs::read_to_string(&path).unwrap();
7910 let mut lines: Vec<String> = content.lines().map(String::from).collect();
7911 lines[3] = "CORRUPTED_ENTRY".to_string();
7912 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7913
7914 session.appends_since_checkpoint = compaction_checkpoint_interval();
7916 session.append_message(make_test_message("post checkpoint"));
7917 run_async(async { session.save().await }).unwrap();
7918 assert_eq!(session.appends_since_checkpoint, 0);
7919
7920 let (reloaded, diagnostics) = run_async(async {
7921 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7922 })
7923 .unwrap();
7924 assert!(diagnostics.skipped_entries.is_empty());
7925 assert_eq!(reloaded.entries.len(), 7);
7926 }
7927
7928 #[test]
7929 fn crash_trailing_newlines_loads_cleanly() {
7930 let temp_dir = tempfile::tempdir().unwrap();
7931 let file_path = temp_dir.path().join("trailing_nl.jsonl");
7932
7933 let mut content = build_crash_test_session_file(2);
7934 content.push_str("\n\n\n");
7935 std::fs::write(&file_path, &content).unwrap();
7936
7937 let (session, diagnostics) = run_async(async {
7938 Session::open_with_diagnostics(file_path.to_string_lossy().as_ref()).await
7939 })
7940 .unwrap();
7941
7942 assert_eq!(session.entries.len(), 2);
7943 assert!(diagnostics.skipped_entries.is_empty());
7944 }
7945
7946 #[test]
7947 fn crash_noop_save_after_reload_is_idempotent() {
7948 let temp_dir = tempfile::tempdir().unwrap();
7949 let mut session = Session::create();
7950 session.session_dir = Some(temp_dir.path().to_path_buf());
7951
7952 session.append_message(make_test_message("hello"));
7953 run_async(async { session.save().await }).unwrap();
7954 let path = session.path.clone().unwrap();
7955 let content_before = std::fs::read_to_string(&path).unwrap();
7956
7957 let mut loaded =
7958 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7959 run_async(async { loaded.save().await }).unwrap();
7960
7961 let content_after = std::fs::read_to_string(&path).unwrap();
7962 assert_eq!(content_before, content_after);
7963 }
7964
7965 #[test]
7966 fn crash_corrupt_then_continue_operation() {
7967 let temp_dir = tempfile::tempdir().unwrap();
7968 let mut session = Session::create();
7969 session.session_dir = Some(temp_dir.path().to_path_buf());
7970
7971 session.append_message(make_test_message("msg A"));
7972 session.append_message(make_test_message("msg B"));
7973 run_async(async { session.save().await }).unwrap();
7974 let path = session.path.clone().unwrap();
7975
7976 let content = std::fs::read_to_string(&path).unwrap();
7978 let mut lines: Vec<String> = content.lines().map(String::from).collect();
7979 *lines.last_mut().unwrap() = "BROKEN_JSON".to_string();
7980 std::fs::write(&path, format!("{}\n", lines.join("\n"))).unwrap();
7981
7982 let (mut recovered, diagnostics) = run_async(async {
7983 Session::open_with_diagnostics(path.to_string_lossy().as_ref()).await
7984 })
7985 .unwrap();
7986 assert_eq!(diagnostics.skipped_entries.len(), 1);
7987 assert_eq!(recovered.entries.len(), 1);
7988
7989 recovered.path = Some(path.clone());
7991 recovered.session_dir = Some(temp_dir.path().to_path_buf());
7992 recovered.append_message(make_test_message("msg C"));
7993 run_async(async { recovered.save().await }).unwrap();
7994
7995 let reloaded =
7996 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
7997 assert_eq!(reloaded.entries.len(), 2, "A and C present after recovery");
7998 }
7999
8000 #[test]
8001 fn crash_defensive_rewrite_when_persisted_exceeds_entries() {
8002 let temp_dir = tempfile::tempdir().unwrap();
8003 let mut session = Session::create();
8004 session.session_dir = Some(temp_dir.path().to_path_buf());
8005
8006 session.append_message(make_test_message("msg A"));
8007 run_async(async { session.save().await }).unwrap();
8008
8009 session.persisted_entry_count.store(999, Ordering::SeqCst);
8010 assert!(session.should_full_rewrite());
8011
8012 session.append_message(make_test_message("msg B"));
8013 run_async(async { session.save().await }).unwrap();
8014 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8015 assert_eq!(session.appends_since_checkpoint, 0);
8016 }
8017
8018 #[test]
8019 fn crash_persisted_count_unchanged_on_append_failure() {
8020 let temp_dir = tempfile::tempdir().unwrap();
8021 let mut session = Session::create();
8022 session.session_dir = Some(temp_dir.path().to_path_buf());
8023
8024 session.append_message(make_test_message("msg A"));
8025 run_async(async { session.save().await }).unwrap();
8026 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8027
8028 let path = session.path.clone().unwrap();
8029 session.append_message(make_test_message("msg B"));
8030
8031 #[cfg(unix)]
8032 {
8033 use std::os::unix::fs::PermissionsExt;
8034 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8035 }
8036 #[cfg(not(unix))]
8037 {
8038 return;
8039 }
8040
8041 let result = run_async(async { session.save().await });
8042
8043 #[cfg(unix)]
8044 {
8045 use std::os::unix::fs::PermissionsExt;
8046 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8047 }
8048
8049 assert!(result.is_err());
8050 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8051
8052 run_async(async { session.save().await }).unwrap();
8053 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8054 }
8055
8056 #[test]
8057 fn crash_queue_backpressure_at_limit() {
8058 let mut queue = AutosaveQueue::with_limit(3);
8059
8060 queue.enqueue_mutation(AutosaveMutationKind::Message);
8061 queue.enqueue_mutation(AutosaveMutationKind::Message);
8062 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8063 assert_eq!(queue.pending_mutations, 3);
8064
8065 queue.enqueue_mutation(AutosaveMutationKind::Label);
8066 assert_eq!(queue.pending_mutations, 3, "capped");
8067 assert_eq!(queue.backpressure_events, 1);
8068 }
8069
8070 #[test]
8071 fn crash_flush_failure_with_intervening_mutations() {
8072 let mut queue = AutosaveQueue::with_limit(8);
8073
8074 for _ in 0..4 {
8075 queue.enqueue_mutation(AutosaveMutationKind::Message);
8076 }
8077 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8078
8079 queue.enqueue_mutation(AutosaveMutationKind::Message);
8080 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8081 assert_eq!(queue.pending_mutations, 2);
8082
8083 queue.finish_flush(ticket, false);
8085 assert_eq!(queue.pending_mutations, 6);
8086 assert_eq!(queue.flush_failed, 1);
8087 }
8088
8089 #[test]
8090 fn crash_queue_metrics_snapshot() {
8091 let mut queue = AutosaveQueue::with_limit(5);
8092 queue.enqueue_mutation(AutosaveMutationKind::Message);
8093 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8094 queue.enqueue_mutation(AutosaveMutationKind::Label);
8095
8096 let metrics = queue.metrics();
8097 assert_eq!(metrics.pending_mutations, 3);
8098 assert_eq!(metrics.max_pending_mutations, 5);
8099 assert_eq!(metrics.coalesced_mutations, 2);
8100 assert_eq!(metrics.flush_started, 0);
8101 assert!(metrics.last_flush_duration_ms.is_none());
8102 }
8103
8104 #[test]
8105 fn crash_double_flush_is_noop() {
8106 let mut queue = AutosaveQueue::with_limit(10);
8107 queue.enqueue_mutation(AutosaveMutationKind::Message);
8108 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8109 queue.finish_flush(ticket, true);
8110
8111 assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
8112 }
8113
8114 #[test]
8115 fn crash_entries_survive_failed_full_rewrite() {
8116 let temp_dir = tempfile::tempdir().unwrap();
8119 let mut session = Session::create();
8120 session.session_dir = Some(temp_dir.path().to_path_buf());
8121
8122 session.append_message(make_test_message("msg A"));
8123 run_async(async { session.save().await }).unwrap();
8124 let path = session.path.clone().unwrap();
8125
8126 session.set_model_header(Some("new-provider".to_string()), None, None);
8127 session.append_message(make_test_message("msg B"));
8128
8129 #[cfg(unix)]
8130 {
8131 use std::os::unix::fs::PermissionsExt;
8132 let parent = path.parent().unwrap();
8133 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o555)).unwrap();
8134 }
8135 #[cfg(not(unix))]
8136 {
8137 return;
8138 }
8139
8140 let result = run_async(async { session.save().await });
8141 assert!(result.is_err());
8142
8143 assert_eq!(session.entries.len(), 2, "entries restored");
8144 assert_eq!(session.entry_index.len(), 2);
8145 assert!(session.header_dirty);
8146
8147 #[cfg(unix)]
8148 {
8149 use std::os::unix::fs::PermissionsExt;
8150 let parent = path.parent().unwrap();
8151 std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o755)).unwrap();
8152 }
8153
8154 run_async(async { session.save().await }).unwrap();
8155 assert!(!session.header_dirty);
8156 }
8157
8158 #[test]
8159 fn crash_metrics_accumulate_across_failure_recovery() {
8160 let temp_dir = tempfile::tempdir().unwrap();
8161 let mut session = Session::create();
8162 session.session_dir = Some(temp_dir.path().to_path_buf());
8163
8164 session.append_message(make_test_message("msg A"));
8165 run_async(async { session.save().await }).unwrap();
8166 let path = session.path.clone().unwrap();
8167
8168 let m = session.autosave_metrics();
8169 assert_eq!(m.flush_succeeded, 1);
8170 assert_eq!(m.flush_failed, 0);
8171
8172 #[cfg(unix)]
8173 {
8174 use std::os::unix::fs::PermissionsExt;
8175 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8176 }
8177 #[cfg(not(unix))]
8178 {
8179 return;
8180 }
8181
8182 session.append_message(make_test_message("msg B"));
8183 let _ = run_async(async { session.save().await });
8184
8185 let m = session.autosave_metrics();
8186 assert_eq!(m.flush_failed, 1);
8187 assert!(m.pending_mutations > 0);
8188
8189 #[cfg(unix)]
8190 {
8191 use std::os::unix::fs::PermissionsExt;
8192 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8193 }
8194 run_async(async { session.save().await }).unwrap();
8195
8196 let m = session.autosave_metrics();
8197 assert_eq!(m.flush_succeeded, 2);
8198 assert_eq!(m.flush_failed, 1);
8199 assert_eq!(m.pending_mutations, 0);
8200 assert_eq!(m.flush_started, 3);
8201 }
8202
8203 #[test]
8204 fn crash_many_sequential_appends_accumulate() {
8205 let temp_dir = tempfile::tempdir().unwrap();
8206 let mut session = Session::create();
8207 session.session_dir = Some(temp_dir.path().to_path_buf());
8208
8209 session.append_message(make_test_message("initial"));
8210 run_async(async { session.save().await }).unwrap();
8211
8212 for i in 0..10 {
8213 session.append_message(make_test_message(&format!("append-{i}")));
8214 run_async(async { session.save().await }).unwrap();
8215 }
8216
8217 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 11);
8218 assert_eq!(session.appends_since_checkpoint, 10);
8219
8220 let path = session.path.clone().unwrap();
8221 let line_count = std::fs::read_to_string(&path).unwrap().lines().count();
8222 assert_eq!(line_count, 12, "1 header + 11 entries");
8223
8224 let loaded =
8225 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8226 assert_eq!(loaded.entries.len(), 11);
8227 }
8228
8229 #[test]
8230 fn crash_load_unsaved_entry_absent() {
8231 let temp_dir = tempfile::tempdir().unwrap();
8232 let mut session = Session::create();
8233 session.session_dir = Some(temp_dir.path().to_path_buf());
8234
8235 session.append_message(make_test_message("saved A"));
8236 session.append_message(make_test_message("saved B"));
8237 run_async(async { session.save().await }).unwrap();
8238 let path = session.path.clone().unwrap();
8239
8240 session.append_message(make_test_message("unsaved C"));
8241 assert_eq!(session.entries.len(), 3);
8242
8243 let loaded =
8244 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8245 assert_eq!(loaded.entries.len(), 2, "unsaved entry absent");
8246 }
8247
8248 #[test]
8249 fn test_clone_has_independent_persisted_entry_count() {
8250 let session = Session::create();
8251 session.persisted_entry_count.store(10, Ordering::SeqCst);
8253
8254 let clone = session.clone();
8256
8257 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
8259
8260 session.persisted_entry_count.store(20, Ordering::SeqCst);
8262
8263 assert_eq!(clone.persisted_entry_count.load(Ordering::SeqCst), 10);
8265
8266 clone.persisted_entry_count.store(30, Ordering::SeqCst);
8268
8269 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 20);
8271 }
8272
8273 #[test]
8274 fn crash_append_retry_after_transient_failure() {
8275 let temp_dir = tempfile::tempdir().unwrap();
8276 let mut session = Session::create();
8277 session.session_dir = Some(temp_dir.path().to_path_buf());
8278
8279 session.append_message(make_test_message("msg A"));
8280 run_async(async { session.save().await }).unwrap();
8281 let path = session.path.clone().unwrap();
8282
8283 session.append_message(make_test_message("msg B"));
8284
8285 #[cfg(unix)]
8286 {
8287 use std::os::unix::fs::PermissionsExt;
8288 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o444)).unwrap();
8289 }
8290 #[cfg(not(unix))]
8291 {
8292 return;
8293 }
8294
8295 let result = run_async(async { session.save().await });
8296 assert!(result.is_err());
8297 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 1);
8298
8299 #[cfg(unix)]
8300 {
8301 use std::os::unix::fs::PermissionsExt;
8302 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
8303 }
8304
8305 run_async(async { session.save().await }).unwrap();
8306 assert_eq!(session.persisted_entry_count.load(Ordering::SeqCst), 2);
8307
8308 let loaded =
8309 run_async(async { Session::open(path.to_string_lossy().as_ref()).await }).unwrap();
8310 assert_eq!(loaded.entries.len(), 2);
8311 }
8312
8313 #[test]
8314 fn crash_durability_mode_parsing() {
8315 assert_eq!(
8316 AutosaveDurabilityMode::parse("strict"),
8317 Some(AutosaveDurabilityMode::Strict)
8318 );
8319 assert_eq!(
8320 AutosaveDurabilityMode::parse("BALANCED"),
8321 Some(AutosaveDurabilityMode::Balanced)
8322 );
8323 assert_eq!(
8324 AutosaveDurabilityMode::parse(" Throughput "),
8325 Some(AutosaveDurabilityMode::Throughput)
8326 );
8327 assert_eq!(AutosaveDurabilityMode::parse("invalid"), None);
8328 assert_eq!(AutosaveDurabilityMode::parse(""), None);
8329 }
8330
8331 #[test]
8332 fn crash_durability_resolve_precedence() {
8333 assert_eq!(
8334 resolve_autosave_durability_mode(Some("strict"), Some("balanced"), Some("throughput")),
8335 AutosaveDurabilityMode::Strict
8336 );
8337 assert_eq!(
8338 resolve_autosave_durability_mode(None, Some("throughput"), Some("strict")),
8339 AutosaveDurabilityMode::Throughput
8340 );
8341 assert_eq!(
8342 resolve_autosave_durability_mode(None, None, Some("strict")),
8343 AutosaveDurabilityMode::Strict
8344 );
8345 assert_eq!(
8346 resolve_autosave_durability_mode(None, None, None),
8347 AutosaveDurabilityMode::Balanced
8348 );
8349 }
8350
8351 #[test]
8358 fn autosave_queue_limit_one_accepts_single_mutation() {
8359 let mut queue = AutosaveQueue::with_limit(1);
8360 queue.enqueue_mutation(AutosaveMutationKind::Message);
8361 assert_eq!(queue.pending_mutations, 1);
8362 assert_eq!(queue.coalesced_mutations, 0);
8363 assert_eq!(queue.backpressure_events, 0);
8364 }
8365
8366 #[test]
8367 fn autosave_queue_limit_one_backpressures_second_mutation() {
8368 let mut queue = AutosaveQueue::with_limit(1);
8369 queue.enqueue_mutation(AutosaveMutationKind::Message);
8370 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8371 assert_eq!(queue.pending_mutations, 1, "capped at 1");
8372 assert_eq!(queue.backpressure_events, 1);
8373 assert_eq!(queue.coalesced_mutations, 1);
8374 }
8375
8376 #[test]
8377 fn autosave_queue_limit_one_flush_and_refill() {
8378 let mut queue = AutosaveQueue::with_limit(1);
8379 queue.enqueue_mutation(AutosaveMutationKind::Message);
8380
8381 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8382 assert_eq!(queue.pending_mutations, 0);
8383 assert_eq!(ticket.batch_size, 1);
8384 queue.finish_flush(ticket, true);
8385
8386 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8388 assert_eq!(queue.pending_mutations, 1);
8389 assert_eq!(queue.flush_succeeded, 1);
8390 }
8391
8392 #[test]
8395 fn autosave_queue_with_limit_zero_clamps_to_one() {
8396 let queue = AutosaveQueue::with_limit(0);
8397 assert_eq!(queue.max_pending_mutations, 1);
8398 }
8399
8400 #[test]
8403 fn autosave_queue_begin_flush_on_empty_returns_none() {
8404 let mut queue = AutosaveQueue::with_limit(10);
8405 assert!(queue.begin_flush(AutosaveFlushTrigger::Manual).is_none());
8406 assert_eq!(queue.flush_started, 0, "no flush attempt recorded");
8407 }
8408
8409 #[test]
8410 fn autosave_queue_metrics_on_fresh_queue() {
8411 let queue = AutosaveQueue::with_limit(256);
8412 let m = queue.metrics();
8413 assert_eq!(m.pending_mutations, 0);
8414 assert_eq!(m.max_pending_mutations, 256);
8415 assert_eq!(m.coalesced_mutations, 0);
8416 assert_eq!(m.backpressure_events, 0);
8417 assert_eq!(m.flush_started, 0);
8418 assert_eq!(m.flush_succeeded, 0);
8419 assert_eq!(m.flush_failed, 0);
8420 assert_eq!(m.last_flush_batch_size, 0);
8421 assert!(m.last_flush_duration_ms.is_none());
8422 assert!(m.last_flush_trigger.is_none());
8423 }
8424
8425 #[test]
8428 fn autosave_queue_all_mutation_kinds() {
8429 let mut queue = AutosaveQueue::with_limit(10);
8430 queue.enqueue_mutation(AutosaveMutationKind::Message);
8431 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8432 queue.enqueue_mutation(AutosaveMutationKind::Label);
8433 assert_eq!(queue.pending_mutations, 3);
8434 assert_eq!(queue.coalesced_mutations, 2);
8436 }
8437
8438 #[test]
8441 fn autosave_queue_consecutive_success_flushes() {
8442 let mut queue = AutosaveQueue::with_limit(5);
8443
8444 for round in 1..=3_u64 {
8445 queue.enqueue_mutation(AutosaveMutationKind::Message);
8446 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8447 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8448 queue.finish_flush(ticket, true);
8449 assert_eq!(queue.pending_mutations, 0);
8450 assert_eq!(queue.flush_succeeded, round);
8451 assert_eq!(queue.flush_started, round);
8452 assert_eq!(queue.last_flush_batch_size, 2);
8453 }
8454 assert_eq!(queue.flush_failed, 0);
8455 }
8456
8457 #[test]
8458 fn autosave_queue_alternating_success_failure() {
8459 let mut queue = AutosaveQueue::with_limit(10);
8460
8461 queue.enqueue_mutation(AutosaveMutationKind::Message);
8463 let t1 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8464 queue.finish_flush(t1, true);
8465 assert_eq!(queue.flush_succeeded, 1);
8466 assert_eq!(queue.flush_failed, 0);
8467 assert_eq!(queue.pending_mutations, 0);
8468
8469 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8471 queue.enqueue_mutation(AutosaveMutationKind::Label);
8472 let t2 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8473 queue.finish_flush(t2, false);
8474 assert_eq!(queue.flush_succeeded, 1);
8475 assert_eq!(queue.flush_failed, 1);
8476 assert_eq!(queue.pending_mutations, 2, "restored from failure");
8477
8478 let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
8480 assert_eq!(t3.batch_size, 2);
8481 queue.finish_flush(t3, true);
8482 assert_eq!(queue.flush_succeeded, 2);
8483 assert_eq!(queue.flush_failed, 1);
8484 assert_eq!(queue.pending_mutations, 0);
8485 assert_eq!(queue.flush_started, 3);
8486 }
8487
8488 #[test]
8491 fn autosave_queue_failure_drops_all_when_full() {
8492 let mut queue = AutosaveQueue::with_limit(3);
8493
8494 for _ in 0..3 {
8496 queue.enqueue_mutation(AutosaveMutationKind::Message);
8497 }
8498 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8499 assert_eq!(ticket.batch_size, 3);
8500 assert_eq!(queue.pending_mutations, 0);
8501
8502 for _ in 0..3 {
8504 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8505 }
8506 assert_eq!(queue.pending_mutations, 3);
8507
8508 let bp_before = queue.backpressure_events;
8510 queue.finish_flush(ticket, false);
8511 assert_eq!(queue.pending_mutations, 3, "capped at max");
8512 assert_eq!(queue.flush_failed, 1);
8513 assert_eq!(
8514 queue.backpressure_events,
8515 bp_before + 3,
8516 "dropped mutations counted as backpressure"
8517 );
8518 }
8519
8520 #[test]
8523 fn autosave_queue_tracks_trigger_across_flushes() {
8524 let mut queue = AutosaveQueue::with_limit(10);
8525
8526 queue.enqueue_mutation(AutosaveMutationKind::Message);
8528 let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8529 assert_eq!(t1.trigger, AutosaveFlushTrigger::Manual);
8530 queue.finish_flush(t1, true);
8531 assert_eq!(
8532 queue.metrics().last_flush_trigger,
8533 Some(AutosaveFlushTrigger::Manual)
8534 );
8535
8536 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8538 let t2 = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8539 queue.finish_flush(t2, true);
8540 assert_eq!(
8541 queue.metrics().last_flush_trigger,
8542 Some(AutosaveFlushTrigger::Periodic)
8543 );
8544
8545 queue.enqueue_mutation(AutosaveMutationKind::Label);
8547 let t3 = queue.begin_flush(AutosaveFlushTrigger::Shutdown).unwrap();
8548 queue.finish_flush(t3, true);
8549 assert_eq!(
8550 queue.metrics().last_flush_trigger,
8551 Some(AutosaveFlushTrigger::Shutdown)
8552 );
8553 }
8554
8555 #[test]
8558 fn autosave_queue_flush_records_duration() {
8559 let mut queue = AutosaveQueue::with_limit(10);
8560 queue.enqueue_mutation(AutosaveMutationKind::Message);
8561 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8562 queue.finish_flush(ticket, true);
8563 assert!(queue.metrics().last_flush_duration_ms.is_some());
8565 }
8566
8567 #[test]
8570 fn autosave_queue_rapid_single_mutation_flushes() {
8571 let mut queue = AutosaveQueue::with_limit(10);
8572 let rounds = 20;
8573
8574 for _ in 0..rounds {
8575 queue.enqueue_mutation(AutosaveMutationKind::Message);
8576 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8577 queue.finish_flush(ticket, true);
8578 }
8579
8580 let m = queue.metrics();
8581 assert_eq!(m.flush_started, rounds);
8582 assert_eq!(m.flush_succeeded, rounds);
8583 assert_eq!(m.flush_failed, 0);
8584 assert_eq!(m.pending_mutations, 0);
8585 assert_eq!(m.last_flush_batch_size, 1);
8586 }
8587
8588 #[test]
8591 fn autosave_queue_many_backpressure_events_accumulate() {
8592 let mut queue = AutosaveQueue::with_limit(1);
8593 let excess: u64 = 100;
8594
8595 for _ in 0..=excess {
8597 queue.enqueue_mutation(AutosaveMutationKind::Message);
8598 }
8599 assert_eq!(queue.pending_mutations, 1);
8600 assert_eq!(queue.backpressure_events, excess);
8601 }
8602
8603 #[test]
8606 fn autosave_durability_mode_as_str_roundtrip() {
8607 for mode in [
8608 AutosaveDurabilityMode::Strict,
8609 AutosaveDurabilityMode::Balanced,
8610 AutosaveDurabilityMode::Throughput,
8611 ] {
8612 let s = mode.as_str();
8613 let parsed = AutosaveDurabilityMode::parse(s);
8614 assert_eq!(parsed, Some(mode), "roundtrip failed for {s}");
8615 }
8616 }
8617
8618 #[test]
8621 fn autosave_durability_mode_shutdown_behavior_truth_table() {
8622 assert!(AutosaveDurabilityMode::Strict.should_flush_on_shutdown());
8623 assert!(!AutosaveDurabilityMode::Strict.best_effort_on_shutdown());
8624
8625 assert!(AutosaveDurabilityMode::Balanced.should_flush_on_shutdown());
8626 assert!(AutosaveDurabilityMode::Balanced.best_effort_on_shutdown());
8627
8628 assert!(!AutosaveDurabilityMode::Throughput.should_flush_on_shutdown());
8629 assert!(!AutosaveDurabilityMode::Throughput.best_effort_on_shutdown());
8630 }
8631
8632 #[test]
8635 fn autosave_durability_mode_parse_case_insensitive() {
8636 assert_eq!(
8637 AutosaveDurabilityMode::parse("STRICT"),
8638 Some(AutosaveDurabilityMode::Strict)
8639 );
8640 assert_eq!(
8641 AutosaveDurabilityMode::parse("Balanced"),
8642 Some(AutosaveDurabilityMode::Balanced)
8643 );
8644 assert_eq!(
8645 AutosaveDurabilityMode::parse("tHrOuGhPuT"),
8646 Some(AutosaveDurabilityMode::Throughput)
8647 );
8648 }
8649
8650 #[test]
8653 fn autosave_durability_mode_parse_trims_whitespace() {
8654 assert_eq!(
8655 AutosaveDurabilityMode::parse(" strict "),
8656 Some(AutosaveDurabilityMode::Strict)
8657 );
8658 assert_eq!(
8659 AutosaveDurabilityMode::parse("\tbalanced\n"),
8660 Some(AutosaveDurabilityMode::Balanced)
8661 );
8662 }
8663
8664 #[test]
8667 fn autosave_session_save_on_empty_queue_is_noop() {
8668 let temp_dir = tempfile::tempdir().unwrap();
8669 let mut session = Session::create();
8670 session.session_dir = Some(temp_dir.path().to_path_buf());
8671
8672 let m_before = session.autosave_metrics();
8674 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Manual).await }).unwrap();
8675 let m_after = session.autosave_metrics();
8676
8677 assert_eq!(m_before.flush_started, m_after.flush_started);
8678 assert_eq!(m_after.pending_mutations, 0);
8679 }
8680
8681 #[test]
8684 fn autosave_session_mode_change_mid_session() {
8685 let mut session = Session::create();
8686 assert_eq!(
8687 session.autosave_durability_mode(),
8688 AutosaveDurabilityMode::Balanced,
8689 "default is balanced"
8690 );
8691
8692 session.set_autosave_durability_mode(AutosaveDurabilityMode::Strict);
8693 assert_eq!(
8694 session.autosave_durability_mode(),
8695 AutosaveDurabilityMode::Strict
8696 );
8697
8698 session.set_autosave_durability_mode(AutosaveDurabilityMode::Throughput);
8699 assert_eq!(
8700 session.autosave_durability_mode(),
8701 AutosaveDurabilityMode::Throughput
8702 );
8703 }
8704
8705 #[test]
8708 fn autosave_session_all_mutation_types_enqueue() {
8709 let mut session = Session::create();
8710
8711 let first_entry_id = session.append_message(make_test_message("msg"));
8712 assert_eq!(session.autosave_metrics().pending_mutations, 1);
8713
8714 session.append_model_change("prov".to_string(), "model".to_string());
8715 assert_eq!(session.autosave_metrics().pending_mutations, 2);
8716
8717 session.append_thinking_level_change("high".to_string());
8718 assert_eq!(session.autosave_metrics().pending_mutations, 3);
8719
8720 session.append_session_info(Some("test-session".to_string()));
8721 assert_eq!(session.autosave_metrics().pending_mutations, 4);
8722
8723 session.append_custom_entry("custom".to_string(), None);
8724 assert_eq!(session.autosave_metrics().pending_mutations, 5);
8725
8726 session.add_label(&first_entry_id, Some("test-label".to_string()));
8728 assert_eq!(session.autosave_metrics().pending_mutations, 6);
8729 }
8730
8731 #[test]
8734 fn autosave_session_manual_save_resets_pending() {
8735 let temp_dir = tempfile::tempdir().unwrap();
8736 let mut session = Session::create();
8737 session.session_dir = Some(temp_dir.path().to_path_buf());
8738
8739 session.append_message(make_test_message("a"));
8740 session.append_message(make_test_message("b"));
8741 session.append_message(make_test_message("c"));
8742 assert_eq!(session.autosave_metrics().pending_mutations, 3);
8743
8744 run_async(async { session.save().await }).unwrap();
8745
8746 let m = session.autosave_metrics();
8747 assert_eq!(m.pending_mutations, 0);
8748 assert_eq!(m.flush_succeeded, 1);
8749 assert_eq!(m.last_flush_batch_size, 3);
8750 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Manual));
8751 }
8752
8753 #[test]
8756 fn autosave_session_periodic_flush_tracks_trigger() {
8757 let temp_dir = tempfile::tempdir().unwrap();
8758 let mut session = Session::create();
8759 session.session_dir = Some(temp_dir.path().to_path_buf());
8760
8761 session.append_message(make_test_message("periodic msg"));
8762 run_async(async { session.flush_autosave(AutosaveFlushTrigger::Periodic).await }).unwrap();
8763
8764 let m = session.autosave_metrics();
8765 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Periodic));
8766 assert_eq!(m.flush_succeeded, 1);
8767 }
8768
8769 #[test]
8772 fn autosave_session_balanced_shutdown_succeeds_on_valid_path() {
8773 let temp_dir = tempfile::tempdir().unwrap();
8774 let mut session = Session::create();
8775 session.session_dir = Some(temp_dir.path().to_path_buf());
8776 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Balanced);
8777
8778 session.append_message(make_test_message("balanced ok"));
8779 run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
8780
8781 let m = session.autosave_metrics();
8782 assert_eq!(m.flush_succeeded, 1);
8783 assert_eq!(m.pending_mutations, 0);
8784 assert_eq!(m.last_flush_trigger, Some(AutosaveFlushTrigger::Shutdown));
8785 }
8786
8787 #[test]
8790 fn autosave_queue_failure_partial_restoration() {
8791 let mut queue = AutosaveQueue::with_limit(5);
8792
8793 for _ in 0..4 {
8795 queue.enqueue_mutation(AutosaveMutationKind::Message);
8796 }
8797 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8798 assert_eq!(ticket.batch_size, 4);
8799
8800 queue.enqueue_mutation(AutosaveMutationKind::Metadata);
8802 queue.enqueue_mutation(AutosaveMutationKind::Label);
8803 assert_eq!(queue.pending_mutations, 2);
8804
8805 let bp_before = queue.backpressure_events;
8807 let coal_before = queue.coalesced_mutations;
8808 queue.finish_flush(ticket, false);
8809 assert_eq!(queue.pending_mutations, 5, "2 new + 3 restored = 5");
8810 assert_eq!(queue.backpressure_events, bp_before + 1, "1 dropped");
8811 assert_eq!(
8812 queue.coalesced_mutations,
8813 coal_before + 1,
8814 "1 dropped coalesced"
8815 );
8816 }
8817
8818 #[test]
8821 fn autosave_queue_success_does_not_restore_pending() {
8822 let mut queue = AutosaveQueue::with_limit(10);
8823
8824 queue.enqueue_mutation(AutosaveMutationKind::Message);
8825 queue.enqueue_mutation(AutosaveMutationKind::Message);
8826 let ticket = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8827
8828 queue.enqueue_mutation(AutosaveMutationKind::Label);
8830 assert_eq!(queue.pending_mutations, 1);
8831
8832 queue.finish_flush(ticket, true);
8834 assert_eq!(queue.pending_mutations, 1, "only new mutation remains");
8835 assert_eq!(queue.flush_succeeded, 1);
8836 }
8837
8838 #[test]
8841 fn autosave_queue_large_batch_tracking() {
8842 let mut queue = AutosaveQueue::with_limit(500);
8843
8844 for _ in 0..200 {
8845 queue.enqueue_mutation(AutosaveMutationKind::Message);
8846 }
8847
8848 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8849 assert_eq!(ticket.batch_size, 200);
8850 queue.finish_flush(ticket, true);
8851
8852 let m = queue.metrics();
8853 assert_eq!(m.last_flush_batch_size, 200);
8854 assert_eq!(m.flush_succeeded, 1);
8855 assert_eq!(m.pending_mutations, 0);
8856 }
8857
8858 #[test]
8861 fn autosave_resolve_all_invalid_returns_balanced() {
8862 assert_eq!(
8863 resolve_autosave_durability_mode(Some("bad"), Some("worse"), Some("nope")),
8864 AutosaveDurabilityMode::Balanced
8865 );
8866 }
8867
8868 #[test]
8871 fn autosave_session_metrics_accumulate_over_many_cycles() {
8872 let temp_dir = tempfile::tempdir().unwrap();
8873 let mut session = Session::create();
8874 session.session_dir = Some(temp_dir.path().to_path_buf());
8875
8876 let cycles: u64 = 10;
8877 for i in 0..cycles {
8878 session.append_message(make_test_message(&format!("cycle-{i}")));
8879 run_async(async { session.save().await }).unwrap();
8880 }
8881
8882 let m = session.autosave_metrics();
8883 assert_eq!(m.flush_started, cycles);
8884 assert_eq!(m.flush_succeeded, cycles);
8885 assert_eq!(m.flush_failed, 0);
8886 assert_eq!(m.pending_mutations, 0);
8887 assert_eq!(m.last_flush_batch_size, 1);
8888 }
8889
8890 #[test]
8893 fn autosave_queue_coalesced_is_cumulative() {
8894 let mut queue = AutosaveQueue::with_limit(10);
8895
8896 queue.enqueue_mutation(AutosaveMutationKind::Message);
8898 queue.enqueue_mutation(AutosaveMutationKind::Message);
8899 queue.enqueue_mutation(AutosaveMutationKind::Message);
8900 assert_eq!(queue.coalesced_mutations, 2);
8901
8902 let t1 = queue.begin_flush(AutosaveFlushTrigger::Manual).unwrap();
8903 queue.finish_flush(t1, true);
8904
8905 queue.enqueue_mutation(AutosaveMutationKind::Label);
8907 queue.enqueue_mutation(AutosaveMutationKind::Label);
8908 assert_eq!(queue.coalesced_mutations, 3);
8909 }
8910
8911 #[test]
8914 fn autosave_session_respects_queue_limit() {
8915 let temp_dir = tempfile::tempdir().unwrap();
8916 let mut session = Session::create();
8917 session.session_dir = Some(temp_dir.path().to_path_buf());
8918 session.set_autosave_queue_limit_for_test(3);
8919
8920 for i in 0..10 {
8921 session.append_message(make_test_message(&format!("lim-{i}")));
8922 }
8923
8924 let m = session.autosave_metrics();
8925 assert_eq!(m.pending_mutations, 3);
8926 assert_eq!(m.max_pending_mutations, 3);
8927 assert_eq!(m.backpressure_events, 7);
8928
8929 run_async(async { session.save().await }).unwrap();
8931 let m = session.autosave_metrics();
8932 assert_eq!(m.last_flush_batch_size, 3);
8933 assert_eq!(m.pending_mutations, 0);
8934 }
8935
8936 #[test]
8939 fn autosave_session_throughput_shutdown_skips_after_manual_save() {
8940 let temp_dir = tempfile::tempdir().unwrap();
8941 let mut session = Session::create();
8942 session.session_dir = Some(temp_dir.path().to_path_buf());
8943 session.set_autosave_durability_for_test(AutosaveDurabilityMode::Throughput);
8944
8945 session.append_message(make_test_message("saved"));
8946 run_async(async { session.save().await }).unwrap();
8947 assert_eq!(session.autosave_metrics().flush_succeeded, 1);
8948
8949 session.append_message(make_test_message("unsaved"));
8951 assert_eq!(session.autosave_metrics().pending_mutations, 1);
8952
8953 run_async(async { session.flush_autosave_on_shutdown().await }).unwrap();
8955 assert_eq!(
8956 session.autosave_metrics().pending_mutations,
8957 1,
8958 "unsaved mutation remains"
8959 );
8960 assert_eq!(
8961 session.autosave_metrics().flush_succeeded,
8962 1,
8963 "no new flush"
8964 );
8965 }
8966
8967 #[test]
8970 fn autosave_queue_begin_flush_is_atomic_clear() {
8971 let mut queue = AutosaveQueue::with_limit(10);
8972
8973 queue.enqueue_mutation(AutosaveMutationKind::Message);
8974 queue.enqueue_mutation(AutosaveMutationKind::Message);
8975 queue.enqueue_mutation(AutosaveMutationKind::Message);
8976 assert_eq!(queue.pending_mutations, 3);
8977
8978 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
8979
8980 assert_eq!(queue.pending_mutations, 0);
8982 assert_eq!(ticket.batch_size, 3);
8983
8984 queue.enqueue_mutation(AutosaveMutationKind::Label);
8986 assert_eq!(queue.pending_mutations, 1);
8987
8988 queue.finish_flush(ticket, true);
8989 assert_eq!(queue.pending_mutations, 1, "new mutation preserved");
8990 }
8991
8992 #[test]
8995 fn autosave_queue_multiple_failures_accumulate() {
8996 let mut queue = AutosaveQueue::with_limit(10);
8997
8998 for round in 1..=5_u64 {
9003 queue.enqueue_mutation(AutosaveMutationKind::Message);
9004 #[allow(clippy::cast_possible_truncation)]
9005 let expected_batch = round as usize;
9006 let ticket = queue.begin_flush(AutosaveFlushTrigger::Periodic).unwrap();
9007 assert_eq!(ticket.batch_size, expected_batch);
9008 queue.finish_flush(ticket, false);
9009 assert_eq!(queue.flush_failed, round);
9010 assert_eq!(queue.pending_mutations, expected_batch, "restored batch");
9011 }
9012 assert_eq!(queue.flush_succeeded, 0);
9013 assert_eq!(queue.flush_started, 5);
9014 }
9015
9016 #[test]
9019 fn export_snapshot_captures_header_and_entries() {
9020 let mut session = Session::create();
9021 session.append_message(make_test_message("hello world"));
9022 session.append_message(make_test_message("second message"));
9023
9024 let snapshot = session.export_snapshot();
9025 assert_eq!(snapshot.header.id, session.header.id);
9026 assert_eq!(snapshot.header.timestamp, session.header.timestamp);
9027 assert_eq!(snapshot.header.cwd, session.header.cwd);
9028 assert_eq!(snapshot.entries.len(), session.entries.len());
9029 assert_eq!(snapshot.path, session.path);
9030 }
9031
9032 #[test]
9033 fn export_snapshot_does_not_include_internal_caches() {
9034 let mut session = Session::create();
9035 for i in 0..10 {
9036 session.append_message(make_test_message(&format!("msg {i}")));
9037 }
9038 let snapshot = session.export_snapshot();
9041 assert_eq!(snapshot.entries.len(), 10);
9042 assert_eq!(snapshot.header.id, session.header.id);
9044 }
9045
9046 #[test]
9047 fn export_snapshot_html_matches_session_html() {
9048 let mut session = Session::create();
9049 session.append_message(make_test_message("hello"));
9050 session.append_message(make_test_message("world"));
9051
9052 let session_html = session.to_html();
9053 let snapshot_html = session.export_snapshot().to_html();
9054 assert_eq!(session_html, snapshot_html);
9055 }
9056
9057 #[test]
9058 fn export_snapshot_empty_session() {
9059 let session = Session::create();
9060 let snapshot = session.export_snapshot();
9061 assert!(snapshot.entries.is_empty());
9062 let html = snapshot.to_html();
9063 assert!(html.contains("Pi Session"));
9064 assert!(html.contains("</html>"));
9065 }
9066
9067 #[test]
9068 fn render_session_html_contains_header_info() {
9069 let mut session = Session::create();
9070 session.header.id = "test-session-id-xyz".to_string();
9071 session.header.cwd = "/test/cwd/path".to_string();
9072
9073 let html = render_session_html(&session.header, &session.entries);
9074 assert!(html.contains("test-session-id-xyz"));
9075 assert!(html.contains("/test/cwd/path"));
9076 }
9077
9078 #[test]
9079 fn render_session_html_renders_all_entry_types() {
9080 let mut session = Session::create();
9081
9082 session.append_message(make_test_message("user text here"));
9084
9085 session.append_model_change("anthropic".to_string(), "claude-sonnet-4-5".to_string());
9087
9088 session.entries.push(SessionEntry::ThinkingLevelChange(
9090 ThinkingLevelChangeEntry {
9091 base: EntryBase::new(None, "tlc1".to_string()),
9092 thinking_level: "high".to_string(),
9093 },
9094 ));
9095
9096 let html = render_session_html(&session.header, &session.entries);
9097 assert!(html.contains("user text here"));
9098 assert!(html.contains("anthropic"));
9099 assert!(html.contains("claude-sonnet-4-5"));
9100 assert!(html.contains("high"));
9101 }
9102
9103 #[test]
9104 fn export_snapshot_with_path() {
9105 let mut session = Session::create();
9106 session.path = Some(PathBuf::from("/tmp/my-session.jsonl"));
9107 session.append_message(make_test_message("msg"));
9108
9109 let snapshot = session.export_snapshot();
9110 assert_eq!(
9111 snapshot.path.as_deref(),
9112 Some(Path::new("/tmp/my-session.jsonl"))
9113 );
9114 }
9115
9116 #[test]
9117 fn fork_plan_snapshot_consistency() {
9118 let mut session = Session::create();
9119 let msg1 = make_test_message("first message");
9120 session.append_message(msg1);
9121 let msg1_id = session.entries[0].base_id().unwrap().clone();
9122
9123 let msg2 = make_test_message("second message");
9124 session.append_message(msg2);
9125 let msg2_id = session.entries[1].base_id().unwrap().clone();
9126
9127 let plan = session.plan_fork_from_user_message(&msg2_id).unwrap();
9129
9130 assert_eq!(plan.leaf_id, Some(msg1_id));
9132 let plan_entry_count = plan.entries.len();
9134 session.append_message(make_test_message("third message"));
9135 assert_eq!(plan.entries.len(), plan_entry_count);
9136 }
9137}