vtcode_core/utils/session_archive/
snapshot.rs1use 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 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub progress: Option<Box<SessionProgress>>,
29 #[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 #[serde(default)]
47 pub loaded_skills: Vec<String>,
48 #[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#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct VerifiedSessionArchiveIdentifier(pub(crate) String);
67
68impl VerifiedSessionArchiveIdentifier {
69 #[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 pub turn_diagnostics: Option<SnapshotTurnDiagnostics>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
92pub enum SessionProgressPersistenceStatus {
93 Persisted(PathBuf),
95 Throttled(PathBuf),
97 Disabled(PathBuf),
99}
100
101impl SessionProgressPersistenceStatus {
102 #[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 #[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 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}