1use chrono::{DateTime, Utc};
40use hashbrown::HashMap;
41use serde::{Deserialize, Serialize};
42use serde_json::Value;
43use std::path::PathBuf;
44
45pub const AGENT_TRACE_VERSION: &str = "0.1.0";
47
48pub const AGENT_TRACE_MIME_TYPE: &str = "application/vnd.agent-trace.record+json";
50
51#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
60#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
61pub struct TraceRecord {
62 pub version: String,
64
65 #[serde(serialize_with = "serialize_uuid", deserialize_with = "deserialize_uuid")]
67 #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
68 pub id: uuid::Uuid,
69
70 #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
72 pub timestamp: DateTime<Utc>,
73
74 #[serde(skip_serializing_if = "Option::is_none")]
76 pub vcs: Option<VcsInfo>,
77
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub tool: Option<ToolInfo>,
81
82 pub files: Vec<TraceFile>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub metadata: Option<TraceMetadata>,
88}
89
90impl TraceRecord {
91 fn new() -> Self {
93 Self {
94 version: AGENT_TRACE_VERSION.to_string(),
95 id: uuid::Uuid::new_v4(),
96 timestamp: Utc::now(),
97 vcs: None,
98 tool: Some(ToolInfo::vtcode()),
99 files: Vec::new(),
100 metadata: None,
101 }
102 }
103
104 fn for_git_revision(revision: impl Into<String>) -> Self {
106 let mut trace = Self::new();
107 trace.vcs = Some(VcsInfo::git(revision));
108 trace
109 }
110
111 pub fn add_file(&mut self, file: TraceFile) {
113 self.files.push(file);
114 }
115
116 pub fn has_attributions(&self) -> bool {
118 self.files.iter().any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
119 }
120}
121
122impl Default for TraceRecord {
123 fn default() -> Self {
124 Self::new()
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
135pub struct VcsInfo {
136 #[serde(rename = "type")]
138 pub vcs_type: VcsType,
139
140 pub revision: String,
142}
143
144impl VcsInfo {
145 fn git(revision: impl Into<String>) -> Self {
147 Self { vcs_type: VcsType::Git, revision: revision.into() }
148 }
149
150 pub fn jj(change_id: impl Into<String>) -> Self {
152 Self { vcs_type: VcsType::Jj, revision: change_id.into() }
153 }
154}
155
156#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
158#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
159#[serde(rename_all = "lowercase")]
160pub enum VcsType {
161 Git,
163 Jj,
165 Hg,
167 Svn,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
177#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
178pub struct ToolInfo {
179 pub name: String,
181
182 #[serde(skip_serializing_if = "Option::is_none")]
184 pub version: Option<String>,
185}
186
187impl ToolInfo {
188 fn vtcode() -> Self {
190 Self {
191 name: "vtcode".to_string(),
192 version: Some(env!("CARGO_PKG_VERSION").to_string()),
193 }
194 }
195
196 pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
198 Self { name: name.into(), version }
199 }
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
208#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
209pub struct TraceFile {
210 pub path: String,
212
213 pub conversations: Vec<TraceConversation>,
215}
216
217impl TraceFile {
218 pub fn new(path: impl Into<String>) -> Self {
220 Self { path: path.into(), conversations: Vec::new() }
221 }
222
223 pub fn add_conversation(&mut self, conversation: TraceConversation) {
225 self.conversations.push(conversation);
226 }
227
228 pub fn with_ai_ranges(path: impl Into<String>, model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
230 let mut file = Self::new(path);
231 file.add_conversation(TraceConversation {
232 url: None,
233 contributor: Some(Contributor::ai(model_id)),
234 ranges,
235 related: None,
236 });
237 file
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
243#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
244pub struct TraceConversation {
245 #[serde(skip_serializing_if = "Option::is_none")]
247 pub url: Option<String>,
248
249 #[serde(skip_serializing_if = "Option::is_none")]
251 pub contributor: Option<Contributor>,
252
253 pub ranges: Vec<TraceRange>,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub related: Option<Vec<RelatedResource>>,
259}
260
261impl TraceConversation {
262 pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
264 Self {
265 url: None,
266 contributor: Some(Contributor::ai(model_id)),
267 ranges,
268 related: None,
269 }
270 }
271
272 pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
274 self.url = Some(url.into());
275 self
276 }
277}
278
279#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
281#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
282pub struct RelatedResource {
283 #[serde(rename = "type")]
285 resource_type: String,
286
287 url: String,
289}
290
291impl RelatedResource {
292 pub fn session(url: impl Into<String>) -> Self {
294 Self {
295 resource_type: "session".to_string(),
296 url: url.into(),
297 }
298 }
299
300 pub fn prompt(url: impl Into<String>) -> Self {
302 Self {
303 resource_type: "prompt".to_string(),
304 url: url.into(),
305 }
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
315#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
316pub struct TraceRange {
317 start_line: u32,
319
320 end_line: u32,
322
323 #[serde(skip_serializing_if = "Option::is_none")]
325 content_hash: Option<String>,
326
327 #[serde(skip_serializing_if = "Option::is_none")]
329 contributor: Option<Contributor>,
330}
331
332impl TraceRange {
333 pub fn new(start_line: u32, end_line: u32) -> Self {
335 Self {
336 start_line,
337 end_line,
338 content_hash: None,
339 contributor: None,
340 }
341 }
342
343 pub fn single_line(line: u32) -> Self {
345 Self::new(line, line)
346 }
347
348 pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
350 self.content_hash = Some(hash.into());
351 self
352 }
353
354 fn with_content_hash(mut self, content: &str) -> Self {
356 let hash = compute_content_hash(content);
357 self.content_hash = Some(hash);
358 self
359 }
360}
361
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
368#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
369pub struct Contributor {
370 #[serde(rename = "type")]
372 contributor_type: ContributorType,
373
374 #[serde(skip_serializing_if = "Option::is_none")]
376 model_id: Option<String>,
377}
378
379impl Contributor {
380 pub fn ai(model_id: impl Into<String>) -> Self {
382 Self {
383 contributor_type: ContributorType::Ai,
384 model_id: Some(model_id.into()),
385 }
386 }
387
388 pub fn human() -> Self {
390 Self {
391 contributor_type: ContributorType::Human,
392 model_id: None,
393 }
394 }
395
396 pub fn mixed() -> Self {
398 Self {
399 contributor_type: ContributorType::Mixed,
400 model_id: None,
401 }
402 }
403
404 pub fn unknown() -> Self {
406 Self {
407 contributor_type: ContributorType::Unknown,
408 model_id: None,
409 }
410 }
411}
412
413#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
415#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
416#[serde(rename_all = "lowercase")]
417pub enum ContributorType {
418 Human,
420 Ai,
422 Mixed,
424 Unknown,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
434#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
435pub struct TraceMetadata {
436 #[serde(skip_serializing_if = "Option::is_none")]
438 pub confidence: Option<f64>,
439
440 #[serde(skip_serializing_if = "Option::is_none")]
442 pub post_processing_tools: Option<Vec<String>>,
443
444 #[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
446 pub vtcode: Option<VtCodeMetadata>,
447
448 #[serde(flatten)]
450 #[cfg_attr(feature = "schema-export", schemars(skip))]
451 pub extra: HashMap<String, Value>,
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
457pub struct VtCodeMetadata {
458 #[serde(skip_serializing_if = "Option::is_none")]
460 pub session_id: Option<String>,
461
462 #[serde(skip_serializing_if = "Option::is_none")]
464 pub turn_number: Option<u32>,
465
466 #[serde(skip_serializing_if = "Option::is_none")]
468 pub workspace_path: Option<String>,
469
470 #[serde(skip_serializing_if = "Option::is_none")]
472 pub provider: Option<String>,
473}
474
475#[derive(Debug, Clone, Copy, Default)]
481pub enum HashAlgorithm {
482 #[default]
484 MurmurHash3,
485 Fnv1a,
487}
488
489pub fn compute_content_hash(content: &str) -> String {
493 compute_content_hash_with(content, HashAlgorithm::default())
494}
495
496pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
498 match algorithm {
499 HashAlgorithm::MurmurHash3 => {
500 let hash = murmur3_32(content.as_bytes(), 0);
502 format!("murmur3:{hash:08x}")
503 }
504 HashAlgorithm::Fnv1a => {
505 const FNV_OFFSET: u64 = 14695981039346656037;
506 const FNV_PRIME: u64 = 1099511628211;
507 let mut hash = FNV_OFFSET;
508 for byte in content.bytes() {
509 hash ^= byte as u64;
510 hash = hash.wrapping_mul(FNV_PRIME);
511 }
512 format!("fnv1a:{hash:016x}")
513 }
514 }
515}
516
517#[expect(
519 clippy::expect_used,
520 clippy::indexing_slicing,
521 clippy::cast_possible_truncation,
522 reason = "MurmurHash3 intentionally processes fixed four-byte chunks and folds arbitrary input length modulo u32."
523)]
524fn murmur3_32(data: &[u8], seed: u32) -> u32 {
525 const C1: u32 = 0xcc9e2d51;
526 const C2: u32 = 0x1b873593;
527 const R1: u32 = 15;
528 const R2: u32 = 13;
529 const M: u32 = 5;
530 const N: u32 = 0xe6546b64;
531
532 let mut hash = seed;
533 let len = data.len();
534
535 let mut chunks = data.chunks_exact(4);
539 for chunk in &mut chunks {
540 let chunk_array: [u8; 4] = chunk.try_into().expect("chunks_exact(4) yields len-4 slices");
541 let mut k = u32::from_le_bytes(chunk_array);
542 k = k.wrapping_mul(C1);
543 k = k.rotate_left(R1);
544 k = k.wrapping_mul(C2);
545 hash ^= k;
546 hash = hash.rotate_left(R2);
547 hash = hash.wrapping_mul(M).wrapping_add(N);
548 }
549
550 let tail = chunks.remainder();
552 let mut k1: u32 = 0;
553 match tail.len() {
554 3 => {
555 k1 ^= (tail[2] as u32) << 16;
556 k1 ^= (tail[1] as u32) << 8;
557 k1 ^= tail[0] as u32;
558 }
559 2 => {
560 k1 ^= (tail[1] as u32) << 8;
561 k1 ^= tail[0] as u32;
562 }
563 1 => {
564 k1 ^= tail[0] as u32;
565 }
566 _ => {}
567 }
568 if !tail.is_empty() {
569 k1 = k1.wrapping_mul(C1);
570 k1 = k1.rotate_left(R1);
571 k1 = k1.wrapping_mul(C2);
572 hash ^= k1;
573 }
574
575 hash ^= len as u32;
577 hash ^= hash >> 16;
578 hash = hash.wrapping_mul(0x85ebca6b);
579 hash ^= hash >> 13;
580 hash = hash.wrapping_mul(0xc2b2ae35);
581 hash ^= hash >> 16;
582
583 hash
584}
585
586pub fn normalize_model_id(model: &str, provider: &str) -> String {
598 if model.contains('/') {
599 model.to_string()
600 } else {
601 format!("{provider}/{model}")
602 }
603}
604
605fn serialize_uuid<S>(uuid: &uuid::Uuid, serializer: S) -> Result<S::Ok, S::Error>
611where
612 S: serde::Serializer,
613{
614 serializer.serialize_str(&uuid.to_string())
615}
616
617fn deserialize_uuid<'de, D>(deserializer: D) -> Result<uuid::Uuid, D::Error>
619where
620 D: serde::Deserializer<'de>,
621{
622 let s = String::deserialize(deserializer)?;
623 uuid::Uuid::parse_str(&s).map_err(serde::de::Error::custom)
624}
625
626#[derive(Debug, Default)]
632pub struct TraceRecordBuilder {
633 vcs: Option<VcsInfo>,
634 tool: Option<ToolInfo>,
635 files: Vec<TraceFile>,
636 metadata: Option<TraceMetadata>,
637}
638
639impl TraceRecordBuilder {
640 pub fn new() -> Self {
642 Self::default()
643 }
644
645 pub fn vcs(mut self, vcs: VcsInfo) -> Self {
647 self.vcs = Some(vcs);
648 self
649 }
650
651 pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
653 self.vcs = Some(VcsInfo::git(revision));
654 self
655 }
656
657 pub fn tool(mut self, tool: ToolInfo) -> Self {
659 self.tool = Some(tool);
660 self
661 }
662
663 pub fn file(mut self, file: TraceFile) -> Self {
665 self.files.push(file);
666 self
667 }
668
669 pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
671 self.metadata = Some(metadata);
672 self
673 }
674
675 pub fn build(self) -> TraceRecord {
677 TraceRecord {
678 version: AGENT_TRACE_VERSION.to_string(),
679 id: uuid::Uuid::new_v4(),
680 timestamp: Utc::now(),
681 vcs: self.vcs,
682 tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
683 files: self.files,
684 metadata: self.metadata,
685 }
686 }
687}
688
689#[derive(Debug, Clone)]
695pub struct TraceContext {
696 pub revision: Option<String>,
698 pub session_id: Option<String>,
700 pub model_id: String,
702 pub provider: String,
704 pub turn_number: Option<u32>,
706 pub workspace_path: Option<PathBuf>,
708}
709
710impl TraceContext {
711 pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
713 Self {
714 revision: None,
715 session_id: None,
716 model_id: model_id.into(),
717 provider: provider.into(),
718 turn_number: None,
719 workspace_path: None,
720 }
721 }
722
723 pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
725 self.revision = Some(revision.into());
726 self
727 }
728
729 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
731 self.session_id = Some(session_id.into());
732 self
733 }
734
735 pub fn with_turn_number(mut self, turn: u32) -> Self {
737 self.turn_number = Some(turn);
738 self
739 }
740
741 pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
743 self.workspace_path = Some(path.into());
744 self
745 }
746
747 pub fn normalized_model_id(&self) -> String {
749 normalize_model_id(&self.model_id, &self.provider)
750 }
751}
752
753#[cfg(test)]
754#[allow(
755 clippy::expect_used,
756 clippy::unwrap_used,
757 reason = "Intentional compatibility, platform, or test-only suppression."
758)]
759mod tests {
760 use super::*;
761
762 #[test]
763 fn test_trace_record_creation() {
764 let trace = TraceRecord::new();
765 assert_eq!(trace.version, AGENT_TRACE_VERSION);
766 assert!(trace.tool.is_some());
767 assert!(trace.files.is_empty());
768 }
769
770 #[test]
771 fn test_trace_record_for_git() {
772 let trace = TraceRecord::for_git_revision("abc123");
773 assert!(trace.vcs.is_some());
774 let vcs = trace.vcs.as_ref().expect("trace.vcs is None");
775 assert_eq!(vcs.vcs_type, VcsType::Git);
776 assert_eq!(vcs.revision, "abc123");
777 }
778
779 #[test]
780 fn test_contributor_types() {
781 let ai = Contributor::ai("anthropic/claude-opus-4");
782 assert_eq!(ai.contributor_type, ContributorType::Ai);
783 assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
784
785 let human = Contributor::human();
786 assert_eq!(human.contributor_type, ContributorType::Human);
787 assert!(human.model_id.is_none());
788 }
789
790 #[test]
791 fn test_trace_range() {
792 let range = TraceRange::new(10, 25);
793 assert_eq!(range.start_line, 10);
794 assert_eq!(range.end_line, 25);
795
796 let range_with_hash = range.with_content_hash("hello world");
797 assert!(range_with_hash.content_hash.is_some());
798 assert!(range_with_hash.content_hash.unwrap().starts_with("murmur3:"));
800 }
801
802 #[test]
803 fn test_hash_algorithms() {
804 let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
805 assert!(murmur.starts_with("murmur3:"));
806
807 let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
808 assert!(fnv.starts_with("fnv1a:"));
809
810 let default_hash = compute_content_hash("hello world");
812 assert_eq!(default_hash, murmur);
813 }
814
815 #[test]
816 fn test_trace_file_builder() {
817 let file = TraceFile::with_ai_ranges("src/main.rs", "anthropic/claude-opus-4", vec![TraceRange::new(1, 50)]);
818 assert_eq!(file.path, "src/main.rs");
819 assert_eq!(file.conversations.len(), 1);
820 }
821
822 #[test]
823 fn test_normalize_model_id() {
824 assert_eq!(normalize_model_id("claude-3-opus", "anthropic"), "anthropic/claude-3-opus");
825 assert_eq!(normalize_model_id("anthropic/claude-3-opus", "anthropic"), "anthropic/claude-3-opus");
826 }
827
828 #[test]
829 fn test_trace_record_builder() {
830 let trace = TraceRecordBuilder::new()
831 .git_revision("abc123def456")
832 .file(TraceFile::with_ai_ranges("src/lib.rs", "openai/gpt-5", vec![TraceRange::new(10, 20)]))
833 .build();
834
835 assert!(trace.vcs.is_some());
836 assert_eq!(trace.files.len(), 1);
837 assert!(trace.has_attributions());
838 }
839
840 #[test]
841 fn test_trace_serialization() {
842 let trace = TraceRecord::for_git_revision("abc123");
843 let json = serde_json::to_string_pretty(&trace).expect("Failed to serialize trace to JSON");
844 assert!(json.contains("\"version\": \"0.1.0\""));
845 assert!(json.contains("abc123"));
846
847 let restored: TraceRecord = serde_json::from_str(&json).expect("Failed to deserialize trace from JSON");
848 assert_eq!(restored.version, trace.version);
849 }
850
851 #[test]
852 fn test_content_hash_consistency() {
853 let hash1 = compute_content_hash("hello world");
855 let hash2 = compute_content_hash("hello world");
856 assert_eq!(hash1, hash2);
857
858 let hash3 = compute_content_hash("hello world!");
859 assert_ne!(hash1, hash3);
860
861 let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
863 let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
864 assert_eq!(fnv1, fnv2);
865 }
866}