Skip to main content

vtcode_core/utils/session_archive/
snapshot.rs

1use std::env;
2use std::path::{Path, PathBuf};
3
4use anyhow::{Context, Result};
5use chrono::{DateTime, Utc};
6use serde::{Deserialize, Serialize};
7
8use super::identifier::validate_session_identifier;
9use super::message::SessionMessage;
10use super::metadata::SessionArchiveMetadata;
11use super::shrink::truncate_preview;
12use crate::core::agent::snapshots::SnapshotTurnDiagnostics;
13use crate::llm::provider::MessageRole;
14use crate::utils::error_log_collector::ErrorLogEntry;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
17pub struct SessionSnapshot {
18    pub metadata: SessionArchiveMetadata,
19    pub started_at: DateTime<Utc>,
20    pub ended_at: DateTime<Utc>,
21    pub total_messages: usize,
22    pub distinct_tools: Vec<String>,
23    pub transcript: Vec<String>,
24    #[serde(default)]
25    pub messages: Vec<SessionMessage>,
26    // SessionProgress is heavy and frequently absent in final snapshots.
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub progress: Option<Box<SessionProgress>>,
29    /// ERROR-level log entries captured during the session for post-mortem debugging.
30    #[serde(default, skip_serializing_if = "Vec::is_empty")]
31    pub error_logs: Vec<ErrorLogEntry>,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
35pub struct SessionProgress {
36    pub turn_number: usize,
37    #[serde(default)]
38    pub recent_messages: Vec<SessionMessage>,
39    #[serde(default)]
40    pub tool_summaries: Vec<String>,
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub token_usage: Option<String>,
43    #[serde(default, skip_serializing_if = "Option::is_none")]
44    pub max_context_tokens: Option<usize>,
45    /// Names of skills loaded at checkpoint time
46    #[serde(default)]
47    pub loaded_skills: Vec<String>,
48    /// Diagnostics for the most recently checkpointed turn, including turns
49    /// that ended blocked, cancelled, or aborted before a turn snapshot.
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub turn_diagnostics: Option<SnapshotTurnDiagnostics>,
52}
53
54#[derive(Debug, Clone)]
55pub struct SessionListing {
56    pub path: PathBuf,
57    pub snapshot: SessionSnapshot,
58}
59
60/// An archive identifier derived from a loaded session listing.
61///
62/// The inner value is crate-private so callers cannot mark an arbitrary
63/// runtime/session ID as resumable. Obtain one from
64/// [`SessionArchive::verify_persisted_resume_identifier`].
65#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct VerifiedSessionArchiveIdentifier(pub(crate) String);
67
68impl VerifiedSessionArchiveIdentifier {
69    /// Borrow the verified archive identifier.
70    #[must_use]
71    pub fn as_str(&self) -> &str {
72        &self.0
73    }
74}
75
76#[derive(Debug, Clone)]
77pub struct SessionProgressArgs {
78    pub total_messages: usize,
79    pub distinct_tools: Vec<String>,
80    pub messages: Vec<SessionMessage>,
81    pub recent_messages: Vec<SessionMessage>,
82    pub turn_number: usize,
83    pub token_usage: Option<String>,
84    pub max_context_tokens: Option<usize>,
85    pub loaded_skills: Option<Vec<String>>,
86    /// Optional diagnostics for the turn represented by this checkpoint.
87    pub turn_diagnostics: Option<SnapshotTurnDiagnostics>,
88}
89
90/// Outcome of an asynchronous session-progress checkpoint.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum SessionProgressPersistenceStatus {
93    /// The archive file was written successfully.
94    Persisted(PathBuf),
95    /// The call was intentionally skipped by the progress throttle.
96    Throttled(PathBuf),
97    /// History persistence is disabled, so no archive file was written.
98    Disabled(PathBuf),
99}
100
101impl SessionProgressPersistenceStatus {
102    /// Return the archive path associated with this checkpoint attempt.
103    #[must_use]
104    pub fn path(&self) -> &Path {
105        match self {
106            Self::Persisted(path) | Self::Throttled(path) | Self::Disabled(path) => path,
107        }
108    }
109
110    /// Return whether this outcome guarantees that the current snapshot was
111    /// written to the archive.
112    #[must_use]
113    pub fn is_persisted(&self) -> bool {
114        matches!(self, Self::Persisted(_))
115    }
116}
117
118impl SessionListing {
119    pub fn identifier(&self) -> String {
120        self.path
121            .file_stem()
122            .and_then(|value| value.to_str())
123            .map(|value| value.to_string())
124            .unwrap_or_else(|| self.path.display().to_string())
125    }
126
127    /// Return an identifier that is safe to advertise as an archive resume target.
128    pub(crate) fn verified_identifier(&self) -> Result<VerifiedSessionArchiveIdentifier> {
129        let identifier = self
130            .path
131            .file_stem()
132            .and_then(|value| value.to_str())
133            .with_context(|| format!("session archive path has no valid identifier: {}", self.path.display()))?;
134        validate_session_identifier(identifier)?;
135        Ok(VerifiedSessionArchiveIdentifier(identifier.to_owned()))
136    }
137
138    pub fn first_prompt_preview(&self) -> Option<String> {
139        self.preview_for_role(MessageRole::User)
140    }
141
142    pub fn first_reply_preview(&self) -> Option<String> {
143        self.preview_for_role(MessageRole::Assistant)
144    }
145
146    fn preview_for_role(&self, role: MessageRole) -> Option<String> {
147        self.snapshot.messages.iter().find_map(|message| {
148            if message.role != role {
149                return None;
150            }
151
152            let text_projection = message.content.as_text();
153            if text_projection.trim().is_empty() {
154                return None;
155            }
156
157            text_projection.lines().find_map(|line| {
158                let trimmed = line.trim();
159                if trimmed.is_empty() {
160                    None
161                } else {
162                    Some(truncate_preview(trimmed, 80))
163                }
164            })
165        })
166    }
167}
168
169fn normalize_workspace_for_match(path: &Path) -> PathBuf {
170    let absolute = if path.is_absolute() {
171        path.to_path_buf()
172    } else {
173        env::current_dir().unwrap_or_else(|_| PathBuf::from(".")).join(path)
174    };
175
176    crate::utils::path::normalize_path(&absolute)
177}
178
179pub fn session_workspace_path(listing: &SessionListing) -> Option<PathBuf> {
180    let raw = listing.snapshot.metadata.workspace_path.trim();
181    if raw.is_empty() { None } else { Some(PathBuf::from(raw)) }
182}
183
184pub fn session_listing_matches_workspace(listing: &SessionListing, workspace: &Path) -> bool {
185    let Some(session_workspace) = session_workspace_path(listing) else {
186        return false;
187    };
188
189    normalize_workspace_for_match(&session_workspace) == normalize_workspace_for_match(workspace)
190}