1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct HistoryConfig {
28 #[serde(default = "default_enabled")]
30 pub enabled: bool,
31
32 #[serde(default = "default_max_files")]
34 pub max_files_per_session: usize,
35
36 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct HistoryMessage {
66 pub turn: usize,
68 pub role: String,
70 pub content: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub tool_call_id: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 pub tool_name: Option<String>,
78 pub timestamp: DateTime<Utc>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize)]
84pub struct HistoryMetadata {
85 pub session_id: String,
87 pub turn_number: usize,
89 pub reason: String,
91 pub message_count: usize,
93 pub modified_files: Vec<String>,
95 pub executed_commands: Vec<String>,
97 pub written_at: DateTime<Utc>,
99}
100
101#[derive(Debug, Clone)]
103pub struct HistoryWriteResult {
104 pub file_path: PathBuf,
106 pub metadata: HistoryMetadata,
108}
109
110pub struct HistoryFileManager {
112 workspace_root: PathBuf,
114 history_dir: PathBuf,
116 session_id: String,
118 config: HistoryConfig,
120 file_counter: usize,
122}
123
124impl HistoryFileManager {
125 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 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 pub fn is_enabled(&self) -> bool {
144 self.config.enabled
145 }
146
147 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 fs::create_dir_all(&self.history_dir)
169 .with_context(|| format!("Failed to create history directory: {}", self.history_dir.display()))?;
170
171 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 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 let mut content: Vec<u8> = Vec::new();
191
192 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 for msg in messages {
204 serde_json::to_writer(&mut content, msg)?;
205 content.push(b'\n');
206 }
207
208 fs::write(&file_path, &content)
210 .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
211
212 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 self.cleanup_old_files_sync();
225
226 Ok(HistoryWriteResult { file_path: relative_path, metadata })
227 }
228
229 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 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 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 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 let mut content: Vec<u8> = Vec::new();
273
274 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 for msg in messages {
286 serde_json::to_writer(&mut content, msg)?;
287 content.push(b'\n');
288 }
289
290 async_fs::write(&file_path, &content)
292 .await
293 .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
294
295 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 self.cleanup_old_files().await?;
308
309 Ok(HistoryWriteResult { file_path: relative_path, metadata })
310 }
311
312 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 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 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 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 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
387fn sanitize_session_id(id: &str) -> String {
389 id.chars()
390 .map(|c| {
391 if c.is_alphanumeric() || c == '_' || c == '-' {
392 c
393 } else {
394 '_'
395 }
396 })
397 .take(32)
398 .collect()
399}
400
401fn history_text_from_message_content(content: &MessageContent) -> String {
402 match content {
403 MessageContent::Text(text) => text.clone(),
404 MessageContent::Parts(parts) => parts
405 .iter()
406 .map(|part| match part {
407 ContentPart::Text { text } => text.clone(),
408 ContentPart::Image { .. } => "[Image]".to_string(),
409 ContentPart::File { filename, file_id, file_url, .. } => filename
410 .clone()
411 .or_else(|| file_id.clone())
412 .or_else(|| file_url.clone())
413 .map(|value| format!("[File: {value}]"))
414 .unwrap_or_else(|| "[File]".to_string()),
415 })
416 .collect::<Vec<_>>()
417 .join("\n"),
418 }
419}
420
421pub fn messages_to_history_messages(messages: &[Message], start_turn: usize) -> Vec<HistoryMessage> {
423 let mut history_messages = Vec::with_capacity(messages.len());
424 let now = Utc::now();
425 let mut tool_names_by_call_id = HashMap::new();
426
427 for (i, message) in messages.iter().enumerate() {
428 let turn = start_turn + i;
429 let role = message.role.as_generic_str().to_string();
430
431 if let Some(tool_calls) = &message.tool_calls {
432 for tool_call in tool_calls {
433 if let Some(function) = &tool_call.function {
434 tool_names_by_call_id.insert(tool_call.id.clone(), function.name.clone());
435 }
436 }
437 }
438
439 let content = if message.role == MessageRole::Tool {
440 let tool_name = message
441 .origin_tool
442 .clone()
443 .or_else(|| {
444 message
445 .tool_call_id
446 .as_ref()
447 .and_then(|id| tool_names_by_call_id.get(id).cloned())
448 })
449 .unwrap_or_else(|| "tool".to_string());
450 format!("[Tool response from {}: {}]", tool_name, history_text_from_message_content(&message.content))
451 } else {
452 let mut text_parts = Vec::new();
453 let content_text = history_text_from_message_content(&message.content);
454 if !content_text.is_empty() {
455 text_parts.push(content_text);
456 }
457
458 if let Some(reasoning) = message.reasoning.as_ref()
459 && !reasoning.trim().is_empty()
460 {
461 text_parts.push(format!("[Reasoning: {}]", reasoning.trim()));
462 }
463
464 if let Some(tool_calls) = &message.tool_calls {
465 for tool_call in tool_calls {
466 if let Some(function) = &tool_call.function {
467 text_parts.push(format!("[Tool call: {} with args: {}]", function.name, function.arguments));
468 }
469 }
470 }
471
472 text_parts.join("\n")
473 };
474
475 history_messages.push(HistoryMessage {
476 turn,
477 role,
478 content,
479 tool_call_id: message.tool_call_id.clone(),
480 tool_name: message
481 .tool_call_id
482 .as_ref()
483 .and_then(|id| tool_names_by_call_id.get(id).cloned()),
484 timestamp: now,
485 });
486 }
487
488 history_messages
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use crate::llm::provider::{FunctionCall, Message, ToolCall};
495 use tempfile::tempdir;
496
497 #[tokio::test]
498 async fn test_history_manager_creation() {
499 let temp = tempdir().unwrap();
500 let manager = HistoryFileManager::new(temp.path(), "test_session");
501
502 assert!(manager.is_enabled());
503 assert_eq!(manager.session_id, "test_session");
504 }
505
506 #[tokio::test]
507 async fn test_write_history_async() {
508 let temp = tempdir().unwrap();
509 let mut manager = HistoryFileManager::new(temp.path(), "test_session");
510
511 let messages = vec![
512 HistoryMessage {
513 turn: 1,
514 role: "user".to_string(),
515 content: "Hello".to_string(),
516 tool_call_id: None,
517 tool_name: None,
518 timestamp: Utc::now(),
519 },
520 HistoryMessage {
521 turn: 2,
522 role: "assistant".to_string(),
523 content: "Hi there".to_string(),
524 tool_call_id: None,
525 tool_name: None,
526 timestamp: Utc::now(),
527 },
528 ];
529
530 let result = manager
531 .write_history(&messages, 5, "summarization", &["file.rs".to_string()], &["cargo build".to_string()])
532 .await
533 .unwrap();
534
535 assert!(result.file_path.to_string_lossy().contains("test_session"));
536 assert_eq!(result.metadata.message_count, 2);
537 assert_eq!(result.metadata.turn_number, 5);
538 }
539
540 #[test]
541 fn test_write_history_sync() {
542 let temp = tempdir().unwrap();
543 let mut manager = HistoryFileManager::new(temp.path(), "test_session_sync");
544
545 let messages = vec![HistoryMessage {
546 turn: 1,
547 role: "user".to_string(),
548 content: "Hello sync".to_string(),
549 tool_call_id: None,
550 tool_name: None,
551 timestamp: Utc::now(),
552 }];
553
554 let result = manager.write_history_sync(&messages, 3, "test", &[], &[]).unwrap();
555
556 assert!(result.file_path.to_string_lossy().contains("test_session_sync"));
557 assert_eq!(result.metadata.message_count, 1);
558 }
559
560 #[test]
561 fn test_sanitize_session_id() {
562 assert_eq!(sanitize_session_id("simple"), "simple");
563 assert_eq!(sanitize_session_id("with spaces"), "with_spaces");
564 assert_eq!(sanitize_session_id("a/b/c"), "a_b_c");
565 }
566
567 #[test]
568 fn test_format_summary_with_reference() {
569 let temp = tempdir().unwrap();
570 let manager = HistoryFileManager::new(temp.path(), "test");
571
572 let summary =
573 manager.format_summary_with_reference("Summarized 10 turns.", Path::new(".vtcode/history/test.jsonl"));
574
575 assert!(summary.contains("Summarized 10 turns"));
576 assert!(summary.contains(".vtcode/history/test.jsonl"));
577 assert!(summary.contains("exec_command.cmd"));
578 }
579
580 #[test]
581 fn messages_to_history_messages_preserves_tool_names() {
582 let messages = vec![
583 Message::assistant_with_tools(
584 "Calling tool".to_string(),
585 vec![ToolCall {
586 id: "call_1".to_string(),
587 call_type: "function".to_string(),
588 function: Some(FunctionCall {
589 namespace: None,
590 name: "read_file".to_string(),
591 arguments: "{\"path\":\"src/main.rs\"}".to_string(),
592 }),
593 text: None,
594 thought_signature: None,
595 }],
596 ),
597 Message::tool_response("call_1".to_string(), "{\"ok\":true}".to_string()),
598 ];
599
600 let history = messages_to_history_messages(&messages, 4);
601 assert_eq!(history.len(), 2);
602 assert_eq!(history[0].turn, 4);
603 assert!(history[0].content.contains("read_file"));
604 assert_eq!(history[1].tool_name.as_deref(), Some("read_file"));
605 assert!(history[1].content.contains("Tool response from read_file"));
606 }
607}