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 via `unified_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 `unified_search` 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(
132        workspace_root: &Path,
133        session_id: impl Into<String>,
134        config: HistoryConfig,
135    ) -> Self {
136        let history_dir = workspace_root.join(".vtcode").join("history");
137        Self {
138            workspace_root: workspace_root.to_path_buf(),
139            history_dir,
140            session_id: session_id.into(),
141            config,
142            file_counter: 0,
143        }
144    }
145
146    /// Check if history persistence is enabled
147    pub fn is_enabled(&self) -> bool {
148        self.config.enabled
149    }
150
151    /// Write conversation history to a file (synchronous version)
152    ///
153    /// Returns the file path and metadata if successful.
154    /// Use this when calling from synchronous code paths like summarization.
155    pub fn write_history_sync(
156        &mut self,
157        messages: &[HistoryMessage],
158        turn_number: usize,
159        reason: &str,
160        modified_files: &[String],
161        executed_commands: &[String],
162    ) -> Result<HistoryWriteResult> {
163        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
164        perf.tag("mode", "sync");
165        perf.tag("reason", reason.to_string());
166
167        if !self.config.enabled {
168            return Err(anyhow::anyhow!("History persistence is disabled"));
169        }
170
171        // Ensure directory exists
172        fs::create_dir_all(&self.history_dir).with_context(|| {
173            format!(
174                "Failed to create history directory: {}",
175                self.history_dir.display()
176            )
177        })?;
178
179        // Generate filename
180        self.file_counter += 1;
181        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
182        let filename = format!(
183            "{}_{:04}_{}.jsonl",
184            sanitize_session_id(&self.session_id),
185            turn_number,
186            timestamp
187        );
188        let file_path = self.history_dir.join(&filename);
189
190        // Build metadata
191        let metadata = HistoryMetadata {
192            session_id: self.session_id.clone(),
193            turn_number,
194            reason: reason.to_string(),
195            message_count: messages.len(),
196            modified_files: modified_files.to_vec(),
197            executed_commands: executed_commands.to_vec(),
198            written_at: Utc::now(),
199        };
200
201        // Build file content as JSONL
202        let mut content = String::new();
203
204        // Write metadata as first line
205        content.push_str(&serde_json::to_string(&serde_json::json!({
206            "_type": "metadata",
207            "_metadata": metadata
208        }))?);
209        content.push('\n');
210
211        // Write each message as a line
212        for msg in messages {
213            content.push_str(&serde_json::to_string(msg)?);
214            content.push('\n');
215        }
216
217        // Write file
218        fs::write(&file_path, &content)
219            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
220
221        // Calculate relative path
222        let relative_path = file_path
223            .strip_prefix(&self.workspace_root)
224            .unwrap_or(&file_path)
225            .to_path_buf();
226
227        info!(
228            session = %self.session_id,
229            turn = turn_number,
230            messages = messages.len(),
231            path = %relative_path.display(),
232            "Wrote conversation history to file"
233        );
234
235        // Cleanup old files synchronously
236        self.cleanup_old_files_sync();
237
238        Ok(HistoryWriteResult {
239            file_path: relative_path,
240            metadata,
241        })
242    }
243
244    /// Write conversation history to a file (async version)
245    ///
246    /// Returns the file path and metadata if successful
247    pub async fn write_history(
248        &mut self,
249        messages: &[HistoryMessage],
250        turn_number: usize,
251        reason: &str,
252        modified_files: &[String],
253        executed_commands: &[String],
254    ) -> Result<HistoryWriteResult> {
255        let mut perf = PerfSpan::new("vtcode.perf.history_write_ms");
256        perf.tag("mode", "async");
257        perf.tag("reason", reason.to_string());
258
259        if !self.config.enabled {
260            return Err(anyhow::anyhow!("History persistence is disabled"));
261        }
262
263        // Ensure directory exists
264        async_fs::create_dir_all(&self.history_dir)
265            .await
266            .with_context(|| {
267                format!(
268                    "Failed to create history directory: {}",
269                    self.history_dir.display()
270                )
271            })?;
272
273        // Generate filename
274        self.file_counter += 1;
275        let timestamp = Utc::now().format("%Y%m%dT%H%M%SZ");
276        let filename = format!(
277            "{}_{:04}_{}.jsonl",
278            sanitize_session_id(&self.session_id),
279            turn_number,
280            timestamp
281        );
282        let file_path = self.history_dir.join(&filename);
283
284        // Build metadata
285        let metadata = HistoryMetadata {
286            session_id: self.session_id.clone(),
287            turn_number,
288            reason: reason.to_string(),
289            message_count: messages.len(),
290            modified_files: modified_files.to_vec(),
291            executed_commands: executed_commands.to_vec(),
292            written_at: Utc::now(),
293        };
294
295        // Build file content as JSONL
296        let mut content = String::new();
297
298        // Write metadata as first line
299        content.push_str(&serde_json::to_string(&serde_json::json!({
300            "_type": "metadata",
301            "_metadata": metadata
302        }))?);
303        content.push('\n');
304
305        // Write each message as a line
306        for msg in messages {
307            content.push_str(&serde_json::to_string(msg)?);
308            content.push('\n');
309        }
310
311        // Write file
312        async_fs::write(&file_path, &content)
313            .await
314            .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
315
316        // Calculate relative path
317        let relative_path = file_path
318            .strip_prefix(&self.workspace_root)
319            .unwrap_or(&file_path)
320            .to_path_buf();
321
322        info!(
323            session = %self.session_id,
324            turn = turn_number,
325            messages = messages.len(),
326            path = %relative_path.display(),
327            "Wrote conversation history to file"
328        );
329
330        // Cleanup old files if needed
331        self.cleanup_old_files().await?;
332
333        Ok(HistoryWriteResult {
334            file_path: relative_path,
335            metadata,
336        })
337    }
338
339    /// Generate a summary message with file reference
340    pub fn format_summary_with_reference(&self, base_summary: &str, history_path: &Path) -> String {
341        format!(
342            "{}\n\nFull conversation history saved to: {}\nUse `unified_search` (action='grep') or `unified_file` (action='read') to inspect specific details if needed.",
343            base_summary,
344            history_path.display()
345        )
346    }
347
348    /// Cleanup old history files beyond the limit (synchronous)
349    fn cleanup_old_files_sync(&self) {
350        if !self.history_dir.exists() {
351            return;
352        }
353
354        let prefix = sanitize_session_id(&self.session_id);
355        let mut files: Vec<PathBuf> = Vec::new();
356
357        if let Ok(entries) = fs::read_dir(&self.history_dir) {
358            for entry in entries.flatten() {
359                let path = entry.path();
360                if let Some(name) = path.file_name().and_then(|n| n.to_str())
361                    && name.starts_with(&prefix)
362                    && name.ends_with(".jsonl")
363                {
364                    files.push(path);
365                }
366            }
367        }
368
369        // Sort by name (which includes timestamp) and remove oldest
370        files.sort();
371        let excess = files
372            .len()
373            .saturating_sub(self.config.max_files_per_session);
374
375        for old_file in files.into_iter().take(excess) {
376            if fs::remove_file(&old_file).is_ok() {
377                debug!(path = %old_file.display(), "Removed old history file");
378            }
379        }
380    }
381
382    /// Cleanup old history files beyond the limit (async)
383    async fn cleanup_old_files(&self) -> Result<()> {
384        if !self.history_dir.exists() {
385            return Ok(());
386        }
387
388        let prefix = sanitize_session_id(&self.session_id);
389        let mut files: Vec<PathBuf> = Vec::new();
390
391        let mut entries = async_fs::read_dir(&self.history_dir).await?;
392        while let Some(entry) = entries.next_entry().await? {
393            let path = entry.path();
394            if let Some(name) = path.file_name().and_then(|n| n.to_str())
395                && name.starts_with(&prefix)
396                && name.ends_with(".jsonl")
397            {
398                files.push(path);
399            }
400        }
401
402        // Sort by name (which includes timestamp) and remove oldest
403        files.sort();
404        let excess = files
405            .len()
406            .saturating_sub(self.config.max_files_per_session);
407
408        for old_file in files.into_iter().take(excess) {
409            if async_fs::remove_file(&old_file).await.is_ok() {
410                debug!(path = %old_file.display(), "Removed old history file");
411            }
412        }
413
414        Ok(())
415    }
416
417    /// Get the history directory path
418    pub fn history_dir(&self) -> &Path {
419        &self.history_dir
420    }
421}
422
423/// Sanitize session ID for use in filename
424fn sanitize_session_id(id: &str) -> String {
425    id.chars()
426        .map(|c| {
427            if c.is_alphanumeric() || c == '_' || c == '-' {
428                c
429            } else {
430                '_'
431            }
432        })
433        .take(32)
434        .collect()
435}
436
437fn history_text_from_message_content(content: &MessageContent) -> String {
438    match content {
439        MessageContent::Text(text) => text.clone(),
440        MessageContent::Parts(parts) => parts
441            .iter()
442            .map(|part| match part {
443                ContentPart::Text { text } => text.clone(),
444                ContentPart::Image { .. } => "[Image]".to_string(),
445                ContentPart::File {
446                    filename,
447                    file_id,
448                    file_url,
449                    ..
450                } => filename
451                    .clone()
452                    .or_else(|| file_id.clone())
453                    .or_else(|| file_url.clone())
454                    .map(|value| format!("[File: {value}]"))
455                    .unwrap_or_else(|| "[File]".to_string()),
456            })
457            .collect::<Vec<_>>()
458            .join("\n"),
459    }
460}
461
462/// Convert provider-agnostic messages into persisted history messages.
463pub fn messages_to_history_messages(
464    messages: &[Message],
465    start_turn: usize,
466) -> Vec<HistoryMessage> {
467    let mut history_messages = Vec::with_capacity(messages.len());
468    let now = Utc::now();
469    let mut tool_names_by_call_id = HashMap::new();
470
471    for (i, message) in messages.iter().enumerate() {
472        let turn = start_turn + i;
473        let role = message.role.as_generic_str().to_string();
474
475        if let Some(tool_calls) = &message.tool_calls {
476            for tool_call in tool_calls {
477                if let Some(function) = &tool_call.function {
478                    tool_names_by_call_id.insert(tool_call.id.clone(), function.name.clone());
479                }
480            }
481        }
482
483        let content = if message.role == MessageRole::Tool {
484            let tool_name = message
485                .origin_tool
486                .clone()
487                .or_else(|| {
488                    message
489                        .tool_call_id
490                        .as_ref()
491                        .and_then(|id| tool_names_by_call_id.get(id).cloned())
492                })
493                .unwrap_or_else(|| "tool".to_string());
494            format!(
495                "[Tool response from {}: {}]",
496                tool_name,
497                history_text_from_message_content(&message.content)
498            )
499        } else {
500            let mut text_parts = Vec::new();
501            let content_text = history_text_from_message_content(&message.content);
502            if !content_text.is_empty() {
503                text_parts.push(content_text);
504            }
505
506            if let Some(reasoning) = message.reasoning.as_ref()
507                && !reasoning.trim().is_empty()
508            {
509                text_parts.push(format!("[Reasoning: {}]", reasoning.trim()));
510            }
511
512            if let Some(tool_calls) = &message.tool_calls {
513                for tool_call in tool_calls {
514                    if let Some(function) = &tool_call.function {
515                        text_parts.push(format!(
516                            "[Tool call: {} with args: {}]",
517                            function.name, function.arguments
518                        ));
519                    }
520                }
521            }
522
523            text_parts.join("\n")
524        };
525
526        history_messages.push(HistoryMessage {
527            turn,
528            role,
529            content,
530            tool_call_id: message.tool_call_id.clone(),
531            tool_name: message
532                .tool_call_id
533                .as_ref()
534                .and_then(|id| tool_names_by_call_id.get(id).cloned()),
535            timestamp: now,
536        });
537    }
538
539    history_messages
540}
541
542#[cfg(test)]
543mod tests {
544    use super::*;
545    use crate::llm::provider::{FunctionCall, Message, ToolCall};
546    use tempfile::tempdir;
547
548    #[tokio::test]
549    async fn test_history_manager_creation() {
550        let temp = tempdir().unwrap();
551        let manager = HistoryFileManager::new(temp.path(), "test_session");
552
553        assert!(manager.is_enabled());
554        assert_eq!(manager.session_id, "test_session");
555    }
556
557    #[tokio::test]
558    async fn test_write_history_async() {
559        let temp = tempdir().unwrap();
560        let mut manager = HistoryFileManager::new(temp.path(), "test_session");
561
562        let messages = vec![
563            HistoryMessage {
564                turn: 1,
565                role: "user".to_string(),
566                content: "Hello".to_string(),
567                tool_call_id: None,
568                tool_name: None,
569                timestamp: Utc::now(),
570            },
571            HistoryMessage {
572                turn: 2,
573                role: "assistant".to_string(),
574                content: "Hi there".to_string(),
575                tool_call_id: None,
576                tool_name: None,
577                timestamp: Utc::now(),
578            },
579        ];
580
581        let result = manager
582            .write_history(
583                &messages,
584                5,
585                "summarization",
586                &["file.rs".to_string()],
587                &["cargo build".to_string()],
588            )
589            .await
590            .unwrap();
591
592        assert!(result.file_path.to_string_lossy().contains("test_session"));
593        assert_eq!(result.metadata.message_count, 2);
594        assert_eq!(result.metadata.turn_number, 5);
595    }
596
597    #[test]
598    fn test_write_history_sync() {
599        let temp = tempdir().unwrap();
600        let mut manager = HistoryFileManager::new(temp.path(), "test_session_sync");
601
602        let messages = vec![HistoryMessage {
603            turn: 1,
604            role: "user".to_string(),
605            content: "Hello sync".to_string(),
606            tool_call_id: None,
607            tool_name: None,
608            timestamp: Utc::now(),
609        }];
610
611        let result = manager
612            .write_history_sync(&messages, 3, "test", &[], &[])
613            .unwrap();
614
615        assert!(
616            result
617                .file_path
618                .to_string_lossy()
619                .contains("test_session_sync")
620        );
621        assert_eq!(result.metadata.message_count, 1);
622    }
623
624    #[test]
625    fn test_sanitize_session_id() {
626        assert_eq!(sanitize_session_id("simple"), "simple");
627        assert_eq!(sanitize_session_id("with spaces"), "with_spaces");
628        assert_eq!(sanitize_session_id("a/b/c"), "a_b_c");
629    }
630
631    #[test]
632    fn test_format_summary_with_reference() {
633        let temp = tempdir().unwrap();
634        let manager = HistoryFileManager::new(temp.path(), "test");
635
636        let summary = manager.format_summary_with_reference(
637            "Summarized 10 turns.",
638            Path::new(".vtcode/history/test.jsonl"),
639        );
640
641        assert!(summary.contains("Summarized 10 turns"));
642        assert!(summary.contains(".vtcode/history/test.jsonl"));
643        assert!(summary.contains("unified_search"));
644    }
645
646    #[test]
647    fn messages_to_history_messages_preserves_tool_names() {
648        let messages = vec![
649            Message::assistant_with_tools(
650                "Calling tool".to_string(),
651                vec![ToolCall {
652                    id: "call_1".to_string(),
653                    call_type: "function".to_string(),
654                    function: Some(FunctionCall {
655                        namespace: None,
656                        name: "read_file".to_string(),
657                        arguments: "{\"path\":\"src/main.rs\"}".to_string(),
658                    }),
659                    text: None,
660                    thought_signature: None,
661                }],
662            ),
663            Message::tool_response("call_1".to_string(), "{\"ok\":true}".to_string()),
664        ];
665
666        let history = messages_to_history_messages(&messages, 4);
667        assert_eq!(history.len(), 2);
668        assert_eq!(history[0].turn, 4);
669        assert!(history[0].content.contains("read_file"));
670        assert_eq!(history[1].tool_name.as_deref(), Some("read_file"));
671        assert!(history[1].content.contains("Tool response from read_file"));
672    }
673}