Skip to main content

vtcode_core/trace/
generator.rs

1//! Trace generation from file changes and session data.
2
3use crate::tools::handlers::turn_diff_tracker::{FileChange, FileChangeKind, TurnDiffTracker};
4use hashbrown::HashMap;
5use std::path::{Path, PathBuf};
6use vtcode_exec_events::trace::{
7    Contributor, RelatedResource, TraceConversation, TraceFile, TraceMetadata, TraceRange, TraceRecord,
8    TraceRecordBuilder, VtCodeMetadata, compute_content_hash, normalize_model_id,
9};
10
11// Re-export TraceContext for convenience
12pub use vtcode_exec_events::trace::TraceContext;
13
14/// Generate Agent Trace records from tracked file changes.
15pub struct TraceGenerator;
16
17impl TraceGenerator {
18    /// Generate a trace record from a TurnDiffTracker.
19    pub fn from_diff_tracker(tracker: &TurnDiffTracker, ctx: &TraceContext) -> Option<TraceRecord> {
20        if !tracker.has_changes() {
21            return None;
22        }
23
24        let mut builder = TraceRecordBuilder::new();
25
26        // Set VCS info
27        if let Some(ref revision) = ctx.revision {
28            builder = builder.git_revision(revision);
29        }
30
31        // Get workspace path or use current directory
32        let workspace_path = ctx.workspace_path.clone().unwrap_or_else(|| PathBuf::from("."));
33
34        // Generate files with attributed ranges
35        for (path, change) in tracker.changes() {
36            if let Some(trace_file) = Self::file_change_to_trace_file(path, change, ctx, &workspace_path) {
37                builder = builder.file(trace_file);
38            }
39        }
40
41        // Add VT Code metadata
42        let metadata = TraceMetadata {
43            confidence: Some(1.0), // Direct attribution is high confidence
44            vtcode: Some(VtCodeMetadata {
45                session_id: ctx.session_id.clone(),
46                turn_number: ctx.turn_number,
47                workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
48                provider: Some(ctx.provider.clone()),
49            }),
50            ..Default::default()
51        };
52        builder = builder.metadata(metadata);
53
54        let trace = builder.build();
55
56        // Only return if there are actual attributions
57        if trace.has_attributions() { Some(trace) } else { None }
58    }
59
60    /// Generate a trace record from raw file changes.
61    pub fn from_changes(changes: &HashMap<PathBuf, FileChange>, ctx: &TraceContext) -> Option<TraceRecord> {
62        if changes.is_empty() {
63            return None;
64        }
65
66        let mut builder = TraceRecordBuilder::new();
67
68        if let Some(ref revision) = ctx.revision {
69            builder = builder.git_revision(revision);
70        }
71
72        let workspace_path = ctx.workspace_path.clone().unwrap_or_else(|| PathBuf::from("."));
73
74        for (path, change) in changes {
75            if let Some(trace_file) = Self::file_change_to_trace_file(path, change, ctx, &workspace_path) {
76                builder = builder.file(trace_file);
77            }
78        }
79
80        let metadata = TraceMetadata {
81            confidence: Some(1.0),
82            vtcode: Some(VtCodeMetadata {
83                session_id: ctx.session_id.clone(),
84                turn_number: ctx.turn_number,
85                workspace_path: ctx.workspace_path.as_ref().map(|p| p.display().to_string()),
86                provider: Some(ctx.provider.clone()),
87            }),
88            ..Default::default()
89        };
90        builder = builder.metadata(metadata);
91
92        let trace = builder.build();
93        if trace.has_attributions() { Some(trace) } else { None }
94    }
95
96    /// Convert a FileChange to a TraceFile.
97    fn file_change_to_trace_file(
98        path: &Path,
99        change: &FileChange,
100        ctx: &TraceContext,
101        workspace_path: &Path,
102    ) -> Option<TraceFile> {
103        // Get relative path from workspace
104        let relative_path = path.strip_prefix(workspace_path).unwrap_or(path).to_string_lossy().to_string();
105
106        // Determine line range and content for hash
107        let (line_range, content_for_hash) = match &change.kind {
108            FileChangeKind::Add { content } => {
109                let line_count = content.lines().count() as u32;
110                (Some((1, line_count.max(1))), Some(content.as_str()))
111            }
112            FileChangeKind::Update { new_content, .. } => {
113                // For updates, use the change's line_range if available
114                if let Some((start, end)) = change.line_range {
115                    (Some((start, end)), Some(new_content.as_str()))
116                } else {
117                    let line_count = new_content.lines().count() as u32;
118                    (Some((1, line_count.max(1))), Some(new_content.as_str()))
119                }
120            }
121            FileChangeKind::Delete { .. } => {
122                // Deletions don't have attributed ranges in the new content
123                return None;
124            }
125            FileChangeKind::Rename { new_content, .. } => {
126                if let Some(content) = new_content {
127                    let line_count = content.lines().count() as u32;
128                    (Some((1, line_count.max(1))), Some(content.as_str()))
129                } else {
130                    return None;
131                }
132            }
133        };
134
135        let (start_line, end_line) = line_range?;
136
137        // Build the range
138        let mut range = TraceRange::new(start_line, end_line);
139        if let Some(content) = content_for_hash {
140            range = range.with_hash(compute_content_hash(content));
141        }
142
143        // Determine contributor
144        let contributor = if let Some(ref attr) = change.attribution {
145            if let Some(model_id) = attr.normalized_model_id() {
146                Contributor::ai(model_id)
147            } else {
148                match attr.contributor_type.as_str() {
149                    "human" => Contributor::human(),
150                    "mixed" => Contributor::mixed(),
151                    _ => Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider)),
152                }
153            }
154        } else {
155            // Default to AI with context model
156            Contributor::ai(normalize_model_id(&ctx.model_id, &ctx.provider))
157        };
158
159        // Build conversation
160        let mut conversation = TraceConversation {
161            url: None,
162            contributor: Some(contributor),
163            ranges: vec![range],
164            related: None,
165        };
166
167        // Add session URL if available
168        if let Some(ref session_id) = ctx.session_id {
169            // Use file:// URL for local session files
170            let session_url =
171                format!("file://{}/sessions/{}.json", workspace_path.join(".vtcode").display(), session_id);
172            conversation.url = Some(session_url.clone());
173            conversation.related = Some(vec![RelatedResource::session(session_url)]);
174        }
175
176        let mut trace_file = TraceFile::new(relative_path);
177        trace_file.add_conversation(conversation);
178
179        Some(trace_file)
180    }
181}
182
183/// Get the current git HEAD revision.
184///
185/// # Blocking
186///
187/// Spawns a `git rev-parse HEAD` subprocess and blocks until it exits.
188/// When called from an `async` context, wrap in
189/// [`tokio::task::spawn_blocking`] to avoid stalling the runtime.
190pub fn get_git_head_revision(workspace_path: &Path) -> Option<String> {
191    let output = std::process::Command::new("git")
192        .args(["rev-parse", "HEAD"])
193        .current_dir(workspace_path)
194        .output()
195        .ok()?;
196
197    if output.status.success() {
198        let revision = String::from_utf8_lossy(&output.stdout).trim().to_string();
199        if revision.len() >= 40 { Some(revision) } else { None }
200    } else {
201        None
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::tools::handlers::turn_diff_tracker::ChangeAttribution;
209
210    #[test]
211    fn test_trace_from_diff_tracker() {
212        let mut tracker = TurnDiffTracker::new();
213        tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
214
215        let mut changes = HashMap::new();
216        changes.insert(
217            PathBuf::from("/workspace/src/main.rs"),
218            FileChange::add("fn main() {\n    println!(\"Hello\");\n}"),
219        );
220        tracker.on_patch_begin(changes);
221        tracker.on_patch_end(true);
222
223        let ctx = TraceContext::new("claude-opus-4", "anthropic")
224            .with_workspace_path("/workspace")
225            .with_session_id("session-123")
226            .with_turn_number(1);
227
228        let trace = TraceGenerator::from_diff_tracker(&tracker, &ctx);
229        assert!(trace.is_some());
230
231        let trace = trace.unwrap();
232        assert_eq!(trace.files.len(), 1);
233        assert_eq!(trace.files[0].path, "src/main.rs");
234        assert_eq!(trace.files[0].conversations.len(), 1);
235        assert_eq!(trace.files[0].conversations[0].ranges.len(), 1);
236    }
237
238    #[test]
239    fn test_trace_with_git_revision() {
240        let ctx = TraceContext::new("gpt-5", "openai")
241            .with_workspace_path("/workspace")
242            .with_revision("abc123def456789012345678901234567890abcd");
243
244        let mut changes = HashMap::new();
245        changes.insert(PathBuf::from("/workspace/test.rs"), FileChange::add("test"));
246
247        let trace = TraceGenerator::from_changes(&changes, &ctx);
248        assert!(trace.is_some());
249
250        let trace = trace.unwrap();
251        assert!(trace.vcs.is_some());
252        assert_eq!(trace.vcs.unwrap().revision, "abc123def456789012345678901234567890abcd");
253    }
254
255    #[test]
256    fn test_trace_empty_changes() {
257        let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
258        let changes = HashMap::new();
259
260        let trace = TraceGenerator::from_changes(&changes, &ctx);
261        assert!(trace.is_none());
262    }
263
264    #[test]
265    fn test_trace_delete_not_included() {
266        let ctx = TraceContext::new("model", "provider").with_workspace_path("/workspace");
267
268        let mut changes = HashMap::new();
269        changes.insert(PathBuf::from("/workspace/deleted.rs"), FileChange::delete("old content"));
270
271        let trace = TraceGenerator::from_changes(&changes, &ctx);
272        // Deletions don't create attributions
273        assert!(trace.is_none());
274    }
275}