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