Skip to main content

vtcode_core/utils/session_archive/
metadata.rs

1use serde::{Deserialize, Serialize};
2use uuid::Uuid;
3
4#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
5pub struct SessionArchiveMetadata {
6    pub workspace_label: String,
7    pub workspace_path: String,
8    pub model: String,
9    pub provider: String,
10    pub theme: String,
11    pub reasoning_effort: String,
12    #[serde(default, skip_serializing_if = "Option::is_none")]
13    pub debug_log_path: Option<String>,
14    /// Names of skills loaded in this session
15    #[serde(default)]
16    pub loaded_skills: Vec<String>,
17    #[serde(default, skip_serializing_if = "Option::is_none")]
18    pub prompt_cache_lineage_id: Option<String>,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub external_thread_id: Option<String>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub parent_session_id: Option<String>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub primary_agent: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub fork_mode: Option<SessionForkMode>,
27    #[serde(default, skip_serializing_if = "Option::is_none")]
28    pub continuation_metadata: Option<SessionContinuationMetadata>,
29}
30
31#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
32#[serde(rename_all = "snake_case")]
33pub enum SessionForkMode {
34    FullCopy,
35    Summarized,
36}
37
38#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
39#[serde(rename_all = "snake_case")]
40pub enum SessionContinuationExhaustionReason {
41    MaxBudgetUsd,
42}
43
44#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
45#[serde(rename_all = "snake_case")]
46pub enum SessionContinuationRecommendedAction {
47    ContinueFromSummary,
48    ContinueFullHistory,
49    StartFresh,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
53pub struct SessionContinuationMetadata {
54    pub exhaustion_reason: SessionContinuationExhaustionReason,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub max_budget_usd_micros: Option<u64>,
57    #[serde(default, skip_serializing_if = "Option::is_none")]
58    pub actual_cost_usd_micros: Option<u64>,
59    #[serde(default)]
60    pub summary_available: bool,
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub recommended_action: Option<SessionContinuationRecommendedAction>,
63}
64
65impl SessionContinuationMetadata {
66    pub fn budget_limit(max_budget_usd: f64, actual_cost_usd: f64, summary_available: bool) -> Self {
67        Self {
68            exhaustion_reason: SessionContinuationExhaustionReason::MaxBudgetUsd,
69            max_budget_usd_micros: usd_to_micros(max_budget_usd),
70            actual_cost_usd_micros: usd_to_micros(actual_cost_usd),
71            summary_available,
72            recommended_action: Some(SessionContinuationRecommendedAction::ContinueFromSummary),
73        }
74    }
75
76    pub fn max_budget_usd(&self) -> Option<f64> {
77        self.max_budget_usd_micros.map(micros_to_usd)
78    }
79
80    pub fn actual_cost_usd(&self) -> Option<f64> {
81        self.actual_cost_usd_micros.map(micros_to_usd)
82    }
83
84    pub fn is_budget_limit(&self) -> bool {
85        matches!(self.exhaustion_reason, SessionContinuationExhaustionReason::MaxBudgetUsd)
86    }
87}
88
89fn usd_to_micros(value: f64) -> Option<u64> {
90    if !value.is_finite() || value.is_sign_negative() {
91        return None;
92    }
93
94    #[allow(
95        clippy::cast_sign_loss,
96        reason = "Intentional compatibility, platform, or test-only suppression."
97    )]
98    Some((value * 1_000_000.0).round() as u64)
99}
100
101fn micros_to_usd(value: u64) -> f64 {
102    value as f64 / 1_000_000.0
103}
104
105impl SessionArchiveMetadata {
106    pub fn new(
107        workspace_label: impl Into<String>,
108        workspace_path: impl Into<String>,
109        model: impl Into<String>,
110        provider: impl Into<String>,
111        theme: impl Into<String>,
112        reasoning_effort: impl Into<String>,
113    ) -> Self {
114        Self {
115            workspace_label: workspace_label.into(),
116            workspace_path: workspace_path.into(),
117            model: model.into(),
118            provider: provider.into(),
119            theme: theme.into(),
120            reasoning_effort: reasoning_effort.into(),
121            debug_log_path: None,
122            loaded_skills: Vec::new(),
123            prompt_cache_lineage_id: None,
124            external_thread_id: None,
125            parent_session_id: None,
126            primary_agent: None,
127            fork_mode: None,
128            continuation_metadata: None,
129        }
130    }
131
132    /// Set loaded skills for this session
133    pub fn with_loaded_skills(mut self, skills: Vec<String>) -> Self {
134        self.loaded_skills = skills;
135        self
136    }
137
138    /// Set debug log path associated with this archive.
139    pub fn with_debug_log_path(mut self, path: Option<String>) -> Self {
140        self.debug_log_path = path;
141        self
142    }
143
144    pub fn with_prompt_cache_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
145        self.prompt_cache_lineage_id = Some(lineage_id.into());
146        self
147    }
148
149    pub fn with_external_thread_id(mut self, thread_id: impl Into<String>) -> Self {
150        self.external_thread_id = Some(thread_id.into());
151        self
152    }
153
154    pub fn ensure_prompt_cache_lineage_id(mut self) -> Self {
155        if self.prompt_cache_lineage_id.is_none() {
156            self.prompt_cache_lineage_id = Some(format!("lineage-{}", Uuid::new_v4()));
157        }
158        self
159    }
160
161    pub fn with_parent_session_id(mut self, session_id: impl Into<String>) -> Self {
162        self.parent_session_id = Some(session_id.into());
163        self
164    }
165
166    pub fn with_primary_agent(mut self, primary_agent: impl Into<String>) -> Self {
167        self.primary_agent = Some(primary_agent.into());
168        self
169    }
170
171    pub fn with_fork_mode(mut self, fork_mode: SessionForkMode) -> Self {
172        self.fork_mode = Some(fork_mode);
173        self
174    }
175
176    pub fn with_continuation_metadata(mut self, continuation_metadata: Option<SessionContinuationMetadata>) -> Self {
177        self.continuation_metadata = continuation_metadata;
178        self
179    }
180
181    pub fn budget_limit_continuation(&self) -> Option<&SessionContinuationMetadata> {
182        self.continuation_metadata
183            .as_ref()
184            .filter(|continuation| continuation.is_budget_limit())
185    }
186
187    pub(crate) fn fork_seed(&self) -> Self {
188        Self {
189            workspace_label: self.workspace_label.clone(),
190            workspace_path: self.workspace_path.clone(),
191            model: self.model.clone(),
192            provider: self.provider.clone(),
193            theme: self.theme.clone(),
194            reasoning_effort: self.reasoning_effort.clone(),
195            debug_log_path: self.debug_log_path.clone(),
196            loaded_skills: self.loaded_skills.clone(),
197            prompt_cache_lineage_id: self.prompt_cache_lineage_id.clone(),
198            external_thread_id: self.external_thread_id.clone(),
199            parent_session_id: None,
200            primary_agent: self.primary_agent.clone(),
201            fork_mode: None,
202            continuation_metadata: None,
203        }
204    }
205}