Skip to main content

vtcode_core/tools/handlers/
turn_diff_tracker.rs

1//! Turn Diff Tracker (from Codex)
2//!
3//! Aggregates file diffs across multiple apply_patch tool calls within a turn.
4//! This provides a unified view of all changes made during a conversation turn.
5//!
6//! Supports Agent Trace attribution tracking for AI-generated code.
7
8use hashbrown::HashMap;
9use std::path::PathBuf;
10use std::sync::Arc;
11
12use serde::{Deserialize, Serialize};
13use tokio::sync::RwLock;
14
15/// Attribution information for a file change (Agent Trace compatible).
16#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
17#[serde(rename_all = "snake_case")]
18pub struct ChangeAttribution {
19    /// Model ID in provider/model format (e.g., "anthropic/claude-opus-4").
20    pub model_id: Option<String>,
21    /// Provider name (e.g., "anthropic", "openai").
22    pub provider: Option<String>,
23    /// Session ID linking to the conversation.
24    pub session_id: Option<String>,
25    /// Turn number within the session.
26    pub turn_number: Option<u32>,
27    /// Contributor type: "ai", "human", "mixed", "unknown".
28    pub contributor_type: String,
29}
30
31impl ChangeAttribution {
32    /// Create AI attribution with model info.
33    pub fn ai(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
34        Self {
35            model_id: Some(model_id.into()),
36            provider: Some(provider.into()),
37            session_id: None,
38            turn_number: None,
39            contributor_type: "ai".to_string(),
40        }
41    }
42
43    /// Create human attribution.
44    pub fn human() -> Self {
45        Self {
46            contributor_type: "human".to_string(),
47            ..Default::default()
48        }
49    }
50
51    /// Create unknown attribution.
52    pub fn unknown() -> Self {
53        Self {
54            contributor_type: "unknown".to_string(),
55            ..Default::default()
56        }
57    }
58
59    /// Add session context.
60    pub fn with_session(mut self, session_id: impl Into<String>, turn: u32) -> Self {
61        self.session_id = Some(session_id.into());
62        self.turn_number = Some(turn);
63        self
64    }
65
66    /// Get normalized model ID in provider/model format.
67    pub fn normalized_model_id(&self) -> Option<String> {
68        match (&self.model_id, &self.provider) {
69            (Some(model), Some(provider)) if !model.contains('/') => {
70                Some(format!("{}/{}", provider, model))
71            }
72            (Some(model), _) => Some(model.clone()),
73            _ => None,
74        }
75    }
76}
77
78/// File change types (from Codex protocol)
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FileChange {
81    /// The type of change.
82    #[serde(flatten)]
83    pub kind: FileChangeKind,
84    /// Attribution information (Agent Trace).
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub attribution: Option<ChangeAttribution>,
87    /// Line range affected (1-indexed, for Agent Trace).
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub line_range: Option<(u32, u32)>,
90}
91
92/// Kind of file change.
93#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
94#[serde(tag = "type", rename_all = "snake_case")]
95pub enum FileChangeKind {
96    /// New file added
97    Add { content: String },
98    /// File deleted
99    Delete { original_content: String },
100    /// File modified
101    Update {
102        old_content: String,
103        new_content: String,
104    },
105    /// File renamed
106    Rename {
107        new_path: PathBuf,
108        old_content: Option<String>,
109        new_content: Option<String>,
110    },
111}
112
113impl FileChange {
114    /// Create a new Add change.
115    pub fn add(content: impl Into<String>) -> Self {
116        Self {
117            kind: FileChangeKind::Add {
118                content: content.into(),
119            },
120            attribution: None,
121            line_range: None,
122        }
123    }
124
125    /// Create a new Delete change.
126    pub fn delete(original_content: impl Into<String>) -> Self {
127        Self {
128            kind: FileChangeKind::Delete {
129                original_content: original_content.into(),
130            },
131            attribution: None,
132            line_range: None,
133        }
134    }
135
136    /// Create a new Update change.
137    pub fn update(old_content: impl Into<String>, new_content: impl Into<String>) -> Self {
138        Self {
139            kind: FileChangeKind::Update {
140                old_content: old_content.into(),
141                new_content: new_content.into(),
142            },
143            attribution: None,
144            line_range: None,
145        }
146    }
147
148    /// Create a new Rename change.
149    pub fn rename(
150        new_path: PathBuf,
151        old_content: Option<String>,
152        new_content: Option<String>,
153    ) -> Self {
154        Self {
155            kind: FileChangeKind::Rename {
156                new_path,
157                old_content,
158                new_content,
159            },
160            attribution: None,
161            line_range: None,
162        }
163    }
164
165    /// Add attribution to the change.
166    pub fn with_attribution(mut self, attribution: ChangeAttribution) -> Self {
167        self.attribution = Some(attribution);
168        self
169    }
170
171    /// Add line range to the change.
172    pub fn with_line_range(mut self, start: u32, end: u32) -> Self {
173        self.line_range = Some((start, end));
174        self
175    }
176
177    /// Get the new content if any
178    pub fn new_content(&self) -> Option<&str> {
179        match &self.kind {
180            FileChangeKind::Add { content } => Some(content),
181            FileChangeKind::Update { new_content, .. } => Some(new_content),
182            FileChangeKind::Rename { new_content, .. } => new_content.as_deref(),
183            FileChangeKind::Delete { .. } => None,
184        }
185    }
186
187    /// Get the old content if any
188    pub fn old_content(&self) -> Option<&str> {
189        match &self.kind {
190            FileChangeKind::Delete { original_content } => Some(original_content),
191            FileChangeKind::Update { old_content, .. } => Some(old_content),
192            FileChangeKind::Rename { old_content, .. } => old_content.as_deref(),
193            FileChangeKind::Add { .. } => None,
194        }
195    }
196
197    /// Check if this is an add operation.
198    pub fn is_add(&self) -> bool {
199        matches!(self.kind, FileChangeKind::Add { .. })
200    }
201
202    /// Check if this is a delete operation.
203    pub fn is_delete(&self) -> bool {
204        matches!(self.kind, FileChangeKind::Delete { .. })
205    }
206
207    /// Check if this is an update operation.
208    pub fn is_update(&self) -> bool {
209        matches!(self.kind, FileChangeKind::Update { .. })
210    }
211
212    /// Check if this is a rename operation.
213    pub fn is_rename(&self) -> bool {
214        matches!(self.kind, FileChangeKind::Rename { .. })
215    }
216
217    /// Compute line count for the new content.
218    pub fn new_line_count(&self) -> usize {
219        self.new_content().map(|c| c.lines().count()).unwrap_or(0)
220    }
221
222    /// Convert from legacy FileChange enum (from tool_handler.rs).
223    ///
224    /// This provides backward compatibility with the older FileChange format.
225    pub fn from_legacy(
226        legacy: &super::tool_handler::FileChange,
227        attribution: Option<ChangeAttribution>,
228    ) -> Self {
229        let kind = match legacy {
230            super::tool_handler::FileChange::Add { content } => FileChangeKind::Add {
231                content: content.clone(),
232            },
233            super::tool_handler::FileChange::Delete => FileChangeKind::Delete {
234                original_content: String::new(), // Legacy doesn't preserve content
235            },
236            super::tool_handler::FileChange::Update {
237                old_content,
238                new_content,
239            } => FileChangeKind::Update {
240                old_content: old_content.clone(),
241                new_content: new_content.clone(),
242            },
243            super::tool_handler::FileChange::Rename { new_path, content } => {
244                FileChangeKind::Rename {
245                    new_path: new_path.clone(),
246                    old_content: None,
247                    new_content: content.clone(),
248                }
249            }
250        };
251        Self {
252            kind,
253            attribution,
254            line_range: None,
255        }
256    }
257
258    /// Convert to legacy FileChange enum (for backward compatibility).
259    pub fn to_legacy(&self) -> super::tool_handler::FileChange {
260        match &self.kind {
261            FileChangeKind::Add { content } => super::tool_handler::FileChange::Add {
262                content: content.clone(),
263            },
264            FileChangeKind::Delete { .. } => super::tool_handler::FileChange::Delete,
265            FileChangeKind::Update {
266                old_content,
267                new_content,
268            } => super::tool_handler::FileChange::Update {
269                old_content: old_content.clone(),
270                new_content: new_content.clone(),
271            },
272            FileChangeKind::Rename {
273                new_path,
274                new_content,
275                ..
276            } => super::tool_handler::FileChange::Rename {
277                new_path: new_path.clone(),
278                content: new_content.clone(),
279            },
280        }
281    }
282}
283
284/// Turn diff tracker for aggregating changes (from Codex)
285#[derive(Default)]
286pub struct TurnDiffTracker {
287    changes: HashMap<PathBuf, FileChange>,
288    pending_changes: Option<HashMap<PathBuf, FileChange>>,
289    /// Current attribution context for new changes.
290    current_attribution: Option<ChangeAttribution>,
291}
292
293impl TurnDiffTracker {
294    pub fn new() -> Self {
295        Self::default()
296    }
297
298    /// Set the current attribution context for subsequent changes.
299    pub fn set_attribution(&mut self, attribution: ChangeAttribution) {
300        self.current_attribution = Some(attribution);
301    }
302
303    /// Clear the current attribution context.
304    pub fn clear_attribution(&mut self) {
305        self.current_attribution = None;
306    }
307
308    /// Get the current attribution context.
309    pub fn current_attribution(&self) -> Option<&ChangeAttribution> {
310        self.current_attribution.as_ref()
311    }
312
313    /// Called when a patch application begins (from Codex)
314    ///
315    /// Stores the pending changes until the patch is confirmed
316    pub fn on_patch_begin(&mut self, changes: HashMap<PathBuf, FileChange>) {
317        // Apply current attribution to all changes
318        let changes_with_attribution: HashMap<PathBuf, FileChange> = changes
319            .into_iter()
320            .map(|(path, mut change)| {
321                if change.attribution.is_none() {
322                    change.attribution = self.current_attribution.clone();
323                }
324                (path, change)
325            })
326            .collect();
327        self.pending_changes = Some(changes_with_attribution);
328    }
329
330    /// Called when a patch application ends (from Codex)
331    ///
332    /// If successful, merges pending changes into the main tracker
333    pub fn on_patch_end(&mut self, success: bool) {
334        if success {
335            if let Some(pending) = self.pending_changes.take() {
336                for (path, change) in pending {
337                    self.merge_change(path, change);
338                }
339            }
340        } else {
341            self.pending_changes = None;
342        }
343    }
344
345    /// Merge a change into the tracker, combining with existing changes
346    fn merge_change(&mut self, path: PathBuf, change: FileChange) {
347        if let Some(existing) = self.changes.get(&path) {
348            // Merge the changes, preserving the latest attribution
349            let merged = match (&existing.kind, &change.kind) {
350                // Add then Update = Add with new content
351                (FileChangeKind::Add { .. }, FileChangeKind::Update { new_content, .. }) => {
352                    FileChange {
353                        kind: FileChangeKind::Add {
354                            content: new_content.clone(),
355                        },
356                        attribution: change.attribution.clone().or(existing.attribution.clone()),
357                        line_range: change.line_range,
358                    }
359                }
360                // Add then Delete = No change (remove from tracker)
361                (FileChangeKind::Add { .. }, FileChangeKind::Delete { .. }) => {
362                    self.changes.remove(&path);
363                    return;
364                }
365                // Update then Update = Update with combined old/new
366                (
367                    FileChangeKind::Update { old_content, .. },
368                    FileChangeKind::Update { new_content, .. },
369                ) => FileChange {
370                    kind: FileChangeKind::Update {
371                        old_content: old_content.clone(),
372                        new_content: new_content.clone(),
373                    },
374                    attribution: change.attribution.clone().or(existing.attribution.clone()),
375                    line_range: change.line_range,
376                },
377                // Update then Delete = Delete with original old content
378                (FileChangeKind::Update { old_content, .. }, FileChangeKind::Delete { .. }) => {
379                    FileChange {
380                        kind: FileChangeKind::Delete {
381                            original_content: old_content.clone(),
382                        },
383                        attribution: change.attribution.clone().or(existing.attribution.clone()),
384                        line_range: None,
385                    }
386                }
387                // Delete then Add = Update
388                (FileChangeKind::Delete { original_content }, FileChangeKind::Add { content }) => {
389                    FileChange {
390                        kind: FileChangeKind::Update {
391                            old_content: original_content.clone(),
392                            new_content: content.clone(),
393                        },
394                        attribution: change.attribution.clone().or(existing.attribution.clone()),
395                        line_range: change.line_range,
396                    }
397                }
398                // Default: use the new change
399                _ => change,
400            };
401            self.changes.insert(path, merged);
402        } else {
403            self.changes.insert(path, change);
404        }
405    }
406
407    /// Get all tracked changes
408    pub fn changes(&self) -> &HashMap<PathBuf, FileChange> {
409        &self.changes
410    }
411
412    /// Get pending changes (not yet confirmed)
413    pub fn pending_changes(&self) -> Option<&HashMap<PathBuf, FileChange>> {
414        self.pending_changes.as_ref()
415    }
416
417    /// Check if there are any changes
418    pub fn has_changes(&self) -> bool {
419        !self.changes.is_empty()
420    }
421
422    /// Get unified diff for all tracked changes (from Codex)
423    pub fn get_unified_diff(&self) -> String {
424        let mut diff = String::new();
425
426        for (path, change) in &self.changes {
427            let path_str = path.display();
428            match &change.kind {
429                FileChangeKind::Add { content } => {
430                    let new_label = path_str.to_string();
431                    diff.push_str(&compute_unified_diff_with_labels(
432                        "",
433                        content,
434                        "/dev/null",
435                        &new_label,
436                    ));
437                }
438                FileChangeKind::Delete { original_content } => {
439                    let old_label = path_str.to_string();
440                    diff.push_str(&compute_unified_diff_with_labels(
441                        original_content,
442                        "",
443                        &old_label,
444                        "/dev/null",
445                    ));
446                }
447                FileChangeKind::Update {
448                    old_content,
449                    new_content,
450                } => {
451                    let filename = path_str.to_string();
452                    diff.push_str(&compute_unified_diff_with_labels(
453                        old_content,
454                        new_content,
455                        &filename,
456                        &filename,
457                    ));
458                }
459                FileChangeKind::Rename {
460                    new_path,
461                    old_content,
462                    new_content,
463                } => {
464                    if let (Some(old), Some(new)) = (old_content, new_content) {
465                        let old_label = path_str.to_string();
466                        let new_label = new_path.to_string_lossy();
467                        diff.push_str(&compute_unified_diff_with_labels(
468                            old, new, &old_label, &new_label,
469                        ));
470                    }
471                }
472            }
473            diff.push('\n');
474        }
475
476        diff
477    }
478
479    /// Clear all tracked changes
480    pub fn clear(&mut self) {
481        self.changes.clear();
482        self.pending_changes = None;
483    }
484}
485
486/// Shared turn diff tracker (thread-safe) (from Codex)
487pub type SharedTurnDiffTracker = Arc<RwLock<TurnDiffTracker>>;
488
489/// Create a new shared diff tracker
490pub fn new_shared_tracker() -> SharedTurnDiffTracker {
491    Arc::new(RwLock::new(TurnDiffTracker::new()))
492}
493
494/// Compute unified diff between old and new content
495fn compute_unified_diff_with_labels(
496    old: &str,
497    new: &str,
498    old_label: &str,
499    new_label: &str,
500) -> String {
501    let old_label = format!("a/{}", old_label);
502    let new_label = format!("b/{}", new_label);
503    crate::utils::diff::format_unified_diff(
504        old,
505        new,
506        crate::utils::diff::DiffOptions {
507            context_lines: 3,
508            old_label: Some(&old_label),
509            new_label: Some(&new_label),
510            missing_newline_hint: false,
511        },
512    )
513}
514
515/// Format a diff for newly added content (for testing)
516#[cfg(test)]
517fn format_addition_diff(content: &str) -> String {
518    compute_unified_diff_with_labels("", content, "file", "file")
519}
520
521/// Format a diff for deleted content (for testing)
522#[cfg(test)]
523fn format_deletion_diff(content: &str) -> String {
524    compute_unified_diff_with_labels(content, "", "file", "file")
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    #[test]
532    fn test_on_patch_begin_and_end_success() {
533        let mut tracker = TurnDiffTracker::new();
534
535        let mut changes = HashMap::new();
536        changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
537
538        tracker.on_patch_begin(changes);
539        assert!(tracker.pending_changes().is_some());
540        assert!(!tracker.has_changes());
541
542        tracker.on_patch_end(true);
543        assert!(tracker.pending_changes().is_none());
544        assert!(tracker.has_changes());
545    }
546
547    #[test]
548    fn test_on_patch_end_failure() {
549        let mut tracker = TurnDiffTracker::new();
550
551        let mut changes = HashMap::new();
552        changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
553
554        tracker.on_patch_begin(changes);
555        tracker.on_patch_end(false);
556
557        assert!(tracker.pending_changes().is_none());
558        assert!(!tracker.has_changes());
559    }
560
561    #[test]
562    fn test_merge_add_then_update() {
563        let mut tracker = TurnDiffTracker::new();
564
565        // First patch: Add file
566        let mut changes1 = HashMap::new();
567        changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
568        tracker.on_patch_begin(changes1);
569        tracker.on_patch_end(true);
570
571        // Second patch: Update file
572        let mut changes2 = HashMap::new();
573        changes2.insert(
574            PathBuf::from("test.txt"),
575            FileChange::update("hello", "world"),
576        );
577        tracker.on_patch_begin(changes2);
578        tracker.on_patch_end(true);
579
580        // Result should be Add with new content
581        let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
582        assert!(change.is_add());
583        assert_eq!(change.new_content(), Some("world"));
584    }
585
586    #[test]
587    fn test_merge_add_then_delete() {
588        let mut tracker = TurnDiffTracker::new();
589
590        // First patch: Add file
591        let mut changes1 = HashMap::new();
592        changes1.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
593        tracker.on_patch_begin(changes1);
594        tracker.on_patch_end(true);
595
596        // Second patch: Delete file
597        let mut changes2 = HashMap::new();
598        changes2.insert(PathBuf::from("test.txt"), FileChange::delete("hello"));
599        tracker.on_patch_begin(changes2);
600        tracker.on_patch_end(true);
601
602        // Result: no change (add then delete cancels out)
603        assert!(!tracker.has_changes());
604    }
605
606    #[test]
607    fn test_get_unified_diff() {
608        let mut tracker = TurnDiffTracker::new();
609
610        let mut changes = HashMap::new();
611        changes.insert(PathBuf::from("new.txt"), FileChange::add("line1\nline2"));
612        tracker.on_patch_begin(changes);
613        tracker.on_patch_end(true);
614
615        let diff = tracker.get_unified_diff();
616        assert!(diff.contains("--- "));
617        assert!(diff.contains("+++ "));
618        assert!(diff.contains("new.txt"));
619        assert!(diff.contains("+line1"));
620        assert!(diff.contains("+line2"));
621    }
622
623    #[test]
624    fn test_attribution_propagation() {
625        let mut tracker = TurnDiffTracker::new();
626        tracker.set_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"));
627
628        let mut changes = HashMap::new();
629        changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
630        tracker.on_patch_begin(changes);
631        tracker.on_patch_end(true);
632
633        let change = tracker.changes().get(&PathBuf::from("test.txt")).unwrap();
634        assert!(change.attribution.is_some());
635        let attr = change.attribution.as_ref().unwrap();
636        assert_eq!(attr.contributor_type, "ai");
637        assert_eq!(attr.model_id, Some("claude-opus-4".to_string()));
638    }
639
640    #[test]
641    fn test_format_addition_diff() {
642        let diff = format_addition_diff("line1\nline2");
643        assert!(diff.contains("@@"));
644        assert!(diff.contains("+line1"));
645        assert!(diff.contains("+line2"));
646    }
647
648    #[test]
649    fn test_format_deletion_diff() {
650        let diff = format_deletion_diff("line1\nline2");
651        assert!(diff.contains("@@"));
652        assert!(diff.contains("-line1"));
653        assert!(diff.contains("-line2"));
654    }
655
656    #[test]
657    fn test_compute_unified_diff() {
658        let old = "line1\nline2\nline3";
659        let new = "line1\nmodified\nline3";
660        let diff = compute_unified_diff_with_labels(old, new, "file.txt", "file.txt");
661
662        assert!(diff.contains(" line1"));
663        assert!(diff.contains("-line2"));
664        assert!(diff.contains("+modified"));
665        assert!(diff.contains(" line3"));
666        assert!(diff.starts_with("--- a/"));
667    }
668
669    #[test]
670    fn test_file_change_accessors() {
671        let add = FileChange::add("hello");
672        assert_eq!(add.new_content(), Some("hello"));
673        assert_eq!(add.old_content(), None);
674        assert!(add.is_add());
675
676        let delete = FileChange::delete("goodbye");
677        assert_eq!(delete.new_content(), None);
678        assert_eq!(delete.old_content(), Some("goodbye"));
679        assert!(delete.is_delete());
680
681        let update = FileChange::update("old", "new");
682        assert_eq!(update.new_content(), Some("new"));
683        assert_eq!(update.old_content(), Some("old"));
684        assert!(update.is_update());
685    }
686
687    #[test]
688    fn test_normalized_model_id() {
689        let attr = ChangeAttribution::ai("claude-opus-4", "anthropic");
690        assert_eq!(
691            attr.normalized_model_id(),
692            Some("anthropic/claude-opus-4".to_string())
693        );
694
695        let attr2 = ChangeAttribution::ai("anthropic/claude-opus-4", "anthropic");
696        assert_eq!(
697            attr2.normalized_model_id(),
698            Some("anthropic/claude-opus-4".to_string())
699        );
700    }
701
702    #[tokio::test]
703    async fn test_shared_tracker() {
704        let tracker = new_shared_tracker();
705
706        {
707            let mut t = tracker.write().await;
708            let mut changes = HashMap::new();
709            changes.insert(PathBuf::from("test.txt"), FileChange::add("hello"));
710            t.on_patch_begin(changes);
711            t.on_patch_end(true);
712        }
713
714        {
715            let t = tracker.read().await;
716            assert!(t.has_changes());
717        }
718    }
719
720    #[test]
721    fn test_file_change_serialization() {
722        let change = FileChange::add("fn main() {}")
723            .with_attribution(ChangeAttribution::ai("claude-opus-4", "anthropic"))
724            .with_line_range(1, 5);
725
726        let json = serde_json::to_string_pretty(&change).unwrap();
727        assert!(json.contains("\"type\": \"add\""));
728        assert!(json.contains("\"content\": \"fn main() {}\""));
729        assert!(json.contains("\"contributor_type\": \"ai\""));
730        assert!(json.contains("\"model_id\": \"claude-opus-4\""));
731
732        // Roundtrip
733        let restored: FileChange = serde_json::from_str(&json).unwrap();
734        assert!(restored.is_add());
735        assert_eq!(restored.new_content(), Some("fn main() {}"));
736        assert!(restored.attribution.is_some());
737    }
738
739    #[test]
740    fn test_change_attribution_serialization() {
741        let attr = ChangeAttribution::ai("gpt-5", "openai").with_session("session-abc", 3);
742
743        let json = serde_json::to_string(&attr).unwrap();
744        let restored: ChangeAttribution = serde_json::from_str(&json).unwrap();
745
746        assert_eq!(restored.model_id, Some("gpt-5".to_string()));
747        assert_eq!(restored.provider, Some("openai".to_string()));
748        assert_eq!(restored.session_id, Some("session-abc".to_string()));
749        assert_eq!(restored.turn_number, Some(3));
750        assert_eq!(restored.contributor_type, "ai");
751    }
752}