Skip to main content

vtcode_core/utils/
session_archive.rs

1use crate::config::constants::defaults;
2use crate::config::{HistoryPersistence, VTCodeConfig};
3use crate::core::agent::snapshots::SnapshotTurnDiagnostics;
4use crate::llm::provider::{AssistantPhase, Message, MessageClearAt, MessageContent, MessageRole, ToolCall};
5use crate::telemetry::perf::PerfSpan;
6use crate::utils::error_log_collector::ErrorLogEntry;
7use crate::utils::session_transcript_norm::{
8    format_repeated_summary, normalize_distinct_tools_for_summary, normalize_recovery_line, normalized_transcript_key,
9    push_clean_transcript_line, should_drop_transcript_line, summarize_tool_block,
10};
11use anyhow::{Context, Result};
12use chrono::{DateTime, Utc};
13use regex::RegexBuilder;
14use serde::{Deserialize, Deserializer, Serialize};
15use std::collections::HashMap;
16use std::env;
17use std::fs;
18use std::path::{Path, PathBuf};
19use std::process;
20use std::str::FromStr;
21use std::sync::{Arc, Mutex, OnceLock};
22use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
23use uuid::Uuid;
24use vtcode_commons::VtCodePaths;
25use vtcode_commons::fs::{read_private_json_file, write_private_json_file};
26
27const SESSION_FILE_PREFIX: &str = "session";
28const SESSION_FILE_EXTENSION: &str = "json";
29pub const SESSION_DIR_ENV: &str = "VT_SESSION_DIR";
30pub const SESSION_MAX_FILES_ENV: &str = "VT_SESSION_MAX_FILES";
31pub const SESSION_MAX_AGE_DAYS_ENV: &str = "VT_SESSION_MAX_AGE_DAYS";
32pub const SESSION_MAX_SIZE_MB_ENV: &str = "VT_SESSION_MAX_SIZE_MB";
33const DEFAULT_SESSION_MAX_FILES: usize = 100;
34const DEFAULT_SESSION_MAX_AGE_DAYS: u64 = 14;
35const DEFAULT_SESSION_MAX_SIZE_MB: u64 = 100;
36const BYTES_PER_MB: u64 = 1024 * 1024;
37use crate::core::SECONDS_PER_DAY;
38
39#[derive(Debug, Clone, Copy)]
40struct SessionHistorySettings {
41    persistence: HistoryPersistence,
42    max_bytes: Option<usize>,
43}
44
45impl Default for SessionHistorySettings {
46    fn default() -> Self {
47        Self {
48            persistence: HistoryPersistence::File,
49            max_bytes: None,
50        }
51    }
52}
53
54static SESSION_HISTORY_SETTINGS: OnceLock<Mutex<SessionHistorySettings>> = OnceLock::new();
55
56fn session_history_settings() -> SessionHistorySettings {
57    SESSION_HISTORY_SETTINGS
58        .get()
59        .map(|settings| *settings.lock().unwrap_or_else(|e| e.into_inner()))
60        .unwrap_or_default()
61}
62
63pub fn apply_session_history_config_from_vtcode(config: &VTCodeConfig) {
64    let settings = SessionHistorySettings {
65        persistence: config.history.persistence,
66        max_bytes: config.history.max_bytes,
67    };
68    let cell = SESSION_HISTORY_SETTINGS.get_or_init(|| Mutex::new(SessionHistorySettings::default()));
69    if let Ok(mut guard) = cell.lock() {
70        *guard = settings;
71    }
72}
73
74pub fn history_persistence_enabled() -> bool {
75    matches!(session_history_settings().persistence, HistoryPersistence::File)
76}
77
78#[cfg(test)]
79mod test_env_overrides {
80    use hashbrown::HashMap;
81    use std::ffi::OsString;
82    use std::sync::{LazyLock, Mutex};
83
84    static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<OsString>>>> = LazyLock::new(|| Mutex::new(HashMap::new()));
85
86    pub(super) fn get(key: &str) -> Option<Option<OsString>> {
87        OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
88    }
89
90    pub(super) fn set(key: &str, value: Option<OsString>) {
91        if let Ok(mut map) = OVERRIDES.lock() {
92            map.insert(key.to_owned(), value);
93        }
94    }
95
96    pub(super) fn clear(key: &str) {
97        if let Ok(mut map) = OVERRIDES.lock() {
98            map.remove(key);
99        }
100    }
101}
102
103fn read_env_var_os(key: &str) -> Option<std::ffi::OsString> {
104    #[cfg(test)]
105    if let Some(override_value) = test_env_overrides::get(key) {
106        return override_value;
107    }
108
109    env::var_os(key)
110}
111
112fn read_env_var(key: &str) -> Option<String> {
113    #[cfg(test)]
114    if let Some(override_value) = test_env_overrides::get(key) {
115        return override_value.map(|value| value.to_string_lossy().to_string());
116    }
117
118    env::var(key).ok()
119}
120
121#[cfg(test)]
122fn set_test_env_override_path(key: &str, value: &Path) {
123    test_env_overrides::set(key, Some(value.as_os_str().to_os_string()));
124}
125
126#[cfg(test)]
127fn clear_test_env_override(key: &str) {
128    test_env_overrides::clear(key);
129}
130
131#[cfg(test)]
132pub(crate) fn override_sessions_dir_for_tests(path: &Path) {
133    set_test_env_override_path(SESSION_DIR_ENV, path);
134}
135
136#[cfg(test)]
137pub(crate) fn clear_sessions_dir_override_for_tests() {
138    clear_test_env_override(SESSION_DIR_ENV);
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142pub struct SessionArchiveMetadata {
143    pub workspace_label: String,
144    pub workspace_path: String,
145    pub model: String,
146    pub provider: String,
147    pub theme: String,
148    pub reasoning_effort: String,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub debug_log_path: Option<String>,
151    /// Names of skills loaded in this session
152    #[serde(default)]
153    pub loaded_skills: Vec<String>,
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub prompt_cache_lineage_id: Option<String>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub external_thread_id: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub parent_session_id: Option<String>,
160    #[serde(default, skip_serializing_if = "Option::is_none")]
161    pub primary_agent: Option<String>,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub fork_mode: Option<SessionForkMode>,
164    #[serde(default, skip_serializing_if = "Option::is_none")]
165    pub continuation_metadata: Option<SessionContinuationMetadata>,
166}
167
168#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
169#[serde(rename_all = "snake_case")]
170pub enum SessionForkMode {
171    FullCopy,
172    Summarized,
173}
174
175#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
176#[serde(rename_all = "snake_case")]
177pub enum SessionContinuationExhaustionReason {
178    MaxBudgetUsd,
179}
180
181#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
182#[serde(rename_all = "snake_case")]
183pub enum SessionContinuationRecommendedAction {
184    ContinueFromSummary,
185    ContinueFullHistory,
186    StartFresh,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
190pub struct SessionContinuationMetadata {
191    pub exhaustion_reason: SessionContinuationExhaustionReason,
192    #[serde(default, skip_serializing_if = "Option::is_none")]
193    pub max_budget_usd_micros: Option<u64>,
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub actual_cost_usd_micros: Option<u64>,
196    #[serde(default)]
197    pub summary_available: bool,
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub recommended_action: Option<SessionContinuationRecommendedAction>,
200}
201
202impl SessionContinuationMetadata {
203    pub fn budget_limit(max_budget_usd: f64, actual_cost_usd: f64, summary_available: bool) -> Self {
204        Self {
205            exhaustion_reason: SessionContinuationExhaustionReason::MaxBudgetUsd,
206            max_budget_usd_micros: usd_to_micros(max_budget_usd),
207            actual_cost_usd_micros: usd_to_micros(actual_cost_usd),
208            summary_available,
209            recommended_action: Some(SessionContinuationRecommendedAction::ContinueFromSummary),
210        }
211    }
212
213    pub fn max_budget_usd(&self) -> Option<f64> {
214        self.max_budget_usd_micros.map(micros_to_usd)
215    }
216
217    pub fn actual_cost_usd(&self) -> Option<f64> {
218        self.actual_cost_usd_micros.map(micros_to_usd)
219    }
220
221    pub fn is_budget_limit(&self) -> bool {
222        matches!(self.exhaustion_reason, SessionContinuationExhaustionReason::MaxBudgetUsd)
223    }
224}
225
226fn usd_to_micros(value: f64) -> Option<u64> {
227    if !value.is_finite() || value.is_sign_negative() {
228        return None;
229    }
230
231    #[allow(
232        clippy::cast_sign_loss,
233        reason = "Intentional compatibility, platform, or test-only suppression."
234    )]
235    Some((value * 1_000_000.0).round() as u64)
236}
237
238fn micros_to_usd(value: u64) -> f64 {
239    value as f64 / 1_000_000.0
240}
241
242impl SessionArchiveMetadata {
243    pub fn new(
244        workspace_label: impl Into<String>,
245        workspace_path: impl Into<String>,
246        model: impl Into<String>,
247        provider: impl Into<String>,
248        theme: impl Into<String>,
249        reasoning_effort: impl Into<String>,
250    ) -> Self {
251        Self {
252            workspace_label: workspace_label.into(),
253            workspace_path: workspace_path.into(),
254            model: model.into(),
255            provider: provider.into(),
256            theme: theme.into(),
257            reasoning_effort: reasoning_effort.into(),
258            debug_log_path: None,
259            loaded_skills: Vec::new(),
260            prompt_cache_lineage_id: None,
261            external_thread_id: None,
262            parent_session_id: None,
263            primary_agent: None,
264            fork_mode: None,
265            continuation_metadata: None,
266        }
267    }
268
269    /// Set loaded skills for this session
270    pub fn with_loaded_skills(mut self, skills: Vec<String>) -> Self {
271        self.loaded_skills = skills;
272        self
273    }
274
275    /// Set debug log path associated with this archive.
276    pub fn with_debug_log_path(mut self, path: Option<String>) -> Self {
277        self.debug_log_path = path;
278        self
279    }
280
281    pub fn with_prompt_cache_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
282        self.prompt_cache_lineage_id = Some(lineage_id.into());
283        self
284    }
285
286    pub fn with_external_thread_id(mut self, thread_id: impl Into<String>) -> Self {
287        self.external_thread_id = Some(thread_id.into());
288        self
289    }
290
291    pub fn ensure_prompt_cache_lineage_id(mut self) -> Self {
292        if self.prompt_cache_lineage_id.is_none() {
293            self.prompt_cache_lineage_id = Some(format!("lineage-{}", Uuid::new_v4()));
294        }
295        self
296    }
297
298    pub fn with_parent_session_id(mut self, session_id: impl Into<String>) -> Self {
299        self.parent_session_id = Some(session_id.into());
300        self
301    }
302
303    pub fn with_primary_agent(mut self, primary_agent: impl Into<String>) -> Self {
304        self.primary_agent = Some(primary_agent.into());
305        self
306    }
307
308    pub fn with_fork_mode(mut self, fork_mode: SessionForkMode) -> Self {
309        self.fork_mode = Some(fork_mode);
310        self
311    }
312
313    pub fn with_continuation_metadata(mut self, continuation_metadata: Option<SessionContinuationMetadata>) -> Self {
314        self.continuation_metadata = continuation_metadata;
315        self
316    }
317
318    pub fn budget_limit_continuation(&self) -> Option<&SessionContinuationMetadata> {
319        self.continuation_metadata
320            .as_ref()
321            .filter(|continuation| continuation.is_budget_limit())
322    }
323
324    fn fork_seed(&self) -> Self {
325        Self {
326            workspace_label: self.workspace_label.clone(),
327            workspace_path: self.workspace_path.clone(),
328            model: self.model.clone(),
329            provider: self.provider.clone(),
330            theme: self.theme.clone(),
331            reasoning_effort: self.reasoning_effort.clone(),
332            debug_log_path: self.debug_log_path.clone(),
333            loaded_skills: self.loaded_skills.clone(),
334            prompt_cache_lineage_id: self.prompt_cache_lineage_id.clone(),
335            external_thread_id: self.external_thread_id.clone(),
336            parent_session_id: None,
337            primary_agent: self.primary_agent.clone(),
338            fork_mode: None,
339            continuation_metadata: None,
340        }
341    }
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345pub struct SessionMessage {
346    pub role: MessageRole,
347    pub content: MessageContent,
348    // Keep sparse metadata boxed so long histories don't pay large inline Option<T> costs.
349    #[serde(
350        default,
351        skip_serializing_if = "Option::is_none",
352        deserialize_with = "deserialize_boxed_non_empty_string_opt"
353    )]
354    pub reasoning: Option<Box<String>>,
355    #[serde(
356        default,
357        skip_serializing_if = "Option::is_none",
358        deserialize_with = "deserialize_boxed_non_empty_vec_opt"
359    )]
360    pub reasoning_details: Option<Box<Vec<serde_json::Value>>>,
361    #[serde(
362        default,
363        skip_serializing_if = "Option::is_none",
364        deserialize_with = "deserialize_boxed_non_empty_vec_opt"
365    )]
366    pub tool_calls: Option<Box<Vec<ToolCall>>>,
367    #[serde(default, deserialize_with = "deserialize_boxed_non_empty_string_opt")]
368    pub tool_call_id: Option<Box<String>>,
369    #[serde(default, skip_serializing_if = "Option::is_none")]
370    pub phase: Option<AssistantPhase>,
371    #[serde(
372        default,
373        skip_serializing_if = "Option::is_none",
374        deserialize_with = "deserialize_boxed_non_empty_string_opt"
375    )]
376    pub origin_tool: Option<Box<String>>,
377    #[serde(
378        default,
379        skip_serializing_if = "Option::is_none",
380        deserialize_with = "deserialize_boxed_metadata_opt"
381    )]
382    pub metadata: Option<Box<crate::core::message_metadata::MessageMetadata>>,
383    #[serde(default, skip_serializing_if = "Option::is_none")]
384    pub clear_at: Option<MessageClearAt>,
385}
386
387impl Eq for SessionMessage {}
388
389#[expect(
390    clippy::box_collection,
391    reason = "Intentional compatibility, platform, test, or API-shape suppression."
392)]
393#[inline]
394fn boxed_non_empty_string(value: Option<String>) -> Option<Box<String>> {
395    value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
396}
397
398#[expect(
399    clippy::box_collection,
400    reason = "Intentional compatibility, platform, test, or API-shape suppression."
401)]
402#[inline]
403fn boxed_non_empty_vec<T>(value: Option<Vec<T>>) -> Option<Box<Vec<T>>> {
404    value.and_then(|value| (!value.is_empty()).then_some(Box::new(value)))
405}
406
407#[expect(
408    clippy::box_collection,
409    reason = "Intentional compatibility, platform, test, or API-shape suppression."
410)]
411fn clone_non_empty_boxed_string(value: &Option<Box<String>>) -> Option<String> {
412    value
413        .as_deref()
414        .and_then(|value| (!value.is_empty()).then_some(value.to_owned()))
415}
416
417fn deserialize_boxed_metadata_opt<'de, D>(
418    deserializer: D,
419) -> Result<Option<Box<crate::core::message_metadata::MessageMetadata>>, D::Error>
420where
421    D: Deserializer<'de>,
422{
423    crate::core::message_metadata::MessageMetadata::deserialize(deserializer).map(|m| Some(Box::new(m)))
424}
425
426#[expect(
427    clippy::box_collection,
428    reason = "Intentional compatibility, platform, test, or API-shape suppression."
429)]
430fn clone_non_empty_boxed_vec<T: Clone>(value: &Option<Box<Vec<T>>>) -> Option<Vec<T>> {
431    value.as_deref().and_then(|value| (!value.is_empty()).then_some(value.clone()))
432}
433
434#[expect(
435    clippy::box_collection,
436    reason = "Intentional compatibility, platform, test, or API-shape suppression."
437)]
438fn deserialize_boxed_non_empty_string_opt<'de, D>(deserializer: D) -> Result<Option<Box<String>>, D::Error>
439where
440    D: Deserializer<'de>,
441{
442    Option::<String>::deserialize(deserializer).map(boxed_non_empty_string)
443}
444
445#[expect(
446    clippy::box_collection,
447    reason = "Intentional compatibility, platform, test, or API-shape suppression."
448)]
449fn deserialize_boxed_non_empty_vec_opt<'de, D, T>(deserializer: D) -> Result<Option<Box<Vec<T>>>, D::Error>
450where
451    D: Deserializer<'de>,
452    T: Deserialize<'de>,
453{
454    Option::<Vec<T>>::deserialize(deserializer).map(boxed_non_empty_vec)
455}
456
457impl SessionMessage {
458    fn base(role: MessageRole, content: MessageContent) -> Self {
459        Self {
460            role,
461            content,
462            reasoning: None,
463            reasoning_details: None,
464            tool_calls: None,
465            tool_call_id: None,
466            phase: None,
467            origin_tool: None,
468            metadata: None,
469            clear_at: None,
470        }
471    }
472
473    pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
474        Self::base(role, MessageContent::Text(content.into()))
475    }
476
477    pub fn with_content(role: MessageRole, content: MessageContent) -> Self {
478        Self::base(role, content)
479    }
480
481    pub fn with_tool_call_id(role: MessageRole, content: impl Into<String>, tool_call_id: Option<String>) -> Self {
482        Self::with_tool_call_id_content(role, MessageContent::Text(content.into()), tool_call_id)
483    }
484
485    pub fn with_tool_call_id_content(role: MessageRole, content: MessageContent, tool_call_id: Option<String>) -> Self {
486        let mut message = Self::base(role, content);
487        message.tool_call_id = boxed_non_empty_string(tool_call_id);
488        message
489    }
490}
491
492impl From<&Message> for SessionMessage {
493    fn from(message: &Message) -> Self {
494        Self {
495            role: message.role,
496            content: message.content.clone(),
497            reasoning: boxed_non_empty_string(message.reasoning.clone()),
498            reasoning_details: boxed_non_empty_vec(message.reasoning_details.clone()),
499            tool_calls: boxed_non_empty_vec(message.tool_calls.clone()),
500            tool_call_id: boxed_non_empty_string(message.tool_call_id.clone()),
501            phase: message.phase,
502            origin_tool: boxed_non_empty_string(message.origin_tool.clone()),
503            metadata: message.metadata.clone().map(Box::new),
504            clear_at: message.clear_at,
505        }
506    }
507}
508
509impl From<&SessionMessage> for Message {
510    fn from(message: &SessionMessage) -> Self {
511        Self {
512            role: message.role,
513            content: message.content.clone(),
514            reasoning: clone_non_empty_boxed_string(&message.reasoning),
515            reasoning_details: clone_non_empty_boxed_vec(&message.reasoning_details),
516            tool_calls: clone_non_empty_boxed_vec(&message.tool_calls),
517            tool_call_id: clone_non_empty_boxed_string(&message.tool_call_id),
518            phase: message.phase,
519            origin_tool: clone_non_empty_boxed_string(&message.origin_tool),
520            metadata: message.metadata.as_deref().cloned(),
521            clear_at: message.clear_at,
522        }
523    }
524}
525
526#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
527pub struct SessionSnapshot {
528    pub metadata: SessionArchiveMetadata,
529    pub started_at: DateTime<Utc>,
530    pub ended_at: DateTime<Utc>,
531    pub total_messages: usize,
532    pub distinct_tools: Vec<String>,
533    pub transcript: Vec<String>,
534    #[serde(default)]
535    pub messages: Vec<SessionMessage>,
536    // SessionProgress is heavy and frequently absent in final snapshots.
537    #[serde(default, skip_serializing_if = "Option::is_none")]
538    pub progress: Option<Box<SessionProgress>>,
539    /// ERROR-level log entries captured during the session for post-mortem debugging.
540    #[serde(default, skip_serializing_if = "Vec::is_empty")]
541    pub error_logs: Vec<ErrorLogEntry>,
542}
543
544#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
545pub struct SessionProgress {
546    pub turn_number: usize,
547    #[serde(default)]
548    pub recent_messages: Vec<SessionMessage>,
549    #[serde(default)]
550    pub tool_summaries: Vec<String>,
551    #[serde(default, skip_serializing_if = "Option::is_none")]
552    pub token_usage: Option<String>,
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub max_context_tokens: Option<usize>,
555    /// Names of skills loaded at checkpoint time
556    #[serde(default)]
557    pub loaded_skills: Vec<String>,
558    /// Diagnostics for the most recently checkpointed turn, including turns
559    /// that ended blocked, cancelled, or aborted before a turn snapshot.
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub turn_diagnostics: Option<SnapshotTurnDiagnostics>,
562}
563
564#[derive(Debug, Clone)]
565pub struct SessionListing {
566    pub path: PathBuf,
567    pub snapshot: SessionSnapshot,
568}
569
570/// An archive identifier derived from a loaded session listing.
571///
572/// The inner value is crate-private so callers cannot mark an arbitrary
573/// runtime/session ID as resumable. Obtain one from
574/// [`SessionArchive::verify_persisted_resume_identifier`].
575#[derive(Debug, Clone, PartialEq, Eq)]
576pub struct VerifiedSessionArchiveIdentifier(pub(crate) String);
577
578impl VerifiedSessionArchiveIdentifier {
579    /// Borrow the verified archive identifier.
580    #[must_use]
581    pub fn as_str(&self) -> &str {
582        &self.0
583    }
584}
585
586#[derive(Debug, Clone)]
587pub struct SessionProgressArgs {
588    pub total_messages: usize,
589    pub distinct_tools: Vec<String>,
590    pub messages: Vec<SessionMessage>,
591    pub recent_messages: Vec<SessionMessage>,
592    pub turn_number: usize,
593    pub token_usage: Option<String>,
594    pub max_context_tokens: Option<usize>,
595    pub loaded_skills: Option<Vec<String>>,
596    /// Optional diagnostics for the turn represented by this checkpoint.
597    pub turn_diagnostics: Option<SnapshotTurnDiagnostics>,
598}
599
600/// Outcome of an asynchronous session-progress checkpoint.
601#[derive(Debug, Clone, PartialEq, Eq)]
602pub enum SessionProgressPersistenceStatus {
603    /// The archive file was written successfully.
604    Persisted(PathBuf),
605    /// The call was intentionally skipped by the progress throttle.
606    Throttled(PathBuf),
607    /// History persistence is disabled, so no archive file was written.
608    Disabled(PathBuf),
609}
610
611impl SessionProgressPersistenceStatus {
612    /// Return the archive path associated with this checkpoint attempt.
613    #[must_use]
614    pub fn path(&self) -> &Path {
615        match self {
616            Self::Persisted(path) | Self::Throttled(path) | Self::Disabled(path) => path,
617        }
618    }
619
620    /// Return whether this outcome guarantees that the current snapshot was
621    /// written to the archive.
622    #[must_use]
623    pub fn is_persisted(&self) -> bool {
624        matches!(self, Self::Persisted(_))
625    }
626}
627
628impl SessionListing {
629    pub fn identifier(&self) -> String {
630        self.path
631            .file_stem()
632            .and_then(|value| value.to_str())
633            .map(|value| value.to_string())
634            .unwrap_or_else(|| self.path.display().to_string())
635    }
636
637    /// Return an identifier that is safe to advertise as an archive resume target.
638    fn verified_identifier(&self) -> Result<VerifiedSessionArchiveIdentifier> {
639        let identifier = self
640            .path
641            .file_stem()
642            .and_then(|value| value.to_str())
643            .with_context(|| format!("session archive path has no valid identifier: {}", self.path.display()))?;
644        validate_session_identifier(identifier)?;
645        Ok(VerifiedSessionArchiveIdentifier(identifier.to_owned()))
646    }
647
648    pub fn first_prompt_preview(&self) -> Option<String> {
649        self.preview_for_role(MessageRole::User)
650    }
651
652    pub fn first_reply_preview(&self) -> Option<String> {
653        self.preview_for_role(MessageRole::Assistant)
654    }
655
656    fn preview_for_role(&self, role: MessageRole) -> Option<String> {
657        self.snapshot.messages.iter().find_map(|message| {
658            if message.role != role {
659                return None;
660            }
661
662            let text_projection = message.content.as_text();
663            if text_projection.trim().is_empty() {
664                return None;
665            }
666
667            text_projection.lines().find_map(|line| {
668                let trimmed = line.trim();
669                if trimmed.is_empty() {
670                    None
671                } else {
672                    Some(truncate_preview(trimmed, 80))
673                }
674            })
675        })
676    }
677}
678
679fn normalize_workspace_for_match(path: &Path) -> PathBuf {
680    let absolute = if path.is_absolute() {
681        path.to_path_buf()
682    } else {
683        env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
684    };
685
686    crate::utils::path::normalize_path(&absolute)
687}
688
689pub fn session_workspace_path(listing: &SessionListing) -> Option<PathBuf> {
690    let raw = listing.snapshot.metadata.workspace_path.trim();
691    if raw.is_empty() { None } else { Some(PathBuf::from(raw)) }
692}
693
694pub fn session_listing_matches_workspace(listing: &SessionListing, workspace: &Path) -> bool {
695    let Some(session_workspace) = session_workspace_path(listing) else {
696        return false;
697    };
698
699    normalize_workspace_for_match(&session_workspace) == normalize_workspace_for_match(workspace)
700}
701
702fn generate_unique_archive_path(
703    sessions_dir: &Path,
704    metadata: &SessionArchiveMetadata,
705    started_at: DateTime<Utc>,
706    custom_suffix: Option<&str>,
707) -> PathBuf {
708    generate_unique_archive_path_for_label(sessions_dir, &metadata.workspace_label, started_at, custom_suffix)
709}
710
711fn generate_unique_archive_path_for_label(
712    sessions_dir: &Path,
713    workspace_label: &str,
714    started_at: DateTime<Utc>,
715    custom_suffix: Option<&str>,
716) -> PathBuf {
717    if custom_suffix.is_some() {
718        return sessions_dir.join(archive_file_name_for_label(workspace_label, started_at, custom_suffix, None));
719    }
720
721    let mut attempt = 0u32;
722    loop {
723        let candidate =
724            sessions_dir.join(archive_file_name_for_label(workspace_label, started_at, None, Some(attempt)));
725        if !candidate.exists() {
726            return candidate;
727        }
728        attempt = attempt.wrapping_add(1);
729    }
730}
731
732fn archive_file_name_for_label(
733    workspace_label: &str,
734    started_at: DateTime<Utc>,
735    custom_suffix: Option<&str>,
736    attempt: Option<u32>,
737) -> String {
738    let sanitized_label = sanitize_component(workspace_label);
739    let timestamp = started_at.format("%Y%m%dT%H%M%SZ").to_string();
740
741    if let Some(suffix) = custom_suffix {
742        return format!(
743            "{}-{}-{}-{}.{}",
744            SESSION_FILE_PREFIX,
745            sanitized_label,
746            timestamp,
747            sanitize_component(suffix),
748            SESSION_FILE_EXTENSION
749        );
750    }
751
752    let micros = started_at.timestamp_subsec_micros();
753    let pid = process::id();
754    let attempt_suffix = match attempt.unwrap_or_default() {
755        0 => String::new(),
756        value => format!("-{value:02}"),
757    };
758    format!(
759        "{SESSION_FILE_PREFIX}-{sanitized_label}-{timestamp}_{micros:06}-{pid:05}{attempt_suffix}.{SESSION_FILE_EXTENSION}"
760    )
761}
762
763pub fn generate_session_archive_identifier(workspace_label: &str, custom_suffix: Option<String>) -> String {
764    let file_name = archive_file_name_for_label(workspace_label, Utc::now(), custom_suffix.as_deref(), Some(0));
765    Path::new(&file_name)
766        .file_stem()
767        .and_then(|stem| stem.to_str())
768        .map(str::to_owned)
769        .unwrap_or_else(|| format!("session-{}-{}", sanitize_component(workspace_label), process::id()))
770}
771
772fn session_identifier_from_archive_path(path: &Path) -> Result<String> {
773    path.file_stem()
774        .and_then(|stem| stem.to_str())
775        .map(|value| value.to_string())
776        .ok_or_else(|| anyhow::anyhow!("failed to derive session identifier from archive path"))
777}
778
779fn is_valid_session_identifier(value: &str) -> bool {
780    !value.is_empty()
781        && value.len() <= 256
782        && value.chars().all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
783}
784
785fn validate_session_identifier(session_identifier: &str) -> Result<()> {
786    if is_valid_session_identifier(session_identifier) {
787        return Ok(());
788    }
789
790    Err(anyhow::anyhow!(
791        "Invalid session identifier '{session_identifier}': only ASCII letters, digits, '-' and '_' are allowed"
792    ))
793}
794
795fn session_archive_path_for_identifier(sessions_dir: &Path, session_identifier: &str) -> Result<PathBuf> {
796    validate_session_identifier(session_identifier)?;
797    Ok(sessions_dir.join(format!("{session_identifier}.{SESSION_FILE_EXTENSION}")))
798}
799
800fn reserve_new_session_archive_path(sessions_dir: &Path, session_identifier: &str) -> Result<PathBuf> {
801    let path = session_archive_path_for_identifier(sessions_dir, session_identifier)?;
802    if fs::symlink_metadata(&path).is_ok() {
803        return Err(anyhow::anyhow!("Session archive identifier '{session_identifier}' already exists"));
804    }
805
806    Ok(path)
807}
808
809async fn resolve_sessions_dir_for_archive_writes() -> Result<PathBuf> {
810    let sessions_dir = resolve_sessions_dir().await?;
811    apply_session_retention_best_effort(&sessions_dir);
812    Ok(sessions_dir)
813}
814
815/// Reserve a unique session archive identifier for the current process.
816///
817/// The returned identifier is the JSON file stem (without `.json`) and can be reused
818/// to create an archive and pair external artifacts (for example debug logs).
819pub async fn reserve_session_archive_identifier(
820    workspace_label: &str,
821    custom_suffix: Option<String>,
822) -> Result<String> {
823    let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
824    let started_at = Utc::now();
825    let path =
826        generate_unique_archive_path_for_label(&sessions_dir, workspace_label, started_at, custom_suffix.as_deref());
827    session_identifier_from_archive_path(&path)
828}
829
830fn progress_transcript_from_recent_messages(recent_messages: &[SessionMessage]) -> Vec<String> {
831    let mut transcript = Vec::with_capacity(recent_messages.len());
832
833    for message in recent_messages {
834        if !matches!(message.role, MessageRole::User | MessageRole::Assistant) {
835            continue;
836        }
837
838        let content = message.content.trim();
839        let content: &str = content.as_ref();
840        if !content.is_empty() && transcript.last().is_none_or(|last: &String| last.as_str() != content) {
841            transcript.push(content.to_string());
842        }
843    }
844
845    clean_transcript_lines(&transcript)
846}
847
848fn clean_transcript_lines(lines: &[String]) -> Vec<String> {
849    let mut cleaned = Vec::new();
850    let mut seen_tool_blocks: HashMap<String, (usize, usize, String)> = HashMap::new();
851    let mut index = 0usize;
852
853    while index < lines.len() {
854        let line = lines[index].trim_end();
855
856        if should_reset_tool_dedupe_scope(line) {
857            seen_tool_blocks.clear();
858        }
859
860        if let Some(replacement) = normalize_recovery_line(line) {
861            push_clean_transcript_line(&mut cleaned, replacement);
862            index += 1;
863            continue;
864        }
865
866        if should_drop_transcript_line(line) {
867            index += 1;
868            continue;
869        }
870
871        if line.trim_start().starts_with("• ") {
872            let (summary, next_index) = summarize_tool_block(lines, index);
873            let signature = normalized_transcript_key(&summary);
874
875            if let Some((first_index, repeats, original_line)) = seen_tool_blocks.get_mut(&signature) {
876                *repeats += 1;
877                if let Some(existing) = cleaned.get_mut(*first_index) {
878                    *existing = format_repeated_summary(original_line, *repeats);
879                }
880            } else {
881                let insertion_index = cleaned.len();
882                push_clean_transcript_line(&mut cleaned, summary);
883                if cleaned.len() > insertion_index {
884                    seen_tool_blocks.insert(signature, (insertion_index, 1, cleaned[insertion_index].clone()));
885                }
886            }
887            index = next_index;
888            continue;
889        }
890
891        push_clean_transcript_line(&mut cleaned, line.to_string());
892        index += 1;
893    }
894
895    while cleaned.last().is_some_and(|line: &String| line.is_empty()) {
896        cleaned.pop();
897    }
898
899    cleaned
900}
901
902fn should_reset_tool_dedupe_scope(line: &str) -> bool {
903    let trimmed = line.trim();
904    !trimmed.is_empty() && !line.starts_with(' ') && !trimmed.starts_with("• ") && !trimmed.starts_with("[!]")
905}
906
907#[derive(Debug, Clone)]
908pub struct SessionArchive {
909    path: PathBuf,
910    metadata: SessionArchiveMetadata,
911    started_at: DateTime<Utc>,
912    progress_throttle: Arc<Mutex<ProgressThrottle>>,
913}
914
915#[derive(Debug)]
916struct ProgressThrottle {
917    last_written: Instant,
918    last_turn: usize,
919}
920
921impl ProgressThrottle {
922    fn new() -> Self {
923        let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
924        let last_written = Instant::now().checked_sub(min_interval).unwrap_or_else(Instant::now);
925        Self { last_written, last_turn: 0 }
926    }
927}
928
929impl SessionArchive {
930    fn from_path(path: PathBuf, metadata: SessionArchiveMetadata, started_at: DateTime<Utc>) -> Self {
931        Self {
932            path,
933            metadata,
934            started_at,
935            progress_throttle: Arc::new(Mutex::new(ProgressThrottle::new())),
936        }
937    }
938
939    fn build_snapshot(
940        &self,
941        total_messages: usize,
942        distinct_tools: Vec<String>,
943        transcript: Vec<String>,
944        messages: Vec<SessionMessage>,
945        progress: Option<SessionProgress>,
946    ) -> SessionSnapshot {
947        use crate::utils::error_log_collector::drain_error_logs;
948
949        SessionSnapshot {
950            metadata: self.metadata.clone(),
951            started_at: self.started_at,
952            ended_at: Utc::now(),
953            total_messages,
954            distinct_tools,
955            transcript,
956            messages,
957            progress: progress.map(Box::new),
958            error_logs: drain_error_logs(),
959        }
960    }
961
962    /// Records the active primary agent (session "mode") so a later resume can
963    /// restore it. Called once the active agent is selected, after the archive
964    /// and thread have been started.
965    pub fn set_primary_agent(&mut self, primary_agent: impl Into<String>) {
966        self.metadata.primary_agent = Some(primary_agent.into());
967    }
968
969    pub fn metadata(&self) -> &SessionArchiveMetadata {
970        &self.metadata
971    }
972
973    fn build_final_snapshot(
974        &self,
975        transcript: Vec<String>,
976        total_messages: usize,
977        distinct_tools: Vec<String>,
978        messages: Vec<SessionMessage>,
979        turn_diagnostics: Option<SnapshotTurnDiagnostics>,
980    ) -> SessionSnapshot {
981        self.build_snapshot(
982            total_messages,
983            normalize_distinct_tools_for_summary(&distinct_tools),
984            clean_transcript_lines(&transcript),
985            messages,
986            turn_diagnostics.map(|turn_diagnostics| SessionProgress {
987                turn_diagnostics: Some(turn_diagnostics),
988                ..SessionProgress::default()
989            }),
990        )
991    }
992
993    fn build_progress_snapshot(&self, args: SessionProgressArgs) -> SessionSnapshot {
994        let SessionProgressArgs {
995            total_messages,
996            distinct_tools,
997            messages,
998            recent_messages,
999            turn_number,
1000            token_usage,
1001            max_context_tokens,
1002            loaded_skills,
1003            turn_diagnostics,
1004        } = args;
1005
1006        let transcript = progress_transcript_from_recent_messages(&recent_messages);
1007        let distinct_tools = normalize_distinct_tools_for_summary(&distinct_tools);
1008        let tool_summaries = distinct_tools.clone();
1009
1010        self.build_snapshot(
1011            total_messages,
1012            distinct_tools,
1013            transcript,
1014            messages,
1015            Some(SessionProgress {
1016                turn_number,
1017                recent_messages,
1018                tool_summaries,
1019                token_usage,
1020                max_context_tokens,
1021                loaded_skills: loaded_skills.unwrap_or_default(),
1022                turn_diagnostics,
1023            }),
1024        )
1025    }
1026
1027    pub async fn new(metadata: SessionArchiveMetadata, custom_suffix: Option<String>) -> Result<Self> {
1028        let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1029        let started_at = Utc::now();
1030        let path = generate_unique_archive_path(&sessions_dir, &metadata, started_at, custom_suffix.as_deref());
1031
1032        Ok(Self::from_path(path, metadata, started_at))
1033    }
1034
1035    /// Create a session archive using an explicitly reserved session identifier.
1036    pub async fn new_with_identifier(metadata: SessionArchiveMetadata, session_identifier: String) -> Result<Self> {
1037        let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1038        let path = reserve_new_session_archive_path(&sessions_dir, &session_identifier)?;
1039
1040        Ok(Self::from_path(path, metadata, Utc::now()))
1041    }
1042
1043    /// Reopen an existing archive file so follow-up runs can overwrite the snapshot in place.
1044    pub fn resume_from_listing(listing: &SessionListing, metadata: SessionArchiveMetadata) -> Self {
1045        Self::from_path(listing.path.clone(), metadata, listing.snapshot.started_at)
1046    }
1047
1048    pub fn finalize(
1049        &self,
1050        transcript: Vec<String>,
1051        total_messages: usize,
1052        distinct_tools: Vec<String>,
1053        messages: Vec<SessionMessage>,
1054    ) -> Result<PathBuf> {
1055        self.finalize_with_diagnostics(transcript, total_messages, distinct_tools, messages, None)
1056    }
1057
1058    /// Finalize the archive while retaining lightweight diagnostics for the
1059    /// last turn. The final snapshot still omits the heavy progress history.
1060    pub fn finalize_with_diagnostics(
1061        &self,
1062        transcript: Vec<String>,
1063        total_messages: usize,
1064        distinct_tools: Vec<String>,
1065        messages: Vec<SessionMessage>,
1066        turn_diagnostics: Option<SnapshotTurnDiagnostics>,
1067    ) -> Result<PathBuf> {
1068        let snapshot =
1069            self.build_final_snapshot(transcript, total_messages, distinct_tools, messages, turn_diagnostics);
1070
1071        let path = self.write_snapshot(snapshot)?;
1072        if let Some(parent) = path.parent() {
1073            apply_session_retention_best_effort(parent);
1074        }
1075        Ok(path)
1076    }
1077
1078    pub fn persist_progress(&self, args: SessionProgressArgs) -> Result<PathBuf> {
1079        let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
1080        perf.tag("mode", "sync");
1081
1082        let snapshot = self.build_progress_snapshot(args);
1083
1084        self.write_snapshot(snapshot)
1085    }
1086
1087    /// Persist progress asynchronously and report whether this call wrote a
1088    /// new durable snapshot.
1089    ///
1090    /// A throttled checkpoint still returns the archive path for diagnostics,
1091    /// but it must not be treated as durable by callers that acknowledge
1092    /// state only after history has been written.
1093    pub async fn persist_progress_async_with_status(
1094        &self,
1095        args: SessionProgressArgs,
1096    ) -> Result<SessionProgressPersistenceStatus> {
1097        self.persist_progress_async_with_status_inner(args, false).await
1098    }
1099
1100    async fn persist_progress_async_with_status_inner(
1101        &self,
1102        args: SessionProgressArgs,
1103        bypass_throttle: bool,
1104    ) -> Result<SessionProgressPersistenceStatus> {
1105        let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
1106        perf.tag("mode", if bypass_throttle { "async_forced" } else { "async" });
1107
1108        if !history_persistence_enabled() {
1109            return Ok(SessionProgressPersistenceStatus::Disabled(self.path.clone()));
1110        }
1111        if !bypass_throttle && !self.should_persist_progress(args.turn_number)? {
1112            return Ok(SessionProgressPersistenceStatus::Throttled(self.path.clone()));
1113        }
1114
1115        let snapshot = self.build_progress_snapshot(args);
1116        let path = self.write_snapshot_async(snapshot).await?;
1117        Ok(SessionProgressPersistenceStatus::Persisted(path))
1118    }
1119
1120    /// Persist progress immediately, bypassing the normal progress throttle.
1121    ///
1122    /// This is intended for terminal handoff checkpoints where the latest
1123    /// conversation must be durable before an external artifact is written.
1124    pub async fn persist_progress_async_with_status_forced(
1125        &self,
1126        args: SessionProgressArgs,
1127    ) -> Result<SessionProgressPersistenceStatus> {
1128        self.persist_progress_async_with_status_inner(args, true).await
1129    }
1130
1131    pub async fn persist_progress_async(&self, args: SessionProgressArgs) -> Result<PathBuf> {
1132        Ok(self.persist_progress_async_with_status(args).await?.path().to_path_buf())
1133    }
1134
1135    fn write_snapshot(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
1136        let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
1137            return Ok(self.path.clone());
1138        };
1139
1140        write_private_json_file_sync(&self.path, &snapshot)?;
1141        Ok(self.path.clone())
1142    }
1143
1144    async fn write_snapshot_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
1145        let Some(snapshot) = prepare_snapshot_for_write(snapshot)? else {
1146            return Ok(self.path.clone());
1147        };
1148
1149        write_private_json_file(&self.path, &snapshot).await?;
1150        Ok(self.path.clone())
1151    }
1152
1153    fn should_persist_progress(&self, turn_number: usize) -> Result<bool> {
1154        let min_interval = Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
1155        let min_turns = defaults::DEFAULT_SESSION_PROGRESS_MIN_TURN_DELTA;
1156
1157        let mut throttle = self
1158            .progress_throttle
1159            .lock()
1160            .map_err(|err| anyhow::anyhow!("session progress throttle lock poisoned: {err}"))
1161            .context("Failed to evaluate session progress persistence throttle")?;
1162        if turn_number <= throttle.last_turn {
1163            return Ok(false);
1164        }
1165        if throttle.last_written.elapsed() < min_interval && turn_number.saturating_sub(throttle.last_turn) < min_turns
1166        {
1167            return Ok(false);
1168        }
1169
1170        throttle.last_written = Instant::now();
1171        throttle.last_turn = turn_number;
1172        Ok(true)
1173    }
1174    /// Update loaded skills in the archive metadata
1175    pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
1176        self.metadata.loaded_skills = skills;
1177    }
1178
1179    /// Update continuation metadata in the archive metadata.
1180    pub fn set_continuation_metadata(&mut self, continuation_metadata: Option<SessionContinuationMetadata>) {
1181        self.metadata.continuation_metadata = continuation_metadata;
1182    }
1183
1184    pub fn path(&self) -> &Path {
1185        &self.path
1186    }
1187
1188    /// Verify that a persisted snapshot resolves back to this archive and
1189    /// return its typed resume identifier.
1190    pub async fn verify_persisted_resume_identifier(
1191        &self,
1192        persisted_path: &Path,
1193    ) -> Result<Option<VerifiedSessionArchiveIdentifier>> {
1194        if self.path != persisted_path {
1195            return Ok(None);
1196        }
1197
1198        let Some(identifier) = persisted_path.file_stem().and_then(|stem| stem.to_str()) else {
1199            return Ok(None);
1200        };
1201        let Some(listing) = find_session_by_identifier(identifier).await? else {
1202            return Ok(None);
1203        };
1204        if listing.path != self.path {
1205            return Ok(None);
1206        }
1207
1208        Ok(Some(listing.verified_identifier()?))
1209    }
1210
1211    /// Create a forked session from an existing session snapshot
1212    ///
1213    /// This creates a new session archive that inherits metadata from the source
1214    /// session but operates independently. The forked session will have a new
1215    /// archive file with a custom suffix if provided.
1216    ///
1217    /// # Arguments
1218    /// * `source_snapshot` - The snapshot of the session to fork from
1219    /// * `custom_suffix` - Optional custom suffix for the new session ID
1220    ///
1221    /// # Returns
1222    /// A new SessionArchive instance for the forked session
1223    pub async fn fork(source_snapshot: &SessionSnapshot, custom_suffix: Option<String>) -> Result<Self> {
1224        create_fork_archive(source_snapshot, custom_suffix, None).await
1225    }
1226}
1227
1228async fn create_fork_archive(
1229    source_snapshot: &SessionSnapshot,
1230    custom_suffix: Option<String>,
1231    explicit_identifier: Option<String>,
1232) -> Result<SessionArchive> {
1233    let sessions_dir = resolve_sessions_dir_for_archive_writes().await?;
1234    let started_at = Utc::now();
1235
1236    let forked_metadata = source_snapshot.metadata.fork_seed();
1237
1238    let path = if let Some(session_identifier) = explicit_identifier {
1239        reserve_new_session_archive_path(&sessions_dir, &session_identifier)?
1240    } else {
1241        generate_unique_archive_path(&sessions_dir, &forked_metadata, started_at, custom_suffix.as_deref())
1242    };
1243
1244    Ok(SessionArchive::from_path(path, forked_metadata, started_at))
1245}
1246
1247pub async fn list_recent_sessions(limit: usize) -> Result<Vec<SessionListing>> {
1248    let sessions_dir = match resolve_sessions_dir().await {
1249        Ok(dir) => dir,
1250        Err(_) => return Ok(Vec::new()),
1251    };
1252
1253    if !sessions_dir.exists() {
1254        return Ok(Vec::new());
1255    }
1256
1257    // `std::fs::read_dir` is a blocking directory scan; run it off the async
1258    // executor and collect paths before spawning parallel read tasks. See `# Blocking`
1259    // docs in `src/agent/runloop/git.rs`.
1260    let session_paths: Vec<PathBuf> = tokio::task::spawn_blocking(move || -> Result<Vec<PathBuf>> {
1261        let mut paths = Vec::new();
1262        for entry in fs::read_dir(&sessions_dir)
1263            .with_context(|| format!("failed to read session directory: {}", sessions_dir.display()))?
1264        {
1265            let entry = entry.with_context(|| format!("failed to read session entry in {}", sessions_dir.display()))?;
1266            let path = entry.path();
1267            if is_session_file(&path) {
1268                paths.push(path);
1269            }
1270        }
1271        Ok(paths)
1272    })
1273    .await
1274    .context("session directory scan task panicked")??;
1275
1276    // Process session files in parallel for better performance with large archives
1277    // Batch processing to avoid overwhelming the system with too many concurrent tasks
1278    const BATCH_SIZE: usize = 10;
1279    let mut all_listings = Vec::new();
1280
1281    for batch in session_paths.chunks(BATCH_SIZE) {
1282        let mut tasks = Vec::with_capacity(batch.len());
1283
1284        for path in batch {
1285            let path = path.clone();
1286            let task = tokio::task::spawn(async move {
1287                read_private_json_file::<SessionSnapshot>(&path)
1288                    .await
1289                    .ok()
1290                    .map(|snapshot| SessionListing { path, snapshot })
1291            });
1292            tasks.push(task);
1293        }
1294
1295        // Collect results from this batch
1296        for task in tasks {
1297            if let Ok(Some(listing)) = task.await {
1298                all_listings.push(listing);
1299            }
1300        }
1301    }
1302
1303    // Sort and limit results
1304    all_listings.sort_by_key(|a| std::cmp::Reverse(a.snapshot.ended_at));
1305    if limit > 0 && all_listings.len() > limit {
1306        all_listings.truncate(limit);
1307    }
1308
1309    Ok(all_listings)
1310}
1311
1312/// Find a session archive by its identifier (file stem) without needing to list all sessions.
1313pub async fn find_session_by_identifier(identifier: &str) -> Result<Option<SessionListing>> {
1314    let sessions_dir = match resolve_sessions_dir().await {
1315        Ok(dir) => dir,
1316        Err(_) => return Ok(None),
1317    };
1318
1319    if !sessions_dir.exists() {
1320        return Ok(None);
1321    }
1322
1323    let path = match session_archive_path_for_identifier(&sessions_dir, identifier) {
1324        Ok(path) => path,
1325        Err(_) => return Ok(None),
1326    };
1327    match tokio::fs::symlink_metadata(&path).await {
1328        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => return Ok(None),
1329        Ok(_) => {}
1330        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
1331        Err(error) => {
1332            return Err(error).with_context(|| format!("failed to inspect session archive {}", path.display()));
1333        }
1334    }
1335
1336    let snapshot: SessionSnapshot = read_private_json_file(&path).await?;
1337    Ok(Some(SessionListing { path, snapshot }))
1338}
1339
1340fn write_private_json_file_sync<T: Serialize>(path: &Path, value: &T) -> Result<()> {
1341    let contents =
1342        serde_json::to_vec_pretty(value).with_context(|| format!("failed to serialize {}", path.display()))?;
1343    VtCodePaths::write_private_file_atomic(path, &contents)
1344        .with_context(|| format!("failed to write private session archive {}", path.display()))
1345}
1346
1347/// Find a saved session by ID or name for TUI `/resume` command.
1348///
1349/// Searches both the session identifier (UUID or file stem) and matches
1350/// against recent sessions. Returns a descriptive error message when no
1351/// matching session is found.
1352pub async fn find_session_by_id_or_name(id_or_name: &str) -> Result<SessionListing> {
1353    // First try exact identifier match (UUID or file stem)
1354    if let Some(listing) = find_session_by_identifier(id_or_name).await? {
1355        return Ok(listing);
1356    }
1357
1358    // Fall back to searching recent sessions by identifier substring
1359    let listings = list_recent_sessions(200).await?;
1360    let normalized_query = id_or_name.to_lowercase();
1361
1362    for listing in &listings {
1363        // Match against the file stem (session identifier)
1364        if let Some(stem) = listing.path.file_stem()
1365            && let Some(stem_str) = stem.to_str()
1366            && stem_str.to_lowercase().contains(&normalized_query)
1367        {
1368            return Ok(listing.clone());
1369        }
1370    }
1371
1372    Err(anyhow::anyhow!("No saved chat found matching '{id_or_name}'."))
1373}
1374
1375#[derive(Debug, Clone, Serialize, Deserialize)]
1376pub struct SearchResult {
1377    pub session_id: String,
1378    pub session_path: PathBuf,
1379    pub timestamp: DateTime<Utc>,
1380    pub message_index: usize,
1381    pub role: MessageRole,
1382    pub content_snippet: String,
1383    pub score: f32, // Simple matching score (e.g., term frequency or just 1.0)
1384}
1385
1386/// Search for a query string across recent sessions.
1387/// Returns a list of `SearchResult` sorted by relevance (or recency).
1388pub async fn search_sessions(query: &str, session_limit: usize, max_results: usize) -> Result<Vec<SearchResult>> {
1389    if query.trim().is_empty() || max_results == 0 {
1390        return Ok(Vec::new());
1391    }
1392
1393    let listings = list_recent_sessions(session_limit).await?;
1394    let matcher = RegexBuilder::new(&regex::escape(query))
1395        .case_insensitive(true)
1396        .build()
1397        .context("failed to compile session search query")?;
1398    let mut results = Vec::new();
1399
1400    for listing in listings {
1401        for (idx, msg) in listing.snapshot.messages.iter().enumerate() {
1402            let content = match &msg.content {
1403                MessageContent::Text(t) => t.as_str(),
1404                MessageContent::Parts(_) => continue,
1405            };
1406
1407            if let Some(matched) = matcher.find(content) {
1408                let snippet = search_result_snippet(content, matched.start(), matched.end());
1409
1410                results.push(SearchResult {
1411                    session_id: listing.identifier(),
1412                    session_path: listing.path.clone(),
1413                    timestamp: listing.snapshot.started_at,
1414                    message_index: idx,
1415                    role: msg.role,
1416                    content_snippet: snippet,
1417                    score: 1.0,
1418                });
1419
1420                if results.len() >= max_results {
1421                    break;
1422                }
1423            }
1424        }
1425        if results.len() >= max_results {
1426            break;
1427        }
1428    }
1429
1430    Ok(results)
1431}
1432
1433fn search_result_snippet(content: &str, match_start: usize, match_end: usize) -> String {
1434    const CONTEXT_BYTES: usize = 50;
1435
1436    let start = floor_char_boundary(content, match_start.saturating_sub(CONTEXT_BYTES));
1437    let end = ceil_char_boundary(content, (match_end + CONTEXT_BYTES).min(content.len()));
1438
1439    let mut snippet = String::new();
1440    if start > 0 {
1441        snippet.push_str("...");
1442    }
1443    snippet.push_str(&content[start..end].replace('\n', " "));
1444    if end < content.len() {
1445        snippet.push_str("...");
1446    }
1447    snippet
1448}
1449
1450fn floor_char_boundary(content: &str, mut index: usize) -> usize {
1451    while index > 0 && !content.is_char_boundary(index) {
1452        index -= 1;
1453    }
1454    index
1455}
1456
1457fn ceil_char_boundary(content: &str, mut index: usize) -> usize {
1458    while index < content.len() && !content.is_char_boundary(index) {
1459        index += 1;
1460    }
1461    index
1462}
1463
1464async fn resolve_sessions_dir() -> Result<PathBuf> {
1465    if let Some(custom) = read_env_var_os(SESSION_DIR_ENV) {
1466        let path = PathBuf::from(custom);
1467        VtCodePaths::ensure_user_dir(&path)
1468            .with_context(|| format!("failed to initialize session directory {}", path.display()))?;
1469        return Ok(path);
1470    }
1471
1472    let paths = VtCodePaths::resolve().context("failed to resolve VT Code paths")?;
1473    paths
1474        .ensure_state_dir()
1475        .context("failed to initialize VT Code state directory")?;
1476    let dir = paths.state_path("sessions")?;
1477    VtCodePaths::ensure_user_dir(&dir)
1478        .with_context(|| format!("failed to initialize session directory {}", dir.display()))?;
1479    Ok(dir)
1480}
1481
1482#[derive(Debug, Clone)]
1483struct SessionFileEntry {
1484    path: PathBuf,
1485    modified: SystemTime,
1486    size: u64,
1487}
1488
1489#[derive(Debug, Clone, Copy)]
1490struct SessionRetentionLimits {
1491    max_files: usize,
1492    max_age_days: u64,
1493    max_total_size_bytes: u64,
1494}
1495
1496impl Default for SessionRetentionLimits {
1497    fn default() -> Self {
1498        Self {
1499            max_files: DEFAULT_SESSION_MAX_FILES,
1500            max_age_days: DEFAULT_SESSION_MAX_AGE_DAYS,
1501            max_total_size_bytes: DEFAULT_SESSION_MAX_SIZE_MB.saturating_mul(BYTES_PER_MB),
1502        }
1503    }
1504}
1505
1506impl SessionRetentionLimits {
1507    fn from_env() -> Self {
1508        let defaults = Self::default();
1509        Self {
1510            max_files: parse_env_value(SESSION_MAX_FILES_ENV).unwrap_or(defaults.max_files),
1511            max_age_days: parse_env_value(SESSION_MAX_AGE_DAYS_ENV).unwrap_or(defaults.max_age_days),
1512            max_total_size_bytes: parse_env_value::<u64>(SESSION_MAX_SIZE_MB_ENV)
1513                .map(|value| value.saturating_mul(BYTES_PER_MB))
1514                .unwrap_or(defaults.max_total_size_bytes),
1515        }
1516    }
1517}
1518
1519fn session_retention_limits() -> SessionRetentionLimits {
1520    SessionRetentionLimits::from_env()
1521}
1522
1523fn parse_env_value<T>(key: &str) -> Option<T>
1524where
1525    T: FromStr,
1526{
1527    read_env_var(key)?.trim().parse().ok()
1528}
1529
1530fn apply_session_retention_best_effort(sessions_dir: &Path) {
1531    if let Err(err) = apply_session_retention(sessions_dir) {
1532        tracing::warn!(
1533            sessions_dir = %sessions_dir.display(),
1534            error = %err,
1535            "Failed to prune session archives"
1536        );
1537    }
1538}
1539
1540fn apply_session_retention(sessions_dir: &Path) -> Result<()> {
1541    apply_session_retention_with_limits(sessions_dir, session_retention_limits())
1542}
1543
1544fn apply_session_retention_with_limits(sessions_dir: &Path, limits: SessionRetentionLimits) -> Result<()> {
1545    let mut entries = collect_session_entries(sessions_dir)?;
1546
1547    if entries.is_empty() {
1548        return Ok(());
1549    }
1550
1551    let now = SystemTime::now();
1552    let age_cutoff = if limits.max_age_days == 0 {
1553        now
1554    } else {
1555        now.checked_sub(Duration::from_secs(limits.max_age_days.saturating_mul(SECONDS_PER_DAY)))
1556            .unwrap_or(UNIX_EPOCH)
1557    };
1558
1559    let (expired, retained): (Vec<_>, Vec<_>) = entries.into_iter().partition(|entry| entry.modified <= age_cutoff);
1560    remove_session_files(expired);
1561    entries = retained;
1562
1563    entries.sort_by_key(|a| std::cmp::Reverse(a.modified));
1564
1565    if limits.max_files > 0 && entries.len() > limits.max_files {
1566        let overflow = entries.split_off(limits.max_files);
1567        remove_session_files(overflow);
1568    }
1569
1570    if limits.max_total_size_bytes == 0 || entries.is_empty() {
1571        return Ok(());
1572    }
1573
1574    let mut total_size = 0u64;
1575    let mut keep_entries = Vec::with_capacity(entries.len());
1576    let mut size_overflow = Vec::new();
1577
1578    for entry in entries {
1579        let projected = total_size.saturating_add(entry.size);
1580        if keep_entries.is_empty() || projected <= limits.max_total_size_bytes {
1581            total_size = projected;
1582            keep_entries.push(entry);
1583        } else {
1584            size_overflow.push(entry);
1585        }
1586    }
1587
1588    remove_session_files(size_overflow);
1589    Ok(())
1590}
1591
1592fn collect_session_entries(sessions_dir: &Path) -> Result<Vec<SessionFileEntry>> {
1593    if !sessions_dir.exists() {
1594        return Ok(Vec::new());
1595    }
1596
1597    let mut entries = Vec::new();
1598    for entry in fs::read_dir(sessions_dir)
1599        .with_context(|| format!("failed to read session directory for retention: {}", sessions_dir.display()))?
1600    {
1601        let entry = match entry {
1602            Ok(value) => value,
1603            Err(err) => {
1604                tracing::warn!(
1605                    sessions_dir = %sessions_dir.display(),
1606                    error = %err,
1607                    "Failed to read a session archive entry"
1608                );
1609                continue;
1610            }
1611        };
1612        let path = entry.path();
1613        if !is_session_file(&path) {
1614            continue;
1615        }
1616        let metadata = match fs::symlink_metadata(&path) {
1617            Ok(value) => value,
1618            Err(err) => {
1619                tracing::warn!(
1620                    path = %path.display(),
1621                    error = %err,
1622                    "Failed to read session archive metadata"
1623                );
1624                continue;
1625            }
1626        };
1627        if !metadata.is_file() {
1628            continue;
1629        }
1630        let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
1631        entries.push(SessionFileEntry { path, modified, size: metadata.len() });
1632    }
1633
1634    Ok(entries)
1635}
1636
1637fn remove_session_files(entries: Vec<SessionFileEntry>) {
1638    for entry in entries {
1639        if let Err(err) = fs::remove_file(&entry.path) {
1640            tracing::warn!(
1641                path = %entry.path.display(),
1642                error = %err,
1643                "Failed to remove session archive"
1644            );
1645        }
1646    }
1647}
1648
1649fn truncate_preview(input: &str, max_chars: usize) -> String {
1650    vtcode_commons::formatting::truncate_within(input, max_chars, "…")
1651}
1652
1653fn compact_snapshot_to_max_bytes(mut snapshot: SessionSnapshot, max_bytes: usize) -> Result<SessionSnapshot> {
1654    if max_bytes == 0 {
1655        minimize_snapshot_payload(&mut snapshot);
1656        return Ok(snapshot);
1657    }
1658
1659    while serde_json::to_vec(&snapshot)?.len() > max_bytes {
1660        if trim_oldest_snapshot_entries(&mut snapshot) {
1661            continue;
1662        }
1663        if strip_snapshot_overhead(&mut snapshot) {
1664            continue;
1665        }
1666        if shrink_snapshot_strings(&mut snapshot) {
1667            continue;
1668        }
1669        break;
1670    }
1671
1672    if serde_json::to_vec(&snapshot)?.len() > max_bytes {
1673        minimize_snapshot_payload(&mut snapshot);
1674        let _ = shrink_snapshot_strings(&mut snapshot);
1675    }
1676
1677    Ok(snapshot)
1678}
1679
1680fn prepare_snapshot_for_write(snapshot: SessionSnapshot) -> Result<Option<SessionSnapshot>> {
1681    if !history_persistence_enabled() {
1682        return Ok(None);
1683    }
1684
1685    let max_bytes = session_history_settings().max_bytes;
1686    let snapshot = match max_bytes {
1687        Some(max_bytes) => compact_snapshot_to_max_bytes(snapshot, max_bytes)?,
1688        None => snapshot,
1689    };
1690
1691    Ok(Some(snapshot))
1692}
1693
1694fn minimize_snapshot_payload(snapshot: &mut SessionSnapshot) {
1695    snapshot.messages.clear();
1696    snapshot.transcript.clear();
1697    snapshot.distinct_tools.clear();
1698    snapshot.error_logs.clear();
1699    if let Some(progress) = snapshot.progress.as_mut() {
1700        progress.recent_messages.clear();
1701        progress.tool_summaries.clear();
1702        progress.token_usage = None;
1703        progress.max_context_tokens = None;
1704        progress.loaded_skills.clear();
1705    }
1706}
1707
1708fn trim_oldest_snapshot_entries(snapshot: &mut SessionSnapshot) -> bool {
1709    let mut changed = false;
1710
1711    if snapshot.messages.len() > 1 {
1712        snapshot.messages.remove(0);
1713        changed = true;
1714    }
1715
1716    if snapshot.transcript.len() > 1 {
1717        snapshot.transcript.remove(0);
1718        changed = true;
1719    }
1720
1721    if let Some(progress) = snapshot.progress.as_mut()
1722        && progress.recent_messages.len() > 1
1723    {
1724        progress.recent_messages.remove(0);
1725        changed = true;
1726    }
1727
1728    changed
1729}
1730
1731fn strip_snapshot_overhead(snapshot: &mut SessionSnapshot) -> bool {
1732    let mut changed = false;
1733
1734    if !snapshot.transcript.is_empty() {
1735        snapshot.transcript.clear();
1736        changed = true;
1737    }
1738    if !snapshot.distinct_tools.is_empty() {
1739        snapshot.distinct_tools.clear();
1740        changed = true;
1741    }
1742    if !snapshot.error_logs.is_empty() {
1743        snapshot.error_logs.clear();
1744        changed = true;
1745    }
1746
1747    if let Some(progress) = snapshot.progress.as_mut() {
1748        if !progress.tool_summaries.is_empty() {
1749            progress.tool_summaries.clear();
1750            changed = true;
1751        }
1752        if progress.token_usage.take().is_some() {
1753            changed = true;
1754        }
1755        if progress.max_context_tokens.take().is_some() {
1756            changed = true;
1757        }
1758        if !progress.loaded_skills.is_empty() {
1759            progress.loaded_skills.clear();
1760            changed = true;
1761        }
1762    }
1763
1764    changed
1765}
1766
1767fn shrink_snapshot_strings(snapshot: &mut SessionSnapshot) -> bool {
1768    let mut changed = shrink_snapshot_metadata(&mut snapshot.metadata);
1769
1770    for transcript in &mut snapshot.transcript {
1771        changed |= shrink_string(transcript);
1772    }
1773
1774    for message in &mut snapshot.messages {
1775        changed |= shrink_session_message(message);
1776    }
1777
1778    for error_log in &mut snapshot.error_logs {
1779        changed |= shrink_string(&mut error_log.message);
1780    }
1781
1782    if let Some(progress) = snapshot.progress.as_mut() {
1783        for message in &mut progress.recent_messages {
1784            changed |= shrink_session_message(message);
1785        }
1786        if let Some(token_usage) = progress.token_usage.as_mut() {
1787            changed |= shrink_string(token_usage);
1788        }
1789    }
1790
1791    changed
1792}
1793
1794fn shrink_session_message(message: &mut SessionMessage) -> bool {
1795    let mut changed = false;
1796    changed |= shrink_message_content(&mut message.content);
1797
1798    if let Some(reasoning) = message.reasoning.as_mut() {
1799        changed |= shrink_string(reasoning);
1800    }
1801    if message.reasoning_details.take().is_some() {
1802        changed = true;
1803    }
1804    if let Some(tool_call_id) = message.tool_call_id.as_mut() {
1805        changed |= shrink_string(tool_call_id);
1806    }
1807    if let Some(origin_tool) = message.origin_tool.as_mut() {
1808        changed |= shrink_string(origin_tool);
1809    }
1810    if let Some(tool_calls) = message.tool_calls.as_mut() {
1811        for tool_call in tool_calls.iter_mut() {
1812            changed |= shrink_string(&mut tool_call.id);
1813            changed |= shrink_string(&mut tool_call.call_type);
1814            if let Some(function) = tool_call.function.as_mut() {
1815                changed |= shrink_string(&mut function.name);
1816                changed |= shrink_string(&mut function.arguments);
1817            }
1818            if let Some(text) = tool_call.text.as_mut() {
1819                changed |= shrink_string(text);
1820            }
1821            if let Some(thought_signature) = tool_call.thought_signature.as_mut() {
1822                changed |= shrink_string(thought_signature);
1823            }
1824        }
1825    }
1826
1827    changed
1828}
1829
1830fn shrink_snapshot_metadata(metadata: &mut SessionArchiveMetadata) -> bool {
1831    let mut changed = false;
1832
1833    changed |= shrink_string(&mut metadata.workspace_label);
1834    changed |= shrink_string(&mut metadata.workspace_path);
1835    changed |= shrink_string(&mut metadata.model);
1836    changed |= shrink_string(&mut metadata.provider);
1837    changed |= shrink_string(&mut metadata.theme);
1838    changed |= shrink_string(&mut metadata.reasoning_effort);
1839    changed |= shrink_optional_string(&mut metadata.debug_log_path);
1840    changed |= shrink_optional_string(&mut metadata.prompt_cache_lineage_id);
1841    changed |= shrink_optional_string(&mut metadata.external_thread_id);
1842    changed |= shrink_optional_string(&mut metadata.parent_session_id);
1843    changed |= shrink_optional_string(&mut metadata.primary_agent);
1844
1845    for skill in &mut metadata.loaded_skills {
1846        changed |= shrink_string(skill);
1847    }
1848
1849    if let Some(continuation) = metadata.continuation_metadata.as_mut() {
1850        if continuation.max_budget_usd_micros == Some(0) {
1851            continuation.max_budget_usd_micros = None;
1852            changed = true;
1853        }
1854        if continuation.actual_cost_usd_micros == Some(0) {
1855            continuation.actual_cost_usd_micros = None;
1856            changed = true;
1857        }
1858        if !continuation.summary_available
1859            && continuation.recommended_action == Some(SessionContinuationRecommendedAction::ContinueFromSummary)
1860        {
1861            continuation.recommended_action = None;
1862            changed = true;
1863        }
1864    }
1865
1866    changed
1867}
1868
1869fn shrink_message_content(content: &mut MessageContent) -> bool {
1870    match content {
1871        MessageContent::Text(text) => shrink_string(text),
1872        MessageContent::Parts(parts) => {
1873            let mut changed = false;
1874            for part in parts {
1875                changed |= match part {
1876                    crate::llm::provider::ContentPart::Text { text } => shrink_string(text),
1877                    crate::llm::provider::ContentPart::Image { data, mime_type, .. } => {
1878                        shrink_string(data) | shrink_string(mime_type)
1879                    }
1880                    crate::llm::provider::ContentPart::File { filename, file_id, file_data, file_url, .. } => {
1881                        shrink_optional_string(filename)
1882                            | shrink_optional_string(file_id)
1883                            | shrink_optional_string(file_data)
1884                            | shrink_optional_string(file_url)
1885                    }
1886                };
1887            }
1888            changed
1889        }
1890    }
1891}
1892
1893fn shrink_optional_string(value: &mut Option<String>) -> bool {
1894    value.as_mut().is_some_and(shrink_string)
1895}
1896
1897fn shrink_string(value: &mut String) -> bool {
1898    const MIN_RETAINED_CHARS: usize = 8;
1899    const TRUNCATION_MARKER: &str = "...";
1900
1901    if value.len() <= MIN_RETAINED_CHARS + TRUNCATION_MARKER.len() {
1902        return false;
1903    }
1904
1905    let keep_len = (value.len() / 2).max(MIN_RETAINED_CHARS);
1906    let prefix_len = keep_len.saturating_sub(TRUNCATION_MARKER.len());
1907    value.truncate(prefix_len);
1908    value.push_str(TRUNCATION_MARKER);
1909    true
1910}
1911
1912fn sanitize_component(value: &str) -> String {
1913    let mut normalized = String::new();
1914    let mut last_was_separator = false;
1915    for ch in value.chars() {
1916        if ch.is_ascii_alphanumeric() {
1917            normalized.push(ch.to_ascii_lowercase());
1918            last_was_separator = false;
1919        } else if matches!(ch, '-' | '_') {
1920            if !last_was_separator {
1921                normalized.push(ch);
1922                last_was_separator = true;
1923            }
1924        } else if !last_was_separator {
1925            normalized.push('-');
1926            last_was_separator = true;
1927        }
1928    }
1929
1930    let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
1931    if trimmed.is_empty() {
1932        "workspace".to_owned()
1933    } else {
1934        trimmed.to_owned()
1935    }
1936}
1937
1938fn is_session_file(path: &Path) -> bool {
1939    matches!(
1940        path.extension().and_then(|ext| ext.to_str()),
1941        Some(ext)
1942            if ext.eq_ignore_ascii_case(SESSION_FILE_EXTENSION)
1943                || ext.eq_ignore_ascii_case("jsonl")
1944                || ext.eq_ignore_ascii_case("log")
1945    )
1946}
1947
1948#[cfg(test)]
1949mod metadata_compat_tests {
1950    use super::{SessionArchive, SessionArchiveMetadata};
1951
1952    #[test]
1953    fn primary_agent_metadata_is_backward_compatible() {
1954        let metadata = serde_json::from_value::<SessionArchiveMetadata>(serde_json::json!({
1955            "workspace_label": "ws",
1956            "workspace_path": "/tmp/ws",
1957            "model": "gpt-5.6-sol",
1958            "provider": "openai",
1959            "theme": "test",
1960            "reasoning_effort": "low"
1961        }))
1962        .expect("deserialize metadata without primary_agent");
1963
1964        assert_eq!(metadata.primary_agent, None);
1965
1966        let value = serde_json::to_value(&metadata).expect("serialize metadata");
1967        assert!(value.get("primary_agent").is_none());
1968    }
1969
1970    #[test]
1971    fn set_primary_agent_records_mode_on_archive() {
1972        let metadata = SessionArchiveMetadata::new("ws", "/tmp/ws", "gpt-5.6-sol", "openai", "test", "low");
1973        let mut archive = tokio::runtime::Runtime::new()
1974            .expect("runtime")
1975            .block_on(SessionArchive::new_with_identifier(metadata, "ws-0000".to_string()))
1976            .expect("create archive");
1977        assert_eq!(archive.metadata().primary_agent, None);
1978
1979        archive.set_primary_agent("plan");
1980        assert_eq!(archive.metadata().primary_agent.as_deref(), Some("plan"));
1981    }
1982}
1983
1984#[cfg(test)]
1985#[path = "session_archive_tests.rs"]
1986mod tests;