Skip to main content

vtcode_core/context/
history_files.rs

1//! Chat History Files for Dynamic Context Discovery
2//!
3//! Implements Cursor-style chat history persistence during summarization.
4//! When context window fills up and summarization occurs, the full conversation
5//! is written to `.vtcode/history/` so agents can recover details with shell search.
6//!
7//! ## Design Philosophy
8//!
9//! Instead of losing conversation details during lossy summarization:
10//! 1. Write full conversation to `.vtcode/history/session_{id}_{turn}.jsonl`
11//! 2. Include file reference in summary message
12//! 3. Agent can search history with `rg` when details are needed
13
14use crate::llm::provider::{ContentPart, Message, MessageContent, MessageRole};
15use crate::telemetry::perf::PerfSpan;
16use anyhow::{Context, Result};
17use chrono::{DateTime, Utc};
18use hashbrown::HashMap;
19use serde::{Deserialize, Serialize};
20use std::fs;
21use std::path::{Path, PathBuf};
22use tokio::fs as async_fs;
23use tracing::{debug, info};
24
25/// Configuration for history file persistence
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct HistoryConfig {
28    /// Enable history file persistence
29    #[serde(default = "default_enabled")]
30    pub enabled: bool,
31
32    /// Maximum number of history files to keep per session
33    #[serde(default = "default_max_files")]
34    pub max_files_per_session: usize,
35
36    /// Include detailed tool results in history
37    #[serde(default = "default_include_tool_results")]
38    pub include_tool_results: bool,
39}
40
41fn default_enabled() -> bool {
42    true
43}
44
45fn default_max_files() -> usize {
46    10
47}
48
49fn default_include_tool_results() -> bool {
50    true
51}
52
53impl Default for HistoryConfig {
54    fn default() -> Self {
55        Self {
56            enabled: true,
57            max_files_per_session: 10,
58            include_tool_results: true,
59        }
60    }
61}
62
63/// A single message in the history file
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct HistoryMessage {
66    /// Turn number in the conversation
67    pub turn: usize,
68    /// Role: user, assistant, tool
69    pub role: String,
70    /// Message content
71    pub content: String,
72    /// Optional tool call ID
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub tool_call_id: Option<String>,
75    /// Optional tool name
76    #[serde(skip_serializing_if = "Option::is_none")]
77    pub tool_name: Option<String>,
78    /// Timestamp
79    pub timestamp: DateTime<Utc>,
80}
81
82/// Metadata about the history file
83#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HistoryMetadata {
85    /// Session identifier
86    pub session_id: String,
87    /// Turn number when history was written
88    pub turn_number: usize,
89    /// Reason for writing (e.g., "summarization", "checkpoint")
90    pub reason: String,
91    /// Number of messages in file
92    pub message_count: usize,
93    /// Files modified in this conversation
94    pub modified_files: Vec<String>,
95    /// Commands executed
96    pub executed_commands: Vec<String>,
97    /// Timestamp when written
98    pub written_at: DateTime<Utc>,
99}
100
101/// Result of writing a history file
102#[derive(Debug, Clone)]
103pub struct HistoryWriteResult {
104    /// Path to the history file (relative to workspace)
105    pub file_path: PathBuf,
106    /// Metadata about the file
107    pub metadata: HistoryMetadata,
108}
109
110/// Manager for conversation history files
111pub struct HistoryFileManager {
112    /// Workspace root
113    workspace_root: PathBuf,
114    /// History directory
115    history_dir: PathBuf,
116    /// Session identifier
117    session_id: String,
118    /// Configuration
119    config: HistoryConfig,
120    /// Counter for history files in this session
121    file_counter: usize,
122}
123
124impl HistoryFileManager {
125    /// Create a new history file manager
126    pub fn new(workspace_root: &Path, session_id: impl Into<String>) -> Self {
127        Self::with_config(workspace_root, session_id, HistoryConfig::default())
128    }
129
130    /// Create a new history file manager with custom config
131    pub fn with_config(workspace_root: &Path, session_id: impl Into<String>, config: HistoryConfig) -> Self {
132        let history_dir = workspace_root.join(".vtcode").join("history");
133        Self {
134            workspace_root: workspace_root.to_path_buf(),
135            history_dir,
136            session_id: session_id.into(),
137            config,
138            file_counter: 0,
139        }
140    }
141
142    /// Check if history persistence is enabled
143    pub fn is_enabled(&self) -> bool {
144        self.config.enabled
145    }
146
147    /// Write conversation history to a file (synchronous version)
148    ///
149    /// Returns the file path and metadata if successful.
150    /// Use this when calling from synchronous code paths like summarization.
151    pub fn write_history_sync(
152        &mut self,
153        messages: &[HistoryMessage],
154        turn_number: usize,
155        reason: &str,
156        modified_files: &[String],
157        executed_commands: &[String],
158    ) -> Result<HistoryWriteResult> {
159        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
160        perf.tag("mode", "sync");
161        perf.tag("reason", reason.to_string());
162
163        if !self.config.enabled {
164            return Err(anyhow::anyhow!("History persistence is disabled"));
165        }
166
167        // Ensure directory exists
168        fs::create_dir_all(&self.history_dir)
169            .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
170
171        // Generate filename
172        self.file_counter += 1;
173        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
174        let filename = format!("{}_{:04}_{}.jsonl", sanitize_session_id(&self.session_id), turn_number, timestamp);
175        let file_path = self.history_dir.join(&filename);
176
177        // Build metadata
178        let metadata = HistoryMetadata {
179            session_id: self.session_id.clone(),
180            turn_number,
181            reason: reason.to_string(),
182            message_count: messages.len(),
183            modified_files: modified_files.to_vec(),
184            executed_commands: executed_commands.to_vec(),
185            written_at: Utc::now(),
186        };
187
188        // Build file content as JSONL — serialize directly into a byte
189        // buffer to avoid one intermediate `String` allocation per message.
190        let mut content: Vec<u8> = Vec::new();
191
192        // Write metadata as first line
193        serde_json::to_writer(
194            &mut content,
195            &serde_json::json!({
196                "_type": "metadata",
197                "_metadata": metadata
198            }),
199        )?;
200        content.push(b'\n');
201
202        // Write each message as a line
203        for msg in messages {
204            serde_json::to_writer(&mut content, msg)?;
205            content.push(b'\n');
206        }
207
208        // Write file
209        fs::write(&file_path, &content)
210            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
211
212        // Calculate relative path
213        let relative_path = file_path.strip_prefix(&self.workspace_root).unwrap_or(&file_path).to_path_buf();
214
215        info!(
216            session = %self.session_id,
217            turn = turn_number,
218            messages = messages.len(),
219            path = %relative_path.display(),
220            "Wrote conversation history to file"
221        );
222
223        // Cleanup old files synchronously
224        self.cleanup_old_files_sync();
225
226        Ok(HistoryWriteResult { file_path: relative_path, metadata })
227    }
228
229    /// Write conversation history to a file (async version)
230    ///
231    /// Returns the file path and metadata if successful
232    pub async fn write_history(
233        &mut self,
234        messages: &[HistoryMessage],
235        turn_number: usize,
236        reason: &str,
237        modified_files: &[String],
238        executed_commands: &[String],
239    ) -> Result<HistoryWriteResult> {
240        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
241        perf.tag("mode", "async");
242        perf.tag("reason", reason.to_string());
243
244        if !self.config.enabled {
245            return Err(anyhow::anyhow!("History persistence is disabled"));
246        }
247
248        // Ensure directory exists
249        async_fs::create_dir_all(&self.history_dir)
250            .await
251            .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
252
253        // Generate filename
254        self.file_counter += 1;
255        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
256        let filename = format!("{}_{:04}_{}.jsonl", sanitize_session_id(&self.session_id), turn_number, timestamp);
257        let file_path = self.history_dir.join(&filename);
258
259        // Build metadata
260        let metadata = HistoryMetadata {
261            session_id: self.session_id.clone(),
262            turn_number,
263            reason: reason.to_string(),
264            message_count: messages.len(),
265            modified_files: modified_files.to_vec(),
266            executed_commands: executed_commands.to_vec(),
267            written_at: Utc::now(),
268        };
269
270        // Build file content as JSONL — serialize directly into a byte
271        // buffer to avoid one intermediate `String` allocation per message.
272        let mut content: Vec<u8> = Vec::new();
273
274        // Write metadata as first line
275        serde_json::to_writer(
276            &mut content,
277            &serde_json::json!({
278                "_type": "metadata",
279                "_metadata": metadata
280            }),
281        )?;
282        content.push(b'\n');
283
284        // Write each message as a line
285        for msg in messages {
286            serde_json::to_writer(&mut content, msg)?;
287            content.push(b'\n');
288        }
289
290        // Write file
291        async_fs::write(&file_path, &content)
292            .await
293            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
294
295        // Calculate relative path
296        let relative_path = file_path.strip_prefix(&self.workspace_root).unwrap_or(&file_path).to_path_buf();
297
298        info!(
299            session = %self.session_id,
300            turn = turn_number,
301            messages = messages.len(),
302            path = %relative_path.display(),
303            "Wrote conversation history to file"
304        );
305
306        // Cleanup old files if needed
307        self.cleanup_old_files().await?;
308
309        Ok(HistoryWriteResult { file_path: relative_path, metadata })
310    }
311
312    /// Generate a summary message with file reference
313    pub fn format_summary_with_reference(&self, base_summary: &str, history_path: &Path) -> String {
314        format!(
315            "{}\n\nFull conversation history saved to: {}\nUse `exec_command.cmd` with `rg`, `sed`, or `cat` to inspect specific details if needed.",
316            base_summary,
317            history_path.display()
318        )
319    }
320
321    /// Cleanup old history files beyond the limit (synchronous)
322    fn cleanup_old_files_sync(&self) {
323        if !self.history_dir.exists() {
324            return;
325        }
326
327        let prefix = sanitize_session_id(&self.session_id);
328        let mut files: Vec<PathBuf> = Vec::new();
329
330        if let Ok(entries) = fs::read_dir(&self.history_dir) {
331            for entry in entries.flatten() {
332                let path = entry.path();
333                if let Some(name) = path.file_name().and_then(|n| n.to_str())
334                    && name.starts_with(&prefix)
335                    && name.ends_with(".jsonl")
336                {
337                    files.push(path);
338                }
339            }
340        }
341
342        // Sort by name (which includes timestamp) and remove oldest
343        files.sort();
344        let excess = files.len().saturating_sub(self.config.max_files_per_session);
345
346        for old_file in files.into_iter().take(excess) {
347            if fs::remove_file(&old_file).is_ok() {
348                debug!(path = %old_file.display(), "Removed old history file");
349            }
350        }
351    }
352
353    /// Cleanup old history files beyond the limit (async)
354    async fn cleanup_old_files(&self) -> Result<()> {
355        if !async_fs::try_exists(&self.history_dir).await.unwrap_or(false) {
356            return Ok(());
357        }
358
359        let prefix = sanitize_session_id(&self.session_id);
360        let mut files: Vec<PathBuf> = Vec::new();
361
362        let mut entries = async_fs::read_dir(&self.history_dir).await?;
363        while let Some(entry) = entries.next_entry().await? {
364            let path = entry.path();
365            if let Some(name) = path.file_name().and_then(|n| n.to_str())
366                && name.starts_with(&prefix)
367                && name.ends_with(".jsonl")
368            {
369                files.push(path);
370            }
371        }
372
373        // Sort by name (which includes timestamp) and remove oldest
374        files.sort();
375        let excess = files.len().saturating_sub(self.config.max_files_per_session);
376
377        for old_file in files.into_iter().take(excess) {
378            if async_fs::remove_file(&old_file).await.is_ok() {
379                debug!(path = %old_file.display(), "Removed old history file");
380            }
381        }
382
383        Ok(())
384    }
385}
386
387/// Sanitize session ID for use in filename
388fn sanitize_session_id(id: &str) -> String {
389    // Delegates to the canonical envelope-id contract: history filenames and
390    // memory-envelope names must derive the same 32-char prefix from a session
391    // id, so keep a single implementation (see `harness_artifacts`).
392    crate::compaction::memory_envelope::sanitize_session_id(id)
393}
394
395fn history_text_from_message_content(content: &MessageContent) -> String {
396    match content {
397        MessageContent::Text(text) => text.clone(),
398        MessageContent::Parts(parts) => parts
399            .iter()
400            .map(|part| match part {
401                ContentPart::Text { text } => text.clone(),
402                ContentPart::Image { .. } => "[Image]".to_string(),
403                ContentPart::File { filename, file_id, file_url, .. } => filename
404                    .clone()
405                    .or_else(|| file_id.clone())
406                    .or_else(|| file_url.clone())
407                    .map(|value| format!("[File: {value}]"))
408                    .unwrap_or_else(|| "[File]".to_string()),
409            })
410            .collect::<Vec<_>>()
411            .join("\n"),
412    }
413}
414
415/// Convert provider-agnostic messages into persisted history messages.
416pub fn messages_to_history_messages(messages: &[Message], start_turn: usize) -> Vec<HistoryMessage> {
417    let mut history_messages = Vec::with_capacity(messages.len());
418    let now = Utc::now();
419    let mut tool_names_by_call_id = HashMap::new();
420
421    for (i, message) in messages.iter().enumerate() {
422        let turn = start_turn + i;
423        let role = message.role.as_generic_str().to_string();
424
425        if let Some(tool_calls) = &message.tool_calls {
426            for tool_call in tool_calls {
427                if let Some(function) = &tool_call.function {
428                    tool_names_by_call_id.insert(tool_call.id.clone(), function.name.clone());
429                }
430            }
431        }
432
433        let content = if message.role == MessageRole::Tool {
434            let tool_name = message
435                .origin_tool
436                .clone()
437                .or_else(|| {
438                    message
439                        .tool_call_id
440                        .as_ref()
441                        .and_then(|id| tool_names_by_call_id.get(id).cloned())
442                })
443                .unwrap_or_else(|| "tool".to_string());
444            format!("[Tool response from {}: {}]", tool_name, history_text_from_message_content(&message.content))
445        } else {
446            let mut text_parts = Vec::new();
447            let content_text = history_text_from_message_content(&message.content);
448            if !content_text.is_empty() {
449                text_parts.push(content_text);
450            }
451
452            if let Some(reasoning) = message.reasoning.as_ref()
453                && !reasoning.trim().is_empty()
454            {
455                text_parts.push(format!("[Reasoning: {}]", reasoning.trim()));
456            }
457
458            if let Some(tool_calls) = &message.tool_calls {
459                for tool_call in tool_calls {
460                    if let Some(function) = &tool_call.function {
461                        text_parts.push(format!("[Tool call: {} with args: {}]", function.name, function.arguments));
462                    }
463                }
464            }
465
466            text_parts.join("\n")
467        };
468
469        history_messages.push(HistoryMessage {
470            turn,
471            role,
472            content,
473            tool_call_id: message.tool_call_id.clone(),
474            tool_name: message
475                .tool_call_id
476                .as_ref()
477                .and_then(|id| tool_names_by_call_id.get(id).cloned()),
478            timestamp: now,
479        });
480    }
481
482    history_messages
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488    use crate::llm::provider::{FunctionCall, Message, ToolCall};
489    use tempfile::tempdir;
490
491    #[tokio::test]
492    async fn test_history_manager_creation() {
493        let temp = tempdir().unwrap();
494        let manager = HistoryFileManager::new(temp.path(), "test_session");
495
496        assert!(manager.is_enabled());
497        assert_eq!(manager.session_id, "test_session");
498    }
499
500    #[tokio::test]
501    async fn test_write_history_async() {
502        let temp = tempdir().unwrap();
503        let mut manager = HistoryFileManager::new(temp.path(), "test_session");
504
505        let messages = vec![
506            HistoryMessage {
507                turn: 1,
508                role: "user".to_string(),
509                content: "Hello".to_string(),
510                tool_call_id: None,
511                tool_name: None,
512                timestamp: Utc::now(),
513            },
514            HistoryMessage {
515                turn: 2,
516                role: "assistant".to_string(),
517                content: "Hi there".to_string(),
518                tool_call_id: None,
519                tool_name: None,
520                timestamp: Utc::now(),
521            },
522        ];
523
524        let result = manager
525            .write_history(&messages, 5, "summarization", &["file.rs".to_string()], &["cargo build".to_string()])
526            .await
527            .unwrap();
528
529        assert!(result.file_path.to_string_lossy().contains("test_session"));
530        assert_eq!(result.metadata.message_count, 2);
531        assert_eq!(result.metadata.turn_number, 5);
532    }
533
534    #[test]
535    fn test_write_history_sync() {
536        let temp = tempdir().unwrap();
537        let mut manager = HistoryFileManager::new(temp.path(), "test_session_sync");
538
539        let messages = vec![HistoryMessage {
540            turn: 1,
541            role: "user".to_string(),
542            content: "Hello sync".to_string(),
543            tool_call_id: None,
544            tool_name: None,
545            timestamp: Utc::now(),
546        }];
547
548        let result = manager.write_history_sync(&messages, 3, "test", &[], &[]).unwrap();
549
550        assert!(result.file_path.to_string_lossy().contains("test_session_sync"));
551        assert_eq!(result.metadata.message_count, 1);
552    }
553
554    #[test]
555    fn test_sanitize_session_id() {
556        assert_eq!(sanitize_session_id("simple"), "simple");
557        assert_eq!(sanitize_session_id("with spaces"), "with_spaces");
558        assert_eq!(sanitize_session_id("a/b/c"), "a_b_c");
559    }
560
561    #[test]
562    fn test_format_summary_with_reference() {
563        let temp = tempdir().unwrap();
564        let manager = HistoryFileManager::new(temp.path(), "test");
565
566        let summary =
567            manager.format_summary_with_reference("Summarized 10 turns.", Path::new(".vtcode/history/test.jsonl"));
568
569        assert!(summary.contains("Summarized 10 turns"));
570        assert!(summary.contains(".vtcode/history/test.jsonl"));
571        assert!(summary.contains("exec_command.cmd"));
572    }
573
574    #[test]
575    fn messages_to_history_messages_preserves_tool_names() {
576        let messages = vec![
577            Message::assistant_with_tools(
578                "Calling tool".to_string(),
579                vec![ToolCall {
580                    id: "call_1".to_string(),
581                    call_type: "function".to_string(),
582                    function: Some(FunctionCall {
583                        namespace: None,
584                        name: "read_file".to_string(),
585                        arguments: "{\"path\":\"src/main.rs\"}".to_string(),
586                    }),
587                    text: None,
588                    thought_signature: None,
589                }],
590            ),
591            Message::tool_response("call_1".to_string(), "{\"ok\":true}".to_string()),
592        ];
593
594        let history = messages_to_history_messages(&messages, 4);
595        assert_eq!(history.len(), 2);
596        assert_eq!(history[0].turn, 4);
597        assert!(history[0].content.contains("read_file"));
598        assert_eq!(history[1].tool_name.as_deref(), Some("read_file"));
599        assert!(history[1].content.contains("Tool response from read_file"));
600    }
601}