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(
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 pub fn is_enabled(&self) -> bool {
148 self.config.enabled
149 }
150
151 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 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 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 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 let mut content = String::new();
203
204 content.push_str(&serde_json::to_string(&serde_json::json!({
206 "_type": "metadata",
207 "_metadata": metadata
208 }))?);
209 content.push('\n');
210
211 for msg in messages {
213 content.push_str(&serde_json::to_string(msg)?);
214 content.push('\n');
215 }
216
217 fs::write(&file_path, &content)
219 .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
220
221 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 self.cleanup_old_files_sync();
237
238 Ok(HistoryWriteResult {
239 file_path: relative_path,
240 metadata,
241 })
242 }
243
244 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 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 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 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 let mut content = String::new();
297
298 content.push_str(&serde_json::to_string(&serde_json::json!({
300 "_type": "metadata",
301 "_metadata": metadata
302 }))?);
303 content.push('\n');
304
305 for msg in messages {
307 content.push_str(&serde_json::to_string(msg)?);
308 content.push('\n');
309 }
310
311 async_fs::write(&file_path, &content)
313 .await
314 .with_context(|| format!("Failed to write history file: {}", file_path.display()))?;
315
316 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 self.cleanup_old_files().await?;
332
333 Ok(HistoryWriteResult {
334 file_path: relative_path,
335 metadata,
336 })
337 }
338
339 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 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 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 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 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 pub fn history_dir(&self) -> &Path {
419 &self.history_dir
420 }
421}
422
423fn 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
462pub 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}