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