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 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
415pub 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}