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(
67 serialize_with = "serialize_uuid",
68 deserialize_with = "deserialize_uuid"
69 )]
70 #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
71 pub id: uuid::Uuid,
72
73 pub timestamp: DateTime<Utc>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub vcs: Option<VcsInfo>,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub tool: Option<ToolInfo>,
83
84 pub files: Vec<TraceFile>,
86
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub metadata: Option<TraceMetadata>,
90}
91
92impl TraceRecord {
93 pub fn new() -> Self {
95 Self {
96 version: AGENT_TRACE_VERSION.to_string(),
97 id: uuid::Uuid::new_v4(),
98 timestamp: Utc::now(),
99 vcs: None,
100 tool: Some(ToolInfo::vtcode()),
101 files: Vec::new(),
102 metadata: None,
103 }
104 }
105
106 pub fn for_git_revision(revision: impl Into<String>) -> Self {
108 let mut trace = Self::new();
109 trace.vcs = Some(VcsInfo::git(revision));
110 trace
111 }
112
113 pub fn add_file(&mut self, file: TraceFile) {
115 self.files.push(file);
116 }
117
118 pub fn has_attributions(&self) -> bool {
120 self.files
121 .iter()
122 .any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
123 }
124}
125
126impl Default for TraceRecord {
127 fn default() -> Self {
128 Self::new()
129 }
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
138#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
139pub struct VcsInfo {
140 #[serde(rename = "type")]
142 pub vcs_type: VcsType,
143
144 pub revision: String,
146}
147
148impl VcsInfo {
149 pub fn git(revision: impl Into<String>) -> Self {
151 Self {
152 vcs_type: VcsType::Git,
153 revision: revision.into(),
154 }
155 }
156
157 pub fn jj(change_id: impl Into<String>) -> Self {
159 Self {
160 vcs_type: VcsType::Jj,
161 revision: change_id.into(),
162 }
163 }
164}
165
166#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
169#[serde(rename_all = "lowercase")]
170pub enum VcsType {
171 Git,
173 Jj,
175 Hg,
177 Svn,
179}
180
181#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
187#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
188pub struct ToolInfo {
189 pub name: String,
191
192 #[serde(skip_serializing_if = "Option::is_none")]
194 pub version: Option<String>,
195}
196
197impl ToolInfo {
198 pub fn vtcode() -> Self {
200 Self {
201 name: "vtcode".to_string(),
202 version: Some(env!("CARGO_PKG_VERSION").to_string()),
203 }
204 }
205
206 pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
208 Self {
209 name: name.into(),
210 version,
211 }
212 }
213}
214
215#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
221#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
222pub struct TraceFile {
223 pub path: String,
225
226 pub conversations: Vec<TraceConversation>,
228}
229
230impl TraceFile {
231 pub fn new(path: impl Into<String>) -> Self {
233 Self {
234 path: path.into(),
235 conversations: Vec::new(),
236 }
237 }
238
239 pub fn add_conversation(&mut self, conversation: TraceConversation) {
241 self.conversations.push(conversation);
242 }
243
244 pub fn with_ai_ranges(
246 path: impl Into<String>,
247 model_id: impl Into<String>,
248 ranges: Vec<TraceRange>,
249 ) -> Self {
250 let mut file = Self::new(path);
251 file.add_conversation(TraceConversation {
252 url: None,
253 contributor: Some(Contributor::ai(model_id)),
254 ranges,
255 related: None,
256 });
257 file
258 }
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
263#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
264pub struct TraceConversation {
265 #[serde(skip_serializing_if = "Option::is_none")]
267 pub url: Option<String>,
268
269 #[serde(skip_serializing_if = "Option::is_none")]
271 pub contributor: Option<Contributor>,
272
273 pub ranges: Vec<TraceRange>,
275
276 #[serde(skip_serializing_if = "Option::is_none")]
278 pub related: Option<Vec<RelatedResource>>,
279}
280
281impl TraceConversation {
282 pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
284 Self {
285 url: None,
286 contributor: Some(Contributor::ai(model_id)),
287 ranges,
288 related: None,
289 }
290 }
291
292 pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
294 self.url = Some(url.into());
295 self
296 }
297}
298
299#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
301#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
302pub struct RelatedResource {
303 #[serde(rename = "type")]
305 pub resource_type: String,
306
307 pub url: String,
309}
310
311impl RelatedResource {
312 pub fn session(url: impl Into<String>) -> Self {
314 Self {
315 resource_type: "session".to_string(),
316 url: url.into(),
317 }
318 }
319
320 pub fn prompt(url: impl Into<String>) -> Self {
322 Self {
323 resource_type: "prompt".to_string(),
324 url: url.into(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
336pub struct TraceRange {
337 pub start_line: u32,
339
340 pub end_line: u32,
342
343 #[serde(skip_serializing_if = "Option::is_none")]
345 pub content_hash: Option<String>,
346
347 #[serde(skip_serializing_if = "Option::is_none")]
349 pub contributor: Option<Contributor>,
350}
351
352impl TraceRange {
353 pub fn new(start_line: u32, end_line: u32) -> Self {
355 Self {
356 start_line,
357 end_line,
358 content_hash: None,
359 contributor: None,
360 }
361 }
362
363 pub fn single_line(line: u32) -> Self {
365 Self::new(line, line)
366 }
367
368 pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
370 self.content_hash = Some(hash.into());
371 self
372 }
373
374 pub fn with_content_hash(mut self, content: &str) -> Self {
376 let hash = compute_content_hash(content);
377 self.content_hash = Some(hash);
378 self
379 }
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
388#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
389pub struct Contributor {
390 #[serde(rename = "type")]
392 pub contributor_type: ContributorType,
393
394 #[serde(skip_serializing_if = "Option::is_none")]
396 pub model_id: Option<String>,
397}
398
399impl Contributor {
400 pub fn ai(model_id: impl Into<String>) -> Self {
402 Self {
403 contributor_type: ContributorType::Ai,
404 model_id: Some(model_id.into()),
405 }
406 }
407
408 pub fn human() -> Self {
410 Self {
411 contributor_type: ContributorType::Human,
412 model_id: None,
413 }
414 }
415
416 pub fn mixed() -> Self {
418 Self {
419 contributor_type: ContributorType::Mixed,
420 model_id: None,
421 }
422 }
423
424 pub fn unknown() -> Self {
426 Self {
427 contributor_type: ContributorType::Unknown,
428 model_id: None,
429 }
430 }
431}
432
433#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
435#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
436#[serde(rename_all = "lowercase")]
437pub enum ContributorType {
438 Human,
440 Ai,
442 Mixed,
444 Unknown,
446}
447
448#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
454#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
455pub struct TraceMetadata {
456 #[serde(skip_serializing_if = "Option::is_none")]
458 pub confidence: Option<f64>,
459
460 #[serde(skip_serializing_if = "Option::is_none")]
462 pub post_processing_tools: Option<Vec<String>>,
463
464 #[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
466 pub vtcode: Option<VtCodeMetadata>,
467
468 #[serde(flatten)]
470 #[cfg_attr(feature = "schema-export", schemars(skip))]
471 pub extra: HashMap<String, Value>,
472}
473
474#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
476#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
477pub struct VtCodeMetadata {
478 #[serde(skip_serializing_if = "Option::is_none")]
480 pub session_id: Option<String>,
481
482 #[serde(skip_serializing_if = "Option::is_none")]
484 pub turn_number: Option<u32>,
485
486 #[serde(skip_serializing_if = "Option::is_none")]
488 pub workspace_path: Option<String>,
489
490 #[serde(skip_serializing_if = "Option::is_none")]
492 pub provider: Option<String>,
493}
494
495#[derive(Debug, Clone, Copy, Default)]
501pub enum HashAlgorithm {
502 #[default]
504 MurmurHash3,
505 Fnv1a,
507}
508
509pub fn compute_content_hash(content: &str) -> String {
513 compute_content_hash_with(content, HashAlgorithm::default())
514}
515
516pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
518 match algorithm {
519 HashAlgorithm::MurmurHash3 => {
520 let hash = murmur3_32(content.as_bytes(), 0);
522 format!("murmur3:{hash:08x}")
523 }
524 HashAlgorithm::Fnv1a => {
525 const FNV_OFFSET: u64 = 14695981039346656037;
526 const FNV_PRIME: u64 = 1099511628211;
527 let mut hash = FNV_OFFSET;
528 for byte in content.bytes() {
529 hash ^= byte as u64;
530 hash = hash.wrapping_mul(FNV_PRIME);
531 }
532 format!("fnv1a:{hash:016x}")
533 }
534 }
535}
536
537fn murmur3_32(data: &[u8], seed: u32) -> u32 {
539 const C1: u32 = 0xcc9e2d51;
540 const C2: u32 = 0x1b873593;
541 const R1: u32 = 15;
542 const R2: u32 = 13;
543 const M: u32 = 5;
544 const N: u32 = 0xe6546b64;
545
546 let mut hash = seed;
547 let len = data.len();
548
549 let mut chunks = data.chunks_exact(4);
551 for chunk in &mut chunks {
552 let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
553 k = k.wrapping_mul(C1);
554 k = k.rotate_left(R1);
555 k = k.wrapping_mul(C2);
556 hash ^= k;
557 hash = hash.rotate_left(R2);
558 hash = hash.wrapping_mul(M).wrapping_add(N);
559 }
560
561 let tail = chunks.remainder();
563 let mut k1: u32 = 0;
564 match tail.len() {
565 3 => {
566 k1 ^= (tail[2] as u32) << 16;
567 k1 ^= (tail[1] as u32) << 8;
568 k1 ^= tail[0] as u32;
569 }
570 2 => {
571 k1 ^= (tail[1] as u32) << 8;
572 k1 ^= tail[0] as u32;
573 }
574 1 => {
575 k1 ^= tail[0] as u32;
576 }
577 _ => {}
578 }
579 if !tail.is_empty() {
580 k1 = k1.wrapping_mul(C1);
581 k1 = k1.rotate_left(R1);
582 k1 = k1.wrapping_mul(C2);
583 hash ^= k1;
584 }
585
586 hash ^= len as u32;
588 hash ^= hash >> 16;
589 hash = hash.wrapping_mul(0x85ebca6b);
590 hash ^= hash >> 13;
591 hash = hash.wrapping_mul(0xc2b2ae35);
592 hash ^= hash >> 16;
593
594 hash
595}
596
597pub fn normalize_model_id(model: &str, provider: &str) -> String {
609 if model.contains('/') {
610 model.to_string()
611 } else {
612 format!("{provider}/{model}")
613 }
614}
615
616fn serialize_uuid<S>(uuid: &uuid::Uuid, serializer: S) -> Result<S::Ok, S::Error>
622where
623 S: serde::Serializer,
624{
625 serializer.serialize_str(&uuid.to_string())
626}
627
628fn deserialize_uuid<'de, D>(deserializer: D) -> Result<uuid::Uuid, D::Error>
630where
631 D: serde::Deserializer<'de>,
632{
633 let s = String::deserialize(deserializer)?;
634 uuid::Uuid::parse_str(&s).map_err(serde::de::Error::custom)
635}
636
637#[derive(Debug, Default)]
643pub struct TraceRecordBuilder {
644 vcs: Option<VcsInfo>,
645 tool: Option<ToolInfo>,
646 files: Vec<TraceFile>,
647 metadata: Option<TraceMetadata>,
648}
649
650impl TraceRecordBuilder {
651 pub fn new() -> Self {
653 Self::default()
654 }
655
656 pub fn vcs(mut self, vcs: VcsInfo) -> Self {
658 self.vcs = Some(vcs);
659 self
660 }
661
662 pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
664 self.vcs = Some(VcsInfo::git(revision));
665 self
666 }
667
668 pub fn tool(mut self, tool: ToolInfo) -> Self {
670 self.tool = Some(tool);
671 self
672 }
673
674 pub fn file(mut self, file: TraceFile) -> Self {
676 self.files.push(file);
677 self
678 }
679
680 pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
682 self.metadata = Some(metadata);
683 self
684 }
685
686 pub fn build(self) -> TraceRecord {
688 TraceRecord {
689 version: AGENT_TRACE_VERSION.to_string(),
690 id: uuid::Uuid::new_v4(),
691 timestamp: Utc::now(),
692 vcs: self.vcs,
693 tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
694 files: self.files,
695 metadata: self.metadata,
696 }
697 }
698}
699
700#[derive(Debug, Clone)]
706pub struct TraceContext {
707 pub revision: Option<String>,
709 pub session_id: Option<String>,
711 pub model_id: String,
713 pub provider: String,
715 pub turn_number: Option<u32>,
717 pub workspace_path: Option<PathBuf>,
719}
720
721impl TraceContext {
722 pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
724 Self {
725 revision: None,
726 session_id: None,
727 model_id: model_id.into(),
728 provider: provider.into(),
729 turn_number: None,
730 workspace_path: None,
731 }
732 }
733
734 pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
736 self.revision = Some(revision.into());
737 self
738 }
739
740 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
742 self.session_id = Some(session_id.into());
743 self
744 }
745
746 pub fn with_turn_number(mut self, turn: u32) -> Self {
748 self.turn_number = Some(turn);
749 self
750 }
751
752 pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
754 self.workspace_path = Some(path.into());
755 self
756 }
757
758 pub fn normalized_model_id(&self) -> String {
760 normalize_model_id(&self.model_id, &self.provider)
761 }
762}
763
764#[cfg(test)]
765#[allow(clippy::expect_used, clippy::unwrap_used)]
766mod tests {
767 use super::*;
768
769 #[test]
770 fn test_trace_record_creation() {
771 let trace = TraceRecord::new();
772 assert_eq!(trace.version, AGENT_TRACE_VERSION);
773 assert!(trace.tool.is_some());
774 assert!(trace.files.is_empty());
775 }
776
777 #[test]
778 fn test_trace_record_for_git() {
779 let trace = TraceRecord::for_git_revision("abc123");
780 assert!(trace.vcs.is_some());
781 let vcs = trace.vcs.as_ref().expect("trace.vcs is None");
782 assert_eq!(vcs.vcs_type, VcsType::Git);
783 assert_eq!(vcs.revision, "abc123");
784 }
785
786 #[test]
787 fn test_contributor_types() {
788 let ai = Contributor::ai("anthropic/claude-opus-4");
789 assert_eq!(ai.contributor_type, ContributorType::Ai);
790 assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
791
792 let human = Contributor::human();
793 assert_eq!(human.contributor_type, ContributorType::Human);
794 assert!(human.model_id.is_none());
795 }
796
797 #[test]
798 fn test_trace_range() {
799 let range = TraceRange::new(10, 25);
800 assert_eq!(range.start_line, 10);
801 assert_eq!(range.end_line, 25);
802
803 let range_with_hash = range.with_content_hash("hello world");
804 assert!(range_with_hash.content_hash.is_some());
805 assert!(
807 range_with_hash
808 .content_hash
809 .unwrap()
810 .starts_with("murmur3:")
811 );
812 }
813
814 #[test]
815 fn test_hash_algorithms() {
816 let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
817 assert!(murmur.starts_with("murmur3:"));
818
819 let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
820 assert!(fnv.starts_with("fnv1a:"));
821
822 let default_hash = compute_content_hash("hello world");
824 assert_eq!(default_hash, murmur);
825 }
826
827 #[test]
828 fn test_trace_file_builder() {
829 let file = TraceFile::with_ai_ranges(
830 "src/main.rs",
831 "anthropic/claude-opus-4",
832 vec![TraceRange::new(1, 50)],
833 );
834 assert_eq!(file.path, "src/main.rs");
835 assert_eq!(file.conversations.len(), 1);
836 }
837
838 #[test]
839 fn test_normalize_model_id() {
840 assert_eq!(
841 normalize_model_id("claude-3-opus", "anthropic"),
842 "anthropic/claude-3-opus"
843 );
844 assert_eq!(
845 normalize_model_id("anthropic/claude-3-opus", "anthropic"),
846 "anthropic/claude-3-opus"
847 );
848 }
849
850 #[test]
851 fn test_trace_record_builder() {
852 let trace = TraceRecordBuilder::new()
853 .git_revision("abc123def456")
854 .file(TraceFile::with_ai_ranges(
855 "src/lib.rs",
856 "openai/gpt-5",
857 vec![TraceRange::new(10, 20)],
858 ))
859 .build();
860
861 assert!(trace.vcs.is_some());
862 assert_eq!(trace.files.len(), 1);
863 assert!(trace.has_attributions());
864 }
865
866 #[test]
867 fn test_trace_serialization() {
868 let trace = TraceRecord::for_git_revision("abc123");
869 let json = serde_json::to_string_pretty(&trace).expect("Failed to serialize trace to JSON");
870 assert!(json.contains("\"version\": \"0.1.0\""));
871 assert!(json.contains("abc123"));
872
873 let restored: TraceRecord =
874 serde_json::from_str(&json).expect("Failed to deserialize trace from JSON");
875 assert_eq!(restored.version, trace.version);
876 }
877
878 #[test]
879 fn test_content_hash_consistency() {
880 let hash1 = compute_content_hash("hello world");
882 let hash2 = compute_content_hash("hello world");
883 assert_eq!(hash1, hash2);
884
885 let hash3 = compute_content_hash("hello world!");
886 assert_ne!(hash1, hash3);
887
888 let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
890 let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
891 assert_eq!(fnv1, fnv2);
892 }
893}