use crate::config::constants::defaults;
use crate::config::{HistoryPersistence, VTCodeConfig};
use crate::llm::provider::{AssistantPhase, Message, MessageContent, MessageRole, ToolCall};
use crate::telemetry::perf::PerfSpan;
use crate::utils::dot_config::DotManager;
use crate::utils::error_log_collector::ErrorLogEntry;
use crate::utils::file_utils::{
ensure_dir_exists, read_json_file, read_json_file_sync, write_json_file, write_json_file_sync,
};
use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use uuid::Uuid;
const SESSION_FILE_PREFIX: &str = "session";
const SESSION_FILE_EXTENSION: &str = "json";
pub const SESSION_DIR_ENV: &str = "VT_SESSION_DIR";
pub const SESSION_MAX_FILES_ENV: &str = "VT_SESSION_MAX_FILES";
pub const SESSION_MAX_AGE_DAYS_ENV: &str = "VT_SESSION_MAX_AGE_DAYS";
pub const SESSION_MAX_SIZE_MB_ENV: &str = "VT_SESSION_MAX_SIZE_MB";
const DEFAULT_SESSION_MAX_FILES: usize = 100;
const DEFAULT_SESSION_MAX_AGE_DAYS: u64 = 14;
const DEFAULT_SESSION_MAX_SIZE_MB: u64 = 100;
const BYTES_PER_MB: u64 = 1024 * 1024;
const SECONDS_PER_DAY: u64 = 24 * 60 * 60;
#[derive(Debug, Clone, Copy)]
struct SessionHistorySettings {
persistence: HistoryPersistence,
max_bytes: Option<usize>,
}
impl Default for SessionHistorySettings {
fn default() -> Self {
Self {
persistence: HistoryPersistence::File,
max_bytes: None,
}
}
}
static SESSION_HISTORY_SETTINGS: OnceLock<Mutex<SessionHistorySettings>> = OnceLock::new();
fn session_history_settings() -> SessionHistorySettings {
SESSION_HISTORY_SETTINGS
.get()
.and_then(|settings| settings.lock().ok().map(|guard| *guard))
.unwrap_or_default()
}
pub fn apply_session_history_config_from_vtcode(config: &VTCodeConfig) {
let settings = SessionHistorySettings {
persistence: config.history.persistence,
max_bytes: config.history.max_bytes,
};
let cell =
SESSION_HISTORY_SETTINGS.get_or_init(|| Mutex::new(SessionHistorySettings::default()));
if let Ok(mut guard) = cell.lock() {
*guard = settings;
}
}
pub fn history_persistence_enabled() -> bool {
matches!(
session_history_settings().persistence,
HistoryPersistence::File
)
}
#[cfg(test)]
mod test_env_overrides {
use hashbrown::HashMap;
use std::ffi::OsString;
use std::sync::{LazyLock, Mutex};
static OVERRIDES: LazyLock<Mutex<HashMap<String, Option<OsString>>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
pub(super) fn get(key: &str) -> Option<Option<OsString>> {
OVERRIDES.lock().ok().and_then(|map| map.get(key).cloned())
}
pub(super) fn set(key: &str, value: Option<OsString>) {
if let Ok(mut map) = OVERRIDES.lock() {
map.insert(key.to_owned(), value);
}
}
pub(super) fn clear(key: &str) {
if let Ok(mut map) = OVERRIDES.lock() {
map.remove(key);
}
}
}
fn read_env_var_os(key: &str) -> Option<std::ffi::OsString> {
#[cfg(test)]
if let Some(override_value) = test_env_overrides::get(key) {
return override_value;
}
env::var_os(key)
}
fn read_env_var(key: &str) -> Option<String> {
#[cfg(test)]
if let Some(override_value) = test_env_overrides::get(key) {
return override_value.map(|value| value.to_string_lossy().to_string());
}
env::var(key).ok()
}
#[cfg(test)]
fn set_test_env_override_path(key: &str, value: &Path) {
test_env_overrides::set(key, Some(value.as_os_str().to_os_string()));
}
#[cfg(test)]
fn clear_test_env_override(key: &str) {
test_env_overrides::clear(key);
}
#[cfg(test)]
pub(crate) fn override_sessions_dir_for_tests(path: &Path) {
set_test_env_override_path(SESSION_DIR_ENV, path);
}
#[cfg(test)]
pub(crate) fn clear_sessions_dir_override_for_tests() {
clear_test_env_override(SESSION_DIR_ENV);
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionArchiveMetadata {
pub workspace_label: String,
pub workspace_path: String,
pub model: String,
pub provider: String,
pub theme: String,
pub reasoning_effort: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_mode: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub debug_log_path: Option<String>,
#[serde(default)]
pub loaded_skills: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_cache_lineage_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub external_thread_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fork_mode: Option<SessionForkMode>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SessionForkMode {
FullCopy,
Summarized,
}
impl SessionArchiveMetadata {
pub fn new(
workspace_label: impl Into<String>,
workspace_path: impl Into<String>,
model: impl Into<String>,
provider: impl Into<String>,
theme: impl Into<String>,
reasoning_effort: impl Into<String>,
) -> Self {
Self {
workspace_label: workspace_label.into(),
workspace_path: workspace_path.into(),
model: model.into(),
provider: provider.into(),
theme: theme.into(),
reasoning_effort: reasoning_effort.into(),
session_mode: None,
debug_log_path: None,
loaded_skills: Vec::new(),
prompt_cache_lineage_id: None,
external_thread_id: None,
parent_session_id: None,
fork_mode: None,
}
}
pub fn with_loaded_skills(mut self, skills: Vec<String>) -> Self {
self.loaded_skills = skills;
self
}
pub fn with_debug_log_path(mut self, path: Option<String>) -> Self {
self.debug_log_path = path;
self
}
pub fn with_prompt_cache_lineage_id(mut self, lineage_id: impl Into<String>) -> Self {
self.prompt_cache_lineage_id = Some(lineage_id.into());
self
}
pub fn with_external_thread_id(mut self, thread_id: impl Into<String>) -> Self {
self.external_thread_id = Some(thread_id.into());
self
}
pub fn ensure_prompt_cache_lineage_id(mut self) -> Self {
if self.prompt_cache_lineage_id.is_none() {
self.prompt_cache_lineage_id = Some(format!("lineage-{}", Uuid::new_v4()));
}
self
}
pub fn with_parent_session_id(mut self, session_id: impl Into<String>) -> Self {
self.parent_session_id = Some(session_id.into());
self
}
pub fn with_fork_mode(mut self, fork_mode: SessionForkMode) -> Self {
self.fork_mode = Some(fork_mode);
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SessionMessage {
pub role: MessageRole,
pub content: MessageContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_details: Option<Vec<serde_json::Value>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_calls: Option<Vec<ToolCall>>,
#[serde(default)]
pub tool_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub phase: Option<AssistantPhase>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin_tool: Option<String>,
}
impl Eq for SessionMessage {}
impl SessionMessage {
fn base(role: MessageRole, content: MessageContent) -> Self {
Self {
role,
content,
reasoning: None,
reasoning_details: None,
tool_calls: None,
tool_call_id: None,
phase: None,
origin_tool: None,
}
}
pub fn new(role: MessageRole, content: impl Into<String>) -> Self {
Self::base(role, MessageContent::Text(content.into()))
}
pub fn with_content(role: MessageRole, content: MessageContent) -> Self {
Self::base(role, content)
}
pub fn with_tool_call_id(
role: MessageRole,
content: impl Into<String>,
tool_call_id: Option<String>,
) -> Self {
Self::with_tool_call_id_content(role, MessageContent::Text(content.into()), tool_call_id)
}
pub fn with_tool_call_id_content(
role: MessageRole,
content: MessageContent,
tool_call_id: Option<String>,
) -> Self {
let mut message = Self::base(role, content);
message.tool_call_id = tool_call_id;
message
}
}
impl From<&Message> for SessionMessage {
fn from(message: &Message) -> Self {
Self {
role: message.role.clone(),
content: message.content.clone(),
reasoning: message.reasoning.clone(),
reasoning_details: message.reasoning_details.clone(),
tool_calls: message.tool_calls.clone(),
tool_call_id: message.tool_call_id.clone(),
phase: message.phase,
origin_tool: message.origin_tool.clone(),
}
}
}
impl From<&SessionMessage> for Message {
fn from(message: &SessionMessage) -> Self {
Self {
role: message.role.clone(),
content: message.content.clone(),
reasoning: message.reasoning.clone(),
reasoning_details: message.reasoning_details.clone(),
tool_calls: message.tool_calls.clone(),
tool_call_id: message.tool_call_id.clone(),
phase: message.phase,
origin_tool: message.origin_tool.clone(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SessionSnapshot {
pub metadata: SessionArchiveMetadata,
pub started_at: DateTime<Utc>,
pub ended_at: DateTime<Utc>,
pub total_messages: usize,
pub distinct_tools: Vec<String>,
pub transcript: Vec<String>,
#[serde(default)]
pub messages: Vec<SessionMessage>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub progress: Option<SessionProgress>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub error_logs: Vec<ErrorLogEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct SessionProgress {
pub turn_number: usize,
#[serde(default)]
pub recent_messages: Vec<SessionMessage>,
#[serde(default)]
pub tool_summaries: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token_usage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_context_tokens: Option<usize>,
#[serde(default)]
pub loaded_skills: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct SessionListing {
pub path: PathBuf,
pub snapshot: SessionSnapshot,
}
#[derive(Debug, Clone)]
pub struct SessionProgressArgs {
pub total_messages: usize,
pub distinct_tools: Vec<String>,
pub recent_messages: Vec<SessionMessage>,
pub turn_number: usize,
pub token_usage: Option<String>,
pub max_context_tokens: Option<usize>,
pub loaded_skills: Option<Vec<String>>,
}
impl SessionListing {
pub fn identifier(&self) -> String {
self.path
.file_stem()
.and_then(|value| value.to_str())
.map(|value| value.to_string())
.unwrap_or_else(|| self.path.display().to_string())
}
pub fn first_prompt_preview(&self) -> Option<String> {
self.preview_for_role(MessageRole::User)
}
pub fn first_reply_preview(&self) -> Option<String> {
self.preview_for_role(MessageRole::Assistant)
}
fn preview_for_role(&self, role: MessageRole) -> Option<String> {
self.snapshot.messages.iter().find_map(|message| {
if message.role != role {
return None;
}
let text_projection = message.content.as_text();
if text_projection.trim().is_empty() {
return None;
}
text_projection.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
None
} else {
Some(truncate_preview(trimmed, 80))
}
})
})
}
}
fn normalize_workspace_for_match(path: &Path) -> PathBuf {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
env::current_dir()
.unwrap_or_else(|_| PathBuf::from("."))
.join(path)
};
crate::utils::path::normalize_path(&absolute)
}
pub fn session_workspace_path(listing: &SessionListing) -> Option<PathBuf> {
let raw = listing.snapshot.metadata.workspace_path.trim();
if raw.is_empty() {
None
} else {
Some(PathBuf::from(raw))
}
}
pub fn session_listing_matches_workspace(listing: &SessionListing, workspace: &Path) -> bool {
let Some(session_workspace) = session_workspace_path(listing) else {
return false;
};
normalize_workspace_for_match(&session_workspace) == normalize_workspace_for_match(workspace)
}
fn generate_unique_archive_path(
sessions_dir: &Path,
metadata: &SessionArchiveMetadata,
started_at: DateTime<Utc>,
custom_suffix: Option<&str>,
) -> PathBuf {
generate_unique_archive_path_for_label(
sessions_dir,
&metadata.workspace_label,
started_at,
custom_suffix,
)
}
fn generate_unique_archive_path_for_label(
sessions_dir: &Path,
workspace_label: &str,
started_at: DateTime<Utc>,
custom_suffix: Option<&str>,
) -> PathBuf {
if custom_suffix.is_some() {
return sessions_dir.join(archive_file_name_for_label(
workspace_label,
started_at,
custom_suffix,
None,
));
}
let mut attempt = 0u32;
loop {
let candidate = sessions_dir.join(archive_file_name_for_label(
workspace_label,
started_at,
None,
Some(attempt),
));
if !candidate.exists() {
return candidate;
}
attempt = attempt.wrapping_add(1);
}
}
fn archive_file_name_for_label(
workspace_label: &str,
started_at: DateTime<Utc>,
custom_suffix: Option<&str>,
attempt: Option<u32>,
) -> String {
let sanitized_label = sanitize_component(workspace_label);
let timestamp = started_at.format("%Y%m%dT%H%M%SZ").to_string();
if let Some(suffix) = custom_suffix {
return format!(
"{}-{}-{}-{}.{}",
SESSION_FILE_PREFIX,
sanitized_label,
timestamp,
sanitize_component(suffix),
SESSION_FILE_EXTENSION
);
}
let micros = started_at.timestamp_subsec_micros();
let pid = process::id();
let attempt_suffix = match attempt.unwrap_or_default() {
0 => String::new(),
value => format!("-{:02}", value),
};
format!(
"{}-{}-{}_{:06}-{:05}{}.{}",
SESSION_FILE_PREFIX,
sanitized_label,
timestamp,
micros,
pid,
attempt_suffix,
SESSION_FILE_EXTENSION
)
}
pub fn generate_session_archive_identifier(
workspace_label: &str,
custom_suffix: Option<String>,
) -> String {
let file_name = archive_file_name_for_label(
workspace_label,
Utc::now(),
custom_suffix.as_deref(),
Some(0),
);
Path::new(&file_name)
.file_stem()
.and_then(|stem| stem.to_str())
.map(str::to_owned)
.unwrap_or_else(|| {
format!(
"session-{}-{}",
sanitize_component(workspace_label),
process::id()
)
})
}
fn session_identifier_from_archive_path(path: &Path) -> Result<String> {
path.file_stem()
.and_then(|stem| stem.to_str())
.map(|value| value.to_string())
.ok_or_else(|| anyhow::anyhow!("failed to derive session identifier from archive path"))
}
fn is_valid_session_identifier(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 256
&& value
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_'))
}
pub async fn reserve_session_archive_identifier(
workspace_label: &str,
custom_suffix: Option<String>,
) -> Result<String> {
let sessions_dir = resolve_sessions_dir().await?;
apply_session_retention_best_effort(&sessions_dir);
let started_at = Utc::now();
let path = generate_unique_archive_path_for_label(
&sessions_dir,
workspace_label,
started_at,
custom_suffix.as_deref(),
);
session_identifier_from_archive_path(&path)
}
fn progress_transcript_from_recent_messages(recent_messages: &[SessionMessage]) -> Vec<String> {
let mut transcript = Vec::new();
for message in recent_messages {
if !matches!(message.role, MessageRole::User | MessageRole::Assistant) {
continue;
}
let content = message.content.trim();
let content: &str = content.as_ref();
if !content.is_empty()
&& transcript
.last()
.is_none_or(|last: &String| last.as_str() != content)
{
transcript.push(content.to_string());
}
}
clean_transcript_lines(&transcript)
}
fn clean_transcript_lines(lines: &[String]) -> Vec<String> {
let mut cleaned = Vec::new();
let mut seen_tool_blocks: HashMap<String, (usize, usize, String)> = HashMap::new();
let mut index = 0usize;
while index < lines.len() {
let line = lines[index].trim_end();
if should_reset_tool_dedupe_scope(line) {
seen_tool_blocks.clear();
}
if let Some(replacement) = normalize_recovery_line(line) {
push_clean_transcript_line(&mut cleaned, replacement);
index += 1;
continue;
}
if should_drop_transcript_line(line) {
index += 1;
continue;
}
if line.trim_start().starts_with("• ") {
let (summary, next_index) = summarize_tool_block(lines, index);
let signature = normalized_transcript_key(&summary);
if let Some((first_index, repeats, original_line)) =
seen_tool_blocks.get_mut(&signature)
{
*repeats += 1;
if let Some(existing) = cleaned.get_mut(*first_index) {
*existing = format_repeated_summary(original_line, *repeats);
}
} else {
let insertion_index = cleaned.len();
push_clean_transcript_line(&mut cleaned, summary);
if cleaned.len() > insertion_index {
seen_tool_blocks.insert(
signature,
(insertion_index, 1, cleaned[insertion_index].clone()),
);
}
}
index = next_index;
continue;
}
push_clean_transcript_line(&mut cleaned, line.to_string());
index += 1;
}
while cleaned.last().is_some_and(|line: &String| line.is_empty()) {
cleaned.pop();
}
cleaned
}
fn should_reset_tool_dedupe_scope(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty()
&& !line.starts_with(' ')
&& !trimmed.starts_with("• ")
&& !trimmed.starts_with("[!]")
}
fn should_drop_transcript_line(line: &str) -> bool {
let trimmed = line.trim();
trimmed.starts_with("Latest tool output:")
|| trimmed.starts_with("Latest user request:")
|| trimmed.starts_with("Tool output 1:")
|| trimmed.starts_with("Structured result with fields:")
|| trimmed.starts_with("Reuse the latest tool outputs already collected in this turn")
|| trimmed.starts_with("Interrupt received. Stopping task...")
}
fn normalize_recovery_line(line: &str) -> Option<String> {
let trimmed = line.trim();
if trimmed.starts_with("[!] Turn balancer:")
|| trimmed.starts_with("[!] Navigation Loop:")
|| trimmed.starts_with("[!] Navigation loop:")
{
return Some("Repeated low-signal tool churn triggered recovery.".to_string());
}
if trimmed.contains("I couldn't produce a final synthesis because the model returned no answer on the recovery pass.")
{
return Some("Recovery pass failed to produce a final synthesis.".to_string());
}
None
}
fn summarize_tool_block(lines: &[String], start: usize) -> (String, usize) {
let header = lines[start].trim().to_string();
let mut command_continuations = Vec::new();
let mut metadata = Vec::new();
let mut metadata_seen = HashSet::new();
let mut index = start + 1;
while index < lines.len() {
let raw = lines[index].trim_end();
let trimmed = raw.trim_start();
if trimmed.starts_with("• ") || trimmed.starts_with("[!]") || !is_tool_detail_line(trimmed)
{
break;
}
if let Some(continuation) = trimmed.strip_prefix("│ ") {
let continuation = continuation.trim();
if !continuation.is_empty() {
command_continuations.push(continuation.to_string());
}
} else if let Some(extra) = summarize_tool_detail(trimmed)
&& metadata_seen.insert(extra.clone())
{
metadata.push(extra);
}
index += 1;
}
let mut summary = header;
if !command_continuations.is_empty() {
summary.push(' ');
summary.push_str(&command_continuations.join(" "));
}
if !metadata.is_empty() {
summary.push_str(" [");
summary.push_str(&metadata.join(", "));
summary.push(']');
}
(collapse_whitespace(&summary), index)
}
fn is_tool_detail_line(line: &str) -> bool {
line.starts_with("│ ")
|| line.starts_with("â”” ")
|| line.starts_with("✓ ")
|| line.starts_with("✗ ")
|| line.starts_with("… +")
|| line.starts_with("Large output was spooled")
|| line == "(no output)"
}
fn summarize_tool_detail(line: &str) -> Option<String> {
let path = line
.strip_prefix("â”” Path:")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("path {value}"));
if path.is_some() {
return path;
}
let pattern = line
.strip_prefix("â”” Pattern:")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("pattern {value}"));
if pattern.is_some() {
return pattern;
}
let filter = line
.strip_prefix("â”” Filter:")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("filter {value}"));
if filter.is_some() {
return filter;
}
let glob = line
.strip_prefix("â”” Glob:")
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("glob {value}"));
if glob.is_some() {
return glob;
}
if let Some(status) = line.strip_prefix("✗ ") {
let status = status.trim();
if !status.is_empty() {
return Some(status.to_string());
}
}
None
}
fn collapse_whitespace(text: &str) -> String {
text.split_whitespace().collect::<Vec<_>>().join(" ")
}
fn normalized_transcript_key(text: &str) -> String {
collapse_whitespace(text).to_ascii_lowercase()
}
fn format_repeated_summary(line: &str, repeats: usize) -> String {
if repeats <= 1 {
return line.to_string();
}
format!("{line} (repeated x{repeats})")
}
fn push_clean_transcript_line(target: &mut Vec<String>, line: String) {
let trimmed = line.trim();
if trimmed.is_empty() {
if target.last().is_none_or(|last| !last.is_empty()) {
target.push(String::new());
}
return;
}
if target
.last()
.is_some_and(|last| normalized_transcript_key(last) == normalized_transcript_key(trimmed))
{
return;
}
target.push(line);
}
fn normalize_session_tool_name(name: &str) -> String {
crate::tools::tool_intent::canonical_unified_exec_tool_name(name)
.unwrap_or(name)
.to_string()
}
fn normalize_distinct_tools_for_summary(distinct_tools: &[String]) -> Vec<String> {
let mut normalized = Vec::with_capacity(distinct_tools.len());
let mut seen = std::collections::BTreeSet::new();
for tool in distinct_tools {
let mapped = normalize_session_tool_name(tool);
if seen.insert(mapped.clone()) {
normalized.push(mapped);
}
}
normalized
}
#[derive(Debug, Clone)]
pub struct SessionArchive {
path: PathBuf,
metadata: SessionArchiveMetadata,
started_at: DateTime<Utc>,
progress_throttle: Arc<Mutex<ProgressThrottle>>,
}
#[derive(Debug)]
struct ProgressThrottle {
last_written: Instant,
last_turn: usize,
}
impl ProgressThrottle {
fn new() -> Self {
let min_interval =
Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
let last_written = Instant::now()
.checked_sub(min_interval)
.unwrap_or_else(Instant::now);
Self {
last_written,
last_turn: 0,
}
}
}
impl SessionArchive {
fn from_path(
path: PathBuf,
metadata: SessionArchiveMetadata,
started_at: DateTime<Utc>,
) -> Self {
Self {
path,
metadata,
started_at,
progress_throttle: Arc::new(Mutex::new(ProgressThrottle::new())),
}
}
pub async fn new(
metadata: SessionArchiveMetadata,
custom_suffix: Option<String>,
) -> Result<Self> {
let sessions_dir = resolve_sessions_dir().await?;
apply_session_retention_best_effort(&sessions_dir);
let started_at = Utc::now();
let path = generate_unique_archive_path(
&sessions_dir,
&metadata,
started_at,
custom_suffix.as_deref(),
);
Ok(Self::from_path(path, metadata, started_at))
}
pub async fn new_with_identifier(
metadata: SessionArchiveMetadata,
session_identifier: String,
) -> Result<Self> {
let sessions_dir = resolve_sessions_dir().await?;
apply_session_retention_best_effort(&sessions_dir);
if !is_valid_session_identifier(&session_identifier) {
return Err(anyhow::anyhow!(
"Invalid session identifier '{}': only ASCII letters, digits, '-' and '_' are allowed",
session_identifier
));
}
let path = sessions_dir.join(format!("{}.{}", session_identifier, SESSION_FILE_EXTENSION));
if path.exists() {
return Err(anyhow::anyhow!(
"Session archive identifier '{}' already exists",
session_identifier
));
}
Ok(Self::from_path(path, metadata, Utc::now()))
}
pub fn resume_from_listing(listing: &SessionListing, metadata: SessionArchiveMetadata) -> Self {
Self::from_path(listing.path.clone(), metadata, listing.snapshot.started_at)
}
pub fn finalize(
&self,
transcript: Vec<String>,
total_messages: usize,
distinct_tools: Vec<String>,
messages: Vec<SessionMessage>,
) -> Result<PathBuf> {
use crate::utils::error_log_collector::drain_error_logs;
let distinct_tools = normalize_distinct_tools_for_summary(&distinct_tools);
let snapshot = SessionSnapshot {
metadata: self.metadata.clone(),
started_at: self.started_at,
ended_at: Utc::now(),
total_messages,
distinct_tools,
transcript: clean_transcript_lines(&transcript),
messages,
progress: None,
error_logs: drain_error_logs(),
};
let path = self.write_snapshot(snapshot)?;
if let Some(parent) = path.parent() {
apply_session_retention_best_effort(parent);
}
Ok(path)
}
pub fn persist_progress(&self, args: SessionProgressArgs) -> Result<PathBuf> {
use crate::utils::error_log_collector::drain_error_logs;
let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
perf.tag("mode", "sync");
let progress_transcript = progress_transcript_from_recent_messages(&args.recent_messages);
let distinct_tools = normalize_distinct_tools_for_summary(&args.distinct_tools);
let tool_summaries = distinct_tools.clone();
let snapshot = SessionSnapshot {
metadata: self.metadata.clone(),
started_at: self.started_at,
ended_at: Utc::now(),
total_messages: args.total_messages,
distinct_tools,
transcript: progress_transcript,
messages: args.recent_messages.clone(),
progress: Some(SessionProgress {
turn_number: args.turn_number,
recent_messages: args.recent_messages,
tool_summaries,
token_usage: args.token_usage,
max_context_tokens: args.max_context_tokens,
loaded_skills: args.loaded_skills.unwrap_or_default(),
}),
error_logs: drain_error_logs(),
};
self.write_snapshot(snapshot)
}
pub async fn persist_progress_async(&self, args: SessionProgressArgs) -> Result<PathBuf> {
use crate::utils::error_log_collector::drain_error_logs;
let mut perf = PerfSpan::new("vtcode.perf.session_progress_write_ms");
perf.tag("mode", "async");
if !self.should_persist_progress(args.turn_number)? {
return Ok(self.path.clone());
}
let progress_transcript = progress_transcript_from_recent_messages(&args.recent_messages);
let distinct_tools = normalize_distinct_tools_for_summary(&args.distinct_tools);
let tool_summaries = distinct_tools.clone();
let snapshot = SessionSnapshot {
metadata: self.metadata.clone(),
started_at: self.started_at,
ended_at: Utc::now(),
total_messages: args.total_messages,
distinct_tools,
transcript: progress_transcript,
messages: args.recent_messages.clone(),
progress: Some(SessionProgress {
turn_number: args.turn_number,
recent_messages: args.recent_messages,
tool_summaries,
token_usage: args.token_usage,
max_context_tokens: args.max_context_tokens,
loaded_skills: args.loaded_skills.unwrap_or_default(),
}),
error_logs: drain_error_logs(),
};
self.write_snapshot_async(snapshot).await
}
fn write_snapshot(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
if !history_persistence_enabled() {
return Ok(self.path.clone());
}
if let Some(max_bytes) = session_history_settings().max_bytes {
let snapshot = compact_snapshot_to_max_bytes(snapshot, max_bytes)?;
write_json_file_sync(&self.path, &snapshot)?;
} else {
write_json_file_sync(&self.path, &snapshot)?;
}
Ok(self.path.clone())
}
async fn write_snapshot_async(&self, snapshot: SessionSnapshot) -> Result<PathBuf> {
if !history_persistence_enabled() {
return Ok(self.path.clone());
}
if let Some(max_bytes) = session_history_settings().max_bytes {
let snapshot = compact_snapshot_to_max_bytes(snapshot, max_bytes)?;
write_json_file(&self.path, &snapshot).await?;
} else {
write_json_file(&self.path, &snapshot).await?;
}
Ok(self.path.clone())
}
fn should_persist_progress(&self, turn_number: usize) -> Result<bool> {
let min_interval =
Duration::from_millis(defaults::DEFAULT_SESSION_PROGRESS_MIN_INTERVAL_MS);
let min_turns = defaults::DEFAULT_SESSION_PROGRESS_MIN_TURN_DELTA;
let mut throttle = self
.progress_throttle
.lock()
.map_err(|err| anyhow::anyhow!("session progress throttle lock poisoned: {err}"))
.context("Failed to evaluate session progress persistence throttle")?;
if turn_number <= throttle.last_turn {
return Ok(false);
}
if throttle.last_written.elapsed() < min_interval
&& turn_number.saturating_sub(throttle.last_turn) < min_turns
{
return Ok(false);
}
throttle.last_written = Instant::now();
throttle.last_turn = turn_number;
Ok(true)
}
pub fn set_loaded_skills(&mut self, skills: Vec<String>) {
self.metadata.loaded_skills = skills;
}
pub fn path(&self) -> &Path {
&self.path
}
pub async fn fork(
source_snapshot: &SessionSnapshot,
custom_suffix: Option<String>,
) -> Result<Self> {
create_fork_archive(source_snapshot, custom_suffix, None).await
}
}
async fn create_fork_archive(
source_snapshot: &SessionSnapshot,
custom_suffix: Option<String>,
explicit_identifier: Option<String>,
) -> Result<SessionArchive> {
let sessions_dir = resolve_sessions_dir().await?;
apply_session_retention_best_effort(&sessions_dir);
let started_at = Utc::now();
let forked_metadata = SessionArchiveMetadata {
workspace_label: source_snapshot.metadata.workspace_label.clone(),
workspace_path: source_snapshot.metadata.workspace_path.clone(),
model: source_snapshot.metadata.model.clone(),
provider: source_snapshot.metadata.provider.clone(),
theme: source_snapshot.metadata.theme.clone(),
reasoning_effort: source_snapshot.metadata.reasoning_effort.clone(),
session_mode: source_snapshot.metadata.session_mode.clone(),
debug_log_path: source_snapshot.metadata.debug_log_path.clone(),
loaded_skills: source_snapshot.metadata.loaded_skills.clone(),
prompt_cache_lineage_id: source_snapshot.metadata.prompt_cache_lineage_id.clone(),
external_thread_id: source_snapshot.metadata.external_thread_id.clone(),
parent_session_id: None,
fork_mode: None,
};
let path = if let Some(session_identifier) = explicit_identifier {
if !is_valid_session_identifier(&session_identifier) {
return Err(anyhow::anyhow!(
"Invalid session identifier '{}': only ASCII letters, digits, '-' and '_' are allowed",
session_identifier
));
}
let candidate =
sessions_dir.join(format!("{}.{}", session_identifier, SESSION_FILE_EXTENSION));
if candidate.exists() {
return Err(anyhow::anyhow!(
"Session archive identifier '{}' already exists",
session_identifier
));
}
candidate
} else {
generate_unique_archive_path(
&sessions_dir,
&forked_metadata,
started_at,
custom_suffix.as_deref(),
)
};
Ok(SessionArchive::from_path(path, forked_metadata, started_at))
}
pub async fn list_recent_sessions(limit: usize) -> Result<Vec<SessionListing>> {
let sessions_dir = match resolve_sessions_dir().await {
Ok(dir) => dir,
Err(_) => return Ok(Vec::new()),
};
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut session_paths = Vec::new();
for entry in fs::read_dir(&sessions_dir).with_context(|| {
format!(
"failed to read session directory: {}",
sessions_dir.display()
)
})? {
let entry = entry.with_context(|| {
format!("failed to read session entry in {}", sessions_dir.display())
})?;
let path = entry.path();
if is_session_file(&path) {
session_paths.push(path);
}
}
const BATCH_SIZE: usize = 10;
let mut all_listings = Vec::new();
for batch in session_paths.chunks(BATCH_SIZE) {
let mut tasks = Vec::with_capacity(batch.len());
for path in batch {
let path = path.clone();
let task = tokio::task::spawn(async move {
read_json_file::<SessionSnapshot>(&path)
.await
.ok()
.map(|snapshot| SessionListing { path, snapshot })
});
tasks.push(task);
}
for task in tasks {
if let Ok(Some(listing)) = task.await {
all_listings.push(listing);
}
}
}
all_listings.sort_by(|a, b| b.snapshot.ended_at.cmp(&a.snapshot.ended_at));
if limit > 0 && all_listings.len() > limit {
all_listings.truncate(limit);
}
Ok(all_listings)
}
pub async fn find_session_by_identifier(identifier: &str) -> Result<Option<SessionListing>> {
let sessions_dir = match resolve_sessions_dir().await {
Ok(dir) => dir,
Err(_) => return Ok(None),
};
if !sessions_dir.exists() {
return Ok(None);
}
for entry in fs::read_dir(&sessions_dir).with_context(|| {
format!(
"failed to read session directory: {}",
sessions_dir.display()
)
})? {
let entry = entry.with_context(|| {
format!("failed to read session entry in {}", sessions_dir.display())
})?;
let path = entry.path();
if !is_session_file(&path) {
continue;
}
let stem_matches = path
.file_stem()
.and_then(|stem| stem.to_str())
.map(|stem| stem == identifier)
.unwrap_or(false);
if !stem_matches {
continue;
}
let snapshot: SessionSnapshot = read_json_file_sync(&path)?;
return Ok(Some(SessionListing { path, snapshot }));
}
Ok(None)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResult {
pub session_id: String,
pub session_path: PathBuf,
pub timestamp: DateTime<Utc>,
pub message_index: usize,
pub role: MessageRole,
pub content_snippet: String,
pub score: f32, }
pub async fn search_sessions(
query: &str,
session_limit: usize,
max_results: usize,
) -> Result<Vec<SearchResult>> {
let listings = list_recent_sessions(session_limit).await?;
let query_lower = query.to_lowercase();
let mut results = Vec::new();
for listing in listings {
for (idx, msg) in listing.snapshot.messages.iter().enumerate() {
let content = match &msg.content {
MessageContent::Text(t) => t.as_str(),
MessageContent::Parts(_) => continue, };
if let Some(pos) = content.to_lowercase().find(&query_lower) {
let start = pos.saturating_sub(50);
let end = (pos + query_lower.len() + 50).min(content.len());
let snippet = format!("...{}...", &content[start..end].replace('\n', " "));
results.push(SearchResult {
session_id: listing.identifier(),
session_path: listing.path.clone(),
timestamp: listing.snapshot.started_at, message_index: idx,
role: msg.role.clone(),
content_snippet: snippet,
score: 1.0, });
if results.len() >= max_results {
break;
}
}
}
if results.len() >= max_results {
break;
}
}
Ok(results)
}
async fn resolve_sessions_dir() -> Result<PathBuf> {
if let Some(custom) = read_env_var_os(SESSION_DIR_ENV) {
let path = PathBuf::from(custom);
ensure_dir_exists(&path).await?;
return Ok(path);
}
let manager = DotManager::new().context("failed to load VT Code dot manager")?;
manager
.initialize()
.await
.context("failed to initialize VT Code dot directory structure")?;
let dir = manager.sessions_dir();
ensure_dir_exists(&dir).await?;
Ok(dir)
}
#[derive(Debug, Clone)]
struct SessionFileEntry {
path: PathBuf,
modified: SystemTime,
size: u64,
}
#[derive(Debug, Clone, Copy)]
struct SessionRetentionLimits {
max_files: usize,
max_age_days: u64,
max_total_size_bytes: u64,
}
impl Default for SessionRetentionLimits {
fn default() -> Self {
Self {
max_files: DEFAULT_SESSION_MAX_FILES,
max_age_days: DEFAULT_SESSION_MAX_AGE_DAYS,
max_total_size_bytes: DEFAULT_SESSION_MAX_SIZE_MB.saturating_mul(BYTES_PER_MB),
}
}
}
fn parse_env_usize(key: &str) -> Option<usize> {
read_env_var(key)?.trim().parse::<usize>().ok()
}
fn parse_env_u64(key: &str) -> Option<u64> {
read_env_var(key)?.trim().parse::<u64>().ok()
}
fn session_retention_limits() -> SessionRetentionLimits {
let defaults = SessionRetentionLimits::default();
SessionRetentionLimits {
max_files: parse_env_usize(SESSION_MAX_FILES_ENV).unwrap_or(defaults.max_files),
max_age_days: parse_env_u64(SESSION_MAX_AGE_DAYS_ENV).unwrap_or(defaults.max_age_days),
max_total_size_bytes: parse_env_u64(SESSION_MAX_SIZE_MB_ENV)
.map(|value| value.saturating_mul(BYTES_PER_MB))
.unwrap_or(defaults.max_total_size_bytes),
}
}
fn apply_session_retention_best_effort(sessions_dir: &Path) {
if let Err(err) = apply_session_retention(sessions_dir) {
tracing::warn!(
sessions_dir = %sessions_dir.display(),
error = %err,
"Failed to prune session archives"
);
}
}
fn apply_session_retention(sessions_dir: &Path) -> Result<()> {
apply_session_retention_with_limits(sessions_dir, session_retention_limits())
}
fn apply_session_retention_with_limits(
sessions_dir: &Path,
limits: SessionRetentionLimits,
) -> Result<()> {
let mut entries = collect_session_entries(sessions_dir)?;
if entries.is_empty() {
return Ok(());
}
let now = SystemTime::now();
let age_cutoff = if limits.max_age_days == 0 {
now
} else {
now.checked_sub(Duration::from_secs(
limits.max_age_days.saturating_mul(SECONDS_PER_DAY),
))
.unwrap_or(UNIX_EPOCH)
};
let (expired, retained): (Vec<_>, Vec<_>) = entries
.into_iter()
.partition(|entry| entry.modified <= age_cutoff);
remove_session_files(expired);
entries = retained;
entries.sort_by(|a, b| b.modified.cmp(&a.modified));
if limits.max_files > 0 && entries.len() > limits.max_files {
let overflow = entries.split_off(limits.max_files);
remove_session_files(overflow);
}
if limits.max_total_size_bytes == 0 || entries.is_empty() {
return Ok(());
}
let mut total_size = 0u64;
let mut keep_entries = Vec::with_capacity(entries.len());
let mut size_overflow = Vec::new();
for entry in entries {
let projected = total_size.saturating_add(entry.size);
if keep_entries.is_empty() || projected <= limits.max_total_size_bytes {
total_size = projected;
keep_entries.push(entry);
} else {
size_overflow.push(entry);
}
}
remove_session_files(size_overflow);
Ok(())
}
fn collect_session_entries(sessions_dir: &Path) -> Result<Vec<SessionFileEntry>> {
if !sessions_dir.exists() {
return Ok(Vec::new());
}
let mut entries = Vec::new();
for entry in fs::read_dir(sessions_dir).with_context(|| {
format!(
"failed to read session directory for retention: {}",
sessions_dir.display()
)
})? {
let entry = match entry {
Ok(value) => value,
Err(err) => {
tracing::warn!(
sessions_dir = %sessions_dir.display(),
error = %err,
"Failed to read a session archive entry"
);
continue;
}
};
let path = entry.path();
if !is_session_file(&path) {
continue;
}
let metadata = match entry.metadata() {
Ok(value) => value,
Err(err) => {
tracing::warn!(
path = %path.display(),
error = %err,
"Failed to read session archive metadata"
);
continue;
}
};
if !metadata.is_file() {
continue;
}
let modified = metadata.modified().unwrap_or(UNIX_EPOCH);
entries.push(SessionFileEntry {
path,
modified,
size: metadata.len(),
});
}
Ok(entries)
}
fn remove_session_files(entries: Vec<SessionFileEntry>) {
for entry in entries {
if let Err(err) = fs::remove_file(&entry.path) {
tracing::warn!(
path = %entry.path.display(),
error = %err,
"Failed to remove session archive"
);
}
}
}
fn truncate_preview(input: &str, max_chars: usize) -> String {
if input.chars().count() <= max_chars {
return input.to_owned();
}
let mut truncated = String::new();
for ch in input.chars().take(max_chars.saturating_sub(1)) {
truncated.push(ch);
}
truncated.push('…');
truncated
}
fn compact_snapshot_to_max_bytes(
mut snapshot: SessionSnapshot,
max_bytes: usize,
) -> Result<SessionSnapshot> {
if max_bytes == 0 {
minimize_snapshot_payload(&mut snapshot);
return Ok(snapshot);
}
while serde_json::to_vec(&snapshot)?.len() > max_bytes {
if trim_oldest_snapshot_entries(&mut snapshot) {
continue;
}
if strip_snapshot_overhead(&mut snapshot) {
continue;
}
if shrink_snapshot_strings(&mut snapshot) {
continue;
}
break;
}
Ok(snapshot)
}
fn minimize_snapshot_payload(snapshot: &mut SessionSnapshot) {
snapshot.messages.clear();
snapshot.transcript.clear();
snapshot.distinct_tools.clear();
snapshot.error_logs.clear();
if let Some(progress) = snapshot.progress.as_mut() {
progress.recent_messages.clear();
progress.tool_summaries.clear();
progress.token_usage = None;
progress.max_context_tokens = None;
progress.loaded_skills.clear();
}
}
fn trim_oldest_snapshot_entries(snapshot: &mut SessionSnapshot) -> bool {
let mut changed = false;
if snapshot.messages.len() > 1 {
snapshot.messages.remove(0);
changed = true;
}
if snapshot.transcript.len() > 1 {
snapshot.transcript.remove(0);
changed = true;
}
if let Some(progress) = snapshot.progress.as_mut()
&& progress.recent_messages.len() > 1
{
progress.recent_messages.remove(0);
changed = true;
}
changed
}
fn strip_snapshot_overhead(snapshot: &mut SessionSnapshot) -> bool {
let mut changed = false;
if !snapshot.transcript.is_empty() {
snapshot.transcript.clear();
changed = true;
}
if !snapshot.distinct_tools.is_empty() {
snapshot.distinct_tools.clear();
changed = true;
}
if !snapshot.error_logs.is_empty() {
snapshot.error_logs.clear();
changed = true;
}
if let Some(progress) = snapshot.progress.as_mut() {
if !progress.tool_summaries.is_empty() {
progress.tool_summaries.clear();
changed = true;
}
if progress.token_usage.take().is_some() {
changed = true;
}
if progress.max_context_tokens.take().is_some() {
changed = true;
}
if !progress.loaded_skills.is_empty() {
progress.loaded_skills.clear();
changed = true;
}
}
changed
}
fn shrink_snapshot_strings(snapshot: &mut SessionSnapshot) -> bool {
let mut changed = false;
for transcript in &mut snapshot.transcript {
changed |= shrink_string(transcript);
}
for message in &mut snapshot.messages {
changed |= shrink_session_message(message);
}
for error_log in &mut snapshot.error_logs {
changed |= shrink_string(&mut error_log.message);
}
if let Some(progress) = snapshot.progress.as_mut() {
for message in &mut progress.recent_messages {
changed |= shrink_session_message(message);
}
if let Some(token_usage) = progress.token_usage.as_mut() {
changed |= shrink_string(token_usage);
}
}
changed
}
fn shrink_session_message(message: &mut SessionMessage) -> bool {
let mut changed = false;
changed |= shrink_message_content(&mut message.content);
if let Some(reasoning) = message.reasoning.as_mut() {
changed |= shrink_string(reasoning);
}
if let Some(tool_call_id) = message.tool_call_id.as_mut() {
changed |= shrink_string(tool_call_id);
}
if let Some(origin_tool) = message.origin_tool.as_mut() {
changed |= shrink_string(origin_tool);
}
if let Some(tool_calls) = message.tool_calls.as_mut() {
for tool_call in tool_calls {
changed |= shrink_string(&mut tool_call.id);
changed |= shrink_string(&mut tool_call.call_type);
if let Some(function) = tool_call.function.as_mut() {
changed |= shrink_string(&mut function.name);
changed |= shrink_string(&mut function.arguments);
}
if let Some(text) = tool_call.text.as_mut() {
changed |= shrink_string(text);
}
if let Some(thought_signature) = tool_call.thought_signature.as_mut() {
changed |= shrink_string(thought_signature);
}
}
}
changed
}
fn shrink_message_content(content: &mut MessageContent) -> bool {
match content {
MessageContent::Text(text) => shrink_string(text),
MessageContent::Parts(parts) => {
let mut changed = false;
for part in parts {
changed |= match part {
crate::llm::provider::ContentPart::Text { text } => shrink_string(text),
crate::llm::provider::ContentPart::Image {
data, mime_type, ..
} => shrink_string(data) | shrink_string(mime_type),
crate::llm::provider::ContentPart::File {
filename,
file_id,
file_data,
file_url,
..
} => {
shrink_optional_string(filename)
| shrink_optional_string(file_id)
| shrink_optional_string(file_data)
| shrink_optional_string(file_url)
}
};
}
changed
}
}
}
fn shrink_optional_string(value: &mut Option<String>) -> bool {
value.as_mut().is_some_and(shrink_string)
}
fn shrink_string(value: &mut String) -> bool {
const MIN_RETAINED_CHARS: usize = 8;
const TRUNCATION_MARKER: &str = "...";
if value.len() <= MIN_RETAINED_CHARS + TRUNCATION_MARKER.len() {
return false;
}
let keep_len = (value.len() / 2).max(MIN_RETAINED_CHARS);
let prefix_len = keep_len.saturating_sub(TRUNCATION_MARKER.len());
value.truncate(prefix_len);
value.push_str(TRUNCATION_MARKER);
true
}
fn sanitize_component(value: &str) -> String {
let mut normalized = String::new();
let mut last_was_separator = false;
for ch in value.chars() {
if ch.is_ascii_alphanumeric() {
normalized.push(ch.to_ascii_lowercase());
last_was_separator = false;
} else if matches!(ch, '-' | '_') {
if !last_was_separator {
normalized.push(ch);
last_was_separator = true;
}
} else if !last_was_separator {
normalized.push('-');
last_was_separator = true;
}
}
let trimmed = normalized.trim_matches(|c| c == '-' || c == '_');
if trimmed.is_empty() {
"workspace".to_owned()
} else {
trimmed.to_owned()
}
}
fn is_session_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|ext| ext.to_str()),
Some(ext)
if ext.eq_ignore_ascii_case(SESSION_FILE_EXTENSION)
|| ext.eq_ignore_ascii_case("jsonl")
|| ext.eq_ignore_ascii_case("log")
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::constants::tools as tool_names;
use crate::llm::provider::{ContentPart, ToolCall};
use anyhow::anyhow;
use chrono::{TimeZone, Timelike};
use std::sync::LazyLock;
use std::time::Duration;
static SESSION_HISTORY_TEST_LOCK: LazyLock<tokio::sync::Mutex<()>> =
LazyLock::new(|| tokio::sync::Mutex::new(()));
struct EnvGuard {
key: &'static str,
}
impl EnvGuard {
fn set(key: &'static str, value: &Path) -> Self {
set_test_env_override_path(key, value);
Self { key }
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
clear_test_env_override(self.key);
}
}
struct HistorySettingsGuard {
previous: SessionHistorySettings,
}
impl HistorySettingsGuard {
fn set(persistence: HistoryPersistence, max_bytes: Option<usize>) -> Self {
let previous = session_history_settings();
let mut config = VTCodeConfig::default();
config.history.persistence = persistence;
config.history.max_bytes = max_bytes;
apply_session_history_config_from_vtcode(&config);
Self { previous }
}
}
impl Drop for HistorySettingsGuard {
fn drop(&mut self) {
let mut config = VTCodeConfig::default();
config.history.persistence = self.previous.persistence;
config.history.max_bytes = self.previous.max_bytes;
apply_session_history_config_from_vtcode(&config);
}
}
async fn lock_history_test_guard() -> tokio::sync::MutexGuard<'static, ()> {
SESSION_HISTORY_TEST_LOCK.lock().await
}
#[tokio::test]
async fn session_archive_persists_snapshot() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
)
.with_external_thread_id("thread-123");
let archive = SessionArchive::new(metadata.clone(), None).await?;
let transcript = vec!["line one".to_owned(), "line two".to_owned()];
let messages = vec![
SessionMessage::new(MessageRole::User, "Hello world"),
SessionMessage::new(MessageRole::Assistant, "Hi there"),
];
let path = archive.finalize(
transcript.clone(),
4,
vec!["tool_a".to_owned()],
messages.clone(),
)?;
let stored = fs::read_to_string(&path)
.with_context(|| format!("failed to read stored session: {}", path.display()))?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
assert_eq!(snapshot.metadata, metadata);
assert_eq!(snapshot.transcript, transcript);
assert_eq!(snapshot.total_messages, 4);
assert_eq!(snapshot.distinct_tools, vec!["tool_a".to_owned()]);
assert_eq!(snapshot.messages, messages);
Ok(())
}
#[test]
fn session_message_converts_back_and_forth() {
let mut original = Message::assistant("Test response".to_owned());
original.reasoning = Some("Model thoughts".to_owned());
original.phase = Some(AssistantPhase::FinalAnswer);
let stored = SessionMessage::from(&original);
let restored = Message::from(&stored);
assert_eq!(original.role, restored.role);
assert_eq!(original.content, restored.content);
assert_eq!(original.reasoning, stored.reasoning);
assert_eq!(original.reasoning, restored.reasoning);
assert_eq!(original.tool_call_id, restored.tool_call_id);
assert_eq!(original.phase, stored.phase);
assert_eq!(original.phase, restored.phase);
}
#[test]
fn session_message_roundtrip_preserves_commentary_phase() {
let original =
Message::assistant("Working".to_owned()).with_phase(Some(AssistantPhase::Commentary));
let stored = SessionMessage::from(&original);
let restored = Message::from(&stored);
assert_eq!(stored.phase, Some(AssistantPhase::Commentary));
assert_eq!(restored.phase, Some(AssistantPhase::Commentary));
}
#[test]
fn session_message_preserves_tool_calls_reasoning_details_and_origin_tool() {
let mut original = Message::assistant("Calling a tool".to_owned());
original.reasoning_details = Some(vec![serde_json::json!({
"summary": "tool call planning"
})]);
original.tool_calls = Some(vec![ToolCall::function(
"call_1".to_string(),
"unified_exec".to_string(),
"{\"cmd\":\"cargo fmt\"}".to_string(),
)]);
original.origin_tool = Some("unified_exec".to_string());
let stored = SessionMessage::from(&original);
let restored = Message::from(&stored);
assert_eq!(stored.reasoning_details, original.reasoning_details);
assert_eq!(stored.tool_calls, original.tool_calls);
assert_eq!(stored.origin_tool, original.origin_tool);
assert_eq!(restored.reasoning_details, original.reasoning_details);
assert_eq!(restored.tool_calls, original.tool_calls);
assert_eq!(restored.origin_tool, original.origin_tool);
}
#[test]
fn session_message_preserves_parts() {
let original = Message::assistant_with_parts(vec![
ContentPart::text("See attached image".to_owned()),
ContentPart::text("See attached image".to_owned()),
ContentPart::image("encoded-image".to_owned(), "image/png".to_owned()),
ContentPart::image("encoded-image".to_owned(), "image/png".to_owned()),
ContentPart::image("encoded-image".to_owned(), "image/png".to_owned()),
]);
let stored = SessionMessage::from(&original);
assert_eq!(stored.content, original.content);
let restored = Message::from(&stored);
assert_eq!(restored.content, original.content);
}
#[tokio::test]
async fn session_progress_persists_budget_and_recent_messages() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
);
let archive = SessionArchive::new(metadata, None).await?;
let recent = vec![SessionMessage::new(MessageRole::Assistant, "recent")];
let path = archive.persist_progress(SessionProgressArgs {
total_messages: 1,
distinct_tools: vec!["tool_a".to_owned()],
recent_messages: recent.clone(),
turn_number: 2,
token_usage: Some("10 tokens".to_string()),
max_context_tokens: Some(128),
loaded_skills: None, })?;
let stored = fs::read_to_string(&path)
.with_context(|| format!("failed to read stored session: {}", path.display()))?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
let progress = snapshot.progress.expect("progress should exist");
assert_eq!(progress.turn_number, 2);
assert_eq!(progress.recent_messages, recent);
assert_eq!(progress.token_usage, Some("10 tokens".to_string()));
assert_eq!(progress.tool_summaries, vec!["tool_a".to_string()]);
assert_eq!(progress.max_context_tokens, Some(128));
assert_eq!(snapshot.transcript, vec!["recent".to_string()]);
Ok(())
}
#[tokio::test]
async fn session_progress_transcript_skips_tool_noise_and_duplicates() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
);
let archive = SessionArchive::new(metadata, None).await?;
let mut assistant = SessionMessage::new(MessageRole::Assistant, "done");
assistant.reasoning = Some("reasoned".to_string());
let recent = vec![
SessionMessage::new(MessageRole::User, "run cargo check"),
SessionMessage::new(MessageRole::Tool, "{\"output\":\"...\"}"),
SessionMessage::new(MessageRole::Assistant, "done"),
assistant,
];
let path = archive.persist_progress(SessionProgressArgs {
total_messages: recent.len(),
distinct_tools: vec!["unified_exec".to_owned()],
recent_messages: recent,
turn_number: 2,
token_usage: Some("10 tokens".to_string()),
max_context_tokens: Some(128),
loaded_skills: None,
})?;
let stored = fs::read_to_string(&path)
.with_context(|| format!("failed to read stored session: {}", path.display()))?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
assert_eq!(
snapshot.transcript,
vec!["run cargo check".to_string(), "done".to_string()]
);
Ok(())
}
#[test]
fn archive_transcript_cleaner_filters_recovery_noise_and_duplicate_tool_blocks() {
let lines = vec![
"hello".to_string(),
" Hello, Vinh.".to_string(),
"tell me more".to_string(),
" Let me dig deeper into the project structure.".to_string(),
"• Ran cd /tmp/project &&".to_string(),
" │ ls -1 src/".to_string(),
" ✓ exit 0".to_string(),
"• Ran cd /tmp/project &&".to_string(),
" │ ls -1 src/".to_string(),
" ✓ exit 0".to_string(),
"• List files Use Unified search".to_string(),
" â”” Action: list".to_string(),
" â”” Path: /tmp/project/src".to_string(),
" â”” Filter: files".to_string(),
"[!] Turn balancer: repeated low-signal calls detected; scheduling a final recovery pass."
.to_string(),
" I couldn't produce a final synthesis because the model returned no answer on the recovery pass.".to_string(),
" Latest tool output: {\"output\":\"...\"}".to_string(),
" Reuse the latest tool outputs already collected in this turn before retrying."
.to_string(),
"run cargo fmt and report me".to_string(),
"• Ran cd /tmp/project &&".to_string(),
" │ cargo fmt 2>&1".to_string(),
" (no output)".to_string(),
" cargo fmt ran successfully with no output.".to_string(),
];
let cleaned = clean_transcript_lines(&lines);
assert_eq!(
cleaned,
vec![
"hello".to_string(),
" Hello, Vinh.".to_string(),
"tell me more".to_string(),
" Let me dig deeper into the project structure.".to_string(),
"• Ran cd /tmp/project && ls -1 src/ (repeated x2)".to_string(),
"• List files Use Unified search [path /tmp/project/src, filter files]".to_string(),
"Repeated low-signal tool churn triggered recovery.".to_string(),
"Recovery pass failed to produce a final synthesis.".to_string(),
"run cargo fmt and report me".to_string(),
"• Ran cd /tmp/project && cargo fmt 2>&1".to_string(),
" cargo fmt ran successfully with no output.".to_string(),
]
);
}
#[test]
fn archive_transcript_cleaner_preserves_paragraph_spacing_and_drops_structured_result_noise() {
let lines = vec![
"Project summary:".to_string(),
"".to_string(),
" VT Code is a Rust-based coding agent.".to_string(),
"".to_string(),
"Structured result with fields: output, exit_code, wall_time, session_id".to_string(),
"Next step.".to_string(),
];
let cleaned = clean_transcript_lines(&lines);
assert_eq!(
cleaned,
vec![
"Project summary:".to_string(),
"".to_string(),
" VT Code is a Rust-based coding agent.".to_string(),
"".to_string(),
"Next step.".to_string(),
]
);
}
#[tokio::test]
async fn session_progress_normalizes_exec_tool_aliases_in_summaries() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
);
let archive = SessionArchive::new(metadata, None).await?;
let recent = vec![SessionMessage::new(MessageRole::Assistant, "done")];
let path = archive.persist_progress(SessionProgressArgs {
total_messages: 1,
distinct_tools: vec![
tool_names::UNIFIED_EXEC.to_string(),
tool_names::RUN_PTY_CMD.to_string(),
tool_names::SEND_PTY_INPUT.to_string(),
tool_names::READ_PTY_SESSION.to_string(),
tool_names::LIST_PTY_SESSIONS.to_string(),
tool_names::CLOSE_PTY_SESSION.to_string(),
tool_names::EXECUTE_CODE.to_string(),
tool_names::EXEC_COMMAND.to_string(),
tool_names::WRITE_STDIN.to_string(),
"shell".to_string(),
"exec_pty_cmd".to_string(),
"exec".to_string(),
"container.exec".to_string(),
],
recent_messages: recent,
turn_number: 2,
token_usage: Some("10 tokens".to_string()),
max_context_tokens: Some(128),
loaded_skills: None,
})?;
let stored = fs::read_to_string(&path)
.with_context(|| format!("failed to read stored session: {}", path.display()))?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
assert_eq!(
snapshot.distinct_tools,
vec![tool_names::UNIFIED_EXEC.to_string()]
);
let progress = snapshot.progress.expect("progress should exist");
assert_eq!(
progress.tool_summaries,
vec![tool_names::UNIFIED_EXEC.to_string()]
);
Ok(())
}
#[tokio::test]
async fn find_session_by_identifier_returns_match() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"Sample",
"/tmp/sample",
"model-x",
"provider-y",
"dark",
"medium",
);
let archive = SessionArchive::new(metadata.clone(), None).await?;
let messages = vec![
SessionMessage::new(MessageRole::User, "Hi"),
SessionMessage::new(MessageRole::Assistant, "Hello"),
];
let path = archive.finalize(Vec::new(), messages.len(), Vec::new(), messages)?;
let identifier = path
.file_stem()
.and_then(|stem| stem.to_str())
.ok_or_else(|| anyhow!("missing file stem"))?
.to_string();
let listing = find_session_by_identifier(&identifier)
.await?
.ok_or_else(|| anyhow!("expected session to be found"))?;
assert_eq!(listing.identifier(), identifier);
assert_eq!(listing.snapshot.metadata, metadata);
Ok(())
}
#[tokio::test]
async fn session_archive_path_collision_adds_suffix() -> Result<()> {
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
);
let started_at = Utc
.with_ymd_and_hms(2025, 9, 25, 10, 15, 30)
.unwrap()
.with_nanosecond(123_456_000)
.unwrap();
let first_path = generate_unique_archive_path(temp_dir.path(), &metadata, started_at, None);
fs::write(&first_path, "{}").context("failed to create sentinel file")?;
let second_path =
generate_unique_archive_path(temp_dir.path(), &metadata, started_at, None);
assert_ne!(first_path, second_path);
let second_name = second_path
.file_name()
.and_then(|name| name.to_str())
.expect("file name");
assert!(second_name.contains("-01"));
Ok(())
}
#[test]
fn session_archive_filename_includes_microseconds_and_pid() -> Result<()> {
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
);
let started_at = Utc
.with_ymd_and_hms(2025, 9, 25, 10, 15, 30)
.unwrap()
.with_nanosecond(654_321_000)
.expect("nanosecond set");
let path = generate_unique_archive_path(temp_dir.path(), &metadata, started_at, None);
let name = path
.file_name()
.and_then(|value| value.to_str())
.expect("file name string");
assert!(name.contains("20250925T101530Z_654321"));
let pid_fragment = format!("{:05}", process::id());
assert!(name.contains(&pid_fragment));
Ok(())
}
#[tokio::test]
async fn reserve_session_identifier_can_be_reused_for_archive() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let session_id = reserve_session_archive_identifier("ExampleWorkspace", None).await?;
assert!(session_id.starts_with("session-exampleworkspace-"));
let metadata = SessionArchiveMetadata::new(
"ExampleWorkspace",
"/tmp/example",
"model-x",
"provider-y",
"dark",
"medium",
)
.with_debug_log_path(Some("/tmp/debug-session.log".to_string()));
let archive = SessionArchive::new_with_identifier(metadata.clone(), session_id.clone())
.await
.context("failed to create archive with reserved session id")?;
let path = archive.finalize(
vec!["line one".to_owned()],
1,
vec![],
vec![SessionMessage::new(MessageRole::User, "hello")],
)?;
let stored = fs::read_to_string(&path)
.with_context(|| format!("failed to read stored session: {}", path.display()))?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
let stem = path
.file_stem()
.and_then(|value| value.to_str())
.ok_or_else(|| anyhow!("missing file stem"))?;
assert_eq!(stem, session_id);
assert_eq!(
snapshot.metadata.debug_log_path,
Some("/tmp/debug-session.log".to_string())
);
Ok(())
}
#[test]
fn generated_session_identifier_includes_workspace_and_custom_suffix() {
let generated =
generate_session_archive_identifier("Example Workspace", Some("branch".to_string()));
assert!(generated.starts_with("session-example-workspace-"));
assert!(generated.ends_with("-branch"));
}
#[tokio::test]
async fn resume_from_listing_reuses_existing_archive_path() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let session_id = reserve_session_archive_identifier("ResumeWorkspace", None).await?;
let metadata = SessionArchiveMetadata::new(
"ResumeWorkspace",
"/tmp/resume-workspace",
"model-a",
"provider-a",
"light",
"medium",
);
let archive = SessionArchive::new_with_identifier(metadata.clone(), session_id.clone())
.await
.context("failed to create initial archive")?;
let original_path = archive.finalize(
vec!["user: first".to_string()],
1,
vec!["read_file".to_string()],
vec![SessionMessage::new(MessageRole::User, "first")],
)?;
let listing = find_session_by_identifier(&session_id)
.await?
.context("expected archived session listing")?;
let resumed = SessionArchive::resume_from_listing(&listing, metadata);
let resumed_path = resumed.finalize(
vec!["user: second".to_string()],
2,
vec!["read_file".to_string()],
vec![
SessionMessage::new(MessageRole::User, "first"),
SessionMessage::new(MessageRole::Assistant, "second"),
],
)?;
assert_eq!(resumed_path, original_path);
let stored = fs::read_to_string(&resumed_path).with_context(|| {
format!("failed to read stored session: {}", resumed_path.display())
})?;
let snapshot: SessionSnapshot =
serde_json::from_str(&stored).context("failed to deserialize stored snapshot")?;
assert_eq!(snapshot.total_messages, 2);
assert_eq!(snapshot.messages.len(), 2);
Ok(())
}
#[tokio::test]
async fn list_recent_sessions_orders_entries() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let first_metadata = SessionArchiveMetadata::new(
"First",
"/tmp/first",
"model-a",
"provider-a",
"light",
"medium",
);
let first_archive = SessionArchive::new(first_metadata.clone(), None).await?;
first_archive.finalize(
vec!["first".to_owned()],
1,
Vec::new(),
vec![SessionMessage::new(MessageRole::User, "First")],
)?;
tokio::time::sleep(Duration::from_millis(10)).await;
let second_metadata = SessionArchiveMetadata::new(
"Second",
"/tmp/second",
"model-b",
"provider-b",
"dark",
"high",
);
let second_archive = SessionArchive::new(second_metadata.clone(), None).await?;
second_archive.finalize(
vec!["second".to_owned()],
2,
vec!["tool_b".to_owned()],
vec![SessionMessage::new(MessageRole::User, "Second")],
)?;
let listings = list_recent_sessions(10).await?;
assert_eq!(listings.len(), 2);
assert_eq!(listings[0].snapshot.metadata, second_metadata);
assert_eq!(listings[1].snapshot.metadata, first_metadata);
Ok(())
}
#[test]
fn session_archive_retention_prunes_oldest_by_count() -> Result<()> {
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
for idx in 0..3 {
let path = temp_dir.path().join(format!("session-{idx}.json"));
fs::write(&path, format!("{{\"idx\":{idx}}}"))
.with_context(|| format!("failed to write {}", path.display()))?;
std::thread::sleep(Duration::from_millis(5));
}
apply_session_retention_with_limits(
temp_dir.path(),
SessionRetentionLimits {
max_files: 2,
max_age_days: 365,
max_total_size_bytes: 10 * BYTES_PER_MB,
},
)?;
let mut remaining = fs::read_dir(temp_dir.path())
.context("failed to list retained session files")?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.filter_map(|path| {
path.file_name()
.and_then(|name| name.to_str())
.map(|name| name.to_string())
})
.collect::<Vec<_>>();
remaining.sort();
assert_eq!(remaining.len(), 2);
assert!(!remaining.iter().any(|name| name == "session-0.json"));
Ok(())
}
#[test]
fn session_archive_retention_prunes_by_total_size() -> Result<()> {
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
for idx in 0..2 {
let path = temp_dir.path().join(format!("session-{idx}.json"));
fs::write(&path, "x".repeat(800_000))
.with_context(|| format!("failed to write {}", path.display()))?;
std::thread::sleep(Duration::from_millis(5));
}
apply_session_retention_with_limits(
temp_dir.path(),
SessionRetentionLimits {
max_files: 10,
max_age_days: 365,
max_total_size_bytes: BYTES_PER_MB,
},
)?;
let remaining = fs::read_dir(temp_dir.path())
.context("failed to list retained session files")?
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("json"))
.collect::<Vec<_>>();
assert_eq!(remaining.len(), 1);
let remaining_name = remaining[0]
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
assert_eq!(remaining_name, "session-1.json");
Ok(())
}
#[test]
fn listing_previews_return_first_non_empty_lines() {
let metadata = SessionArchiveMetadata::new(
"Workspace",
"/tmp/ws",
"model",
"provider",
"dark",
"medium",
);
let long_response = "response snippet ".repeat(6);
let snapshot = SessionSnapshot {
metadata,
started_at: Utc::now(),
ended_at: Utc::now(),
total_messages: 2,
distinct_tools: Vec::new(),
transcript: Vec::new(),
messages: vec![
SessionMessage::new(MessageRole::System, ""),
SessionMessage::new(MessageRole::User, " prompt line\nsecond"),
SessionMessage::new(MessageRole::Assistant, long_response.clone()),
],
progress: None,
error_logs: Vec::new(),
};
let listing = SessionListing {
path: PathBuf::from("session-workspace.json"),
snapshot,
};
assert_eq!(
listing.first_prompt_preview(),
Some("prompt line".to_owned())
);
let expected = truncate_preview(&long_response, 80);
assert_eq!(listing.first_reply_preview(), Some(expected));
}
#[tokio::test]
async fn search_sessions_finds_keyword() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::File, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new("SearchWS", "/tmp/s", "mod", "prov", "d", "m");
let archive = SessionArchive::new(metadata, None).await?;
let messages = vec![
SessionMessage::new(MessageRole::User, "Where is the secret API key?"),
SessionMessage::new(
MessageRole::Assistant,
"The secret key is defined in .env.local",
),
];
archive.finalize(vec![], 2, vec![], messages)?;
let results = search_sessions("secret key", 10, 5).await?;
assert!(!results.is_empty());
assert!(results[0].content_snippet.contains("secret key"));
assert_eq!(results[0].role, MessageRole::Assistant);
Ok(())
}
#[tokio::test]
async fn session_archive_skips_writes_when_history_persistence_is_disabled() -> Result<()> {
let _settings_lock = lock_history_test_guard().await;
let _history_guard = HistorySettingsGuard::set(HistoryPersistence::None, None);
let temp_dir = tempfile::tempdir().context("failed to create temp dir")?;
let _guard = EnvGuard::set(SESSION_DIR_ENV, temp_dir.path());
let metadata = SessionArchiveMetadata::new(
"NoHistory",
"/tmp/no-history",
"model-x",
"provider-y",
"dark",
"medium",
);
let archive = SessionArchive::new(metadata, None).await?;
let path = archive.finalize(
vec!["line one".to_owned()],
1,
Vec::new(),
vec![SessionMessage::new(MessageRole::User, "hello")],
)?;
assert_eq!(path, archive.path());
assert!(
!path.exists(),
"history disabled should not write archive files"
);
Ok(())
}
#[test]
fn snapshot_compaction_shrinks_large_single_message_payloads() -> Result<()> {
let snapshot = SessionSnapshot {
metadata: SessionArchiveMetadata::new(
"Workspace",
"/tmp/workspace",
"model",
"provider",
"dark",
"medium",
),
started_at: Utc::now(),
ended_at: Utc::now(),
total_messages: 1,
distinct_tools: vec!["very-large-tool-name".repeat(20)],
transcript: vec!["transcript ".repeat(400)],
messages: vec![SessionMessage {
role: MessageRole::Assistant,
content: MessageContent::Parts(vec![
ContentPart::text("text ".repeat(800)),
ContentPart::image("a".repeat(4000), "image/png".to_string()),
]),
reasoning: Some("reasoning ".repeat(300)),
reasoning_details: None,
tool_calls: Some(vec![ToolCall::function(
"call_1".to_string(),
"unified_exec".to_string(),
"{\"cmd\":\"echo giant payload\"}".repeat(100),
)]),
tool_call_id: None,
phase: None,
origin_tool: Some("unified_exec".repeat(50)),
}],
progress: Some(SessionProgress {
turn_number: 1,
recent_messages: vec![SessionMessage::new(
MessageRole::Assistant,
"recent ".repeat(500),
)],
tool_summaries: vec!["summary ".repeat(100)],
token_usage: Some("token ".repeat(200)),
max_context_tokens: Some(128_000),
loaded_skills: vec!["skill ".repeat(50)],
}),
error_logs: vec![ErrorLogEntry {
timestamp: Utc::now().to_rfc3339(),
level: "ERROR".to_string(),
target: "vtcode_test".to_string(),
message: "error ".repeat(400),
}],
};
let compacted = compact_snapshot_to_max_bytes(snapshot, 2_048)?;
assert!(serde_json::to_vec(&compacted)?.len() <= 2_048);
assert_eq!(compacted.messages.len(), 1);
Ok(())
}
}