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 #[cfg_attr(feature = "schema-export", schemars(with = "String"))]
75 pub timestamp: DateTime<Utc>,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub vcs: Option<VcsInfo>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub tool: Option<ToolInfo>,
84
85 pub files: Vec<TraceFile>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub metadata: Option<TraceMetadata>,
91}
92
93impl TraceRecord {
94 pub fn new() -> Self {
96 Self {
97 version: AGENT_TRACE_VERSION.to_string(),
98 id: uuid::Uuid::new_v4(),
99 timestamp: Utc::now(),
100 vcs: None,
101 tool: Some(ToolInfo::vtcode()),
102 files: Vec::new(),
103 metadata: None,
104 }
105 }
106
107 pub fn for_git_revision(revision: impl Into<String>) -> Self {
109 let mut trace = Self::new();
110 trace.vcs = Some(VcsInfo::git(revision));
111 trace
112 }
113
114 pub fn add_file(&mut self, file: TraceFile) {
116 self.files.push(file);
117 }
118
119 pub fn has_attributions(&self) -> bool {
121 self.files.iter().any(|f| f.conversations.iter().any(|c| !c.ranges.is_empty()))
122 }
123}
124
125impl Default for TraceRecord {
126 fn default() -> Self {
127 Self::new()
128 }
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
138pub struct VcsInfo {
139 #[serde(rename = "type")]
141 pub vcs_type: VcsType,
142
143 pub revision: String,
145}
146
147impl VcsInfo {
148 pub fn git(revision: impl Into<String>) -> Self {
150 Self { vcs_type: VcsType::Git, revision: revision.into() }
151 }
152
153 pub fn jj(change_id: impl Into<String>) -> Self {
155 Self { vcs_type: VcsType::Jj, revision: change_id.into() }
156 }
157}
158
159#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
161#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
162#[serde(rename_all = "lowercase")]
163pub enum VcsType {
164 Git,
166 Jj,
168 Hg,
170 Svn,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
180#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
181pub struct ToolInfo {
182 pub name: String,
184
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub version: Option<String>,
188}
189
190impl ToolInfo {
191 pub fn vtcode() -> Self {
193 Self {
194 name: "vtcode".to_string(),
195 version: Some(env!("CARGO_PKG_VERSION").to_string()),
196 }
197 }
198
199 pub fn new(name: impl Into<String>, version: Option<String>) -> Self {
201 Self { name: name.into(), version }
202 }
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
211#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
212pub struct TraceFile {
213 pub path: String,
215
216 pub conversations: Vec<TraceConversation>,
218}
219
220impl TraceFile {
221 pub fn new(path: impl Into<String>) -> Self {
223 Self { path: path.into(), conversations: Vec::new() }
224 }
225
226 pub fn add_conversation(&mut self, conversation: TraceConversation) {
228 self.conversations.push(conversation);
229 }
230
231 pub fn with_ai_ranges(
233 path: impl Into<String>,
234 model_id: impl Into<String>,
235 ranges: Vec<TraceRange>,
236 ) -> Self {
237 let mut file = Self::new(path);
238 file.add_conversation(TraceConversation {
239 url: None,
240 contributor: Some(Contributor::ai(model_id)),
241 ranges,
242 related: None,
243 });
244 file
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
250#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
251pub struct TraceConversation {
252 #[serde(skip_serializing_if = "Option::is_none")]
254 pub url: Option<String>,
255
256 #[serde(skip_serializing_if = "Option::is_none")]
258 pub contributor: Option<Contributor>,
259
260 pub ranges: Vec<TraceRange>,
262
263 #[serde(skip_serializing_if = "Option::is_none")]
265 pub related: Option<Vec<RelatedResource>>,
266}
267
268impl TraceConversation {
269 pub fn ai(model_id: impl Into<String>, ranges: Vec<TraceRange>) -> Self {
271 Self {
272 url: None,
273 contributor: Some(Contributor::ai(model_id)),
274 ranges,
275 related: None,
276 }
277 }
278
279 pub fn with_session_url(mut self, url: impl Into<String>) -> Self {
281 self.url = Some(url.into());
282 self
283 }
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
288#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
289pub struct RelatedResource {
290 #[serde(rename = "type")]
292 pub resource_type: String,
293
294 pub url: String,
296}
297
298impl RelatedResource {
299 pub fn session(url: impl Into<String>) -> Self {
301 Self {
302 resource_type: "session".to_string(),
303 url: url.into(),
304 }
305 }
306
307 pub fn prompt(url: impl Into<String>) -> Self {
309 Self {
310 resource_type: "prompt".to_string(),
311 url: url.into(),
312 }
313 }
314}
315
316#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
322#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
323pub struct TraceRange {
324 pub start_line: u32,
326
327 pub end_line: u32,
329
330 #[serde(skip_serializing_if = "Option::is_none")]
332 pub content_hash: Option<String>,
333
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub contributor: Option<Contributor>,
337}
338
339impl TraceRange {
340 pub fn new(start_line: u32, end_line: u32) -> Self {
342 Self {
343 start_line,
344 end_line,
345 content_hash: None,
346 contributor: None,
347 }
348 }
349
350 pub fn single_line(line: u32) -> Self {
352 Self::new(line, line)
353 }
354
355 pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
357 self.content_hash = Some(hash.into());
358 self
359 }
360
361 pub fn with_content_hash(mut self, content: &str) -> Self {
363 let hash = compute_content_hash(content);
364 self.content_hash = Some(hash);
365 self
366 }
367}
368
369#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
375#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
376pub struct Contributor {
377 #[serde(rename = "type")]
379 pub contributor_type: ContributorType,
380
381 #[serde(skip_serializing_if = "Option::is_none")]
383 pub model_id: Option<String>,
384}
385
386impl Contributor {
387 pub fn ai(model_id: impl Into<String>) -> Self {
389 Self {
390 contributor_type: ContributorType::Ai,
391 model_id: Some(model_id.into()),
392 }
393 }
394
395 pub fn human() -> Self {
397 Self {
398 contributor_type: ContributorType::Human,
399 model_id: None,
400 }
401 }
402
403 pub fn mixed() -> Self {
405 Self {
406 contributor_type: ContributorType::Mixed,
407 model_id: None,
408 }
409 }
410
411 pub fn unknown() -> Self {
413 Self {
414 contributor_type: ContributorType::Unknown,
415 model_id: None,
416 }
417 }
418}
419
420#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
422#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
423#[serde(rename_all = "lowercase")]
424pub enum ContributorType {
425 Human,
427 Ai,
429 Mixed,
431 Unknown,
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
441#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
442pub struct TraceMetadata {
443 #[serde(skip_serializing_if = "Option::is_none")]
445 pub confidence: Option<f64>,
446
447 #[serde(skip_serializing_if = "Option::is_none")]
449 pub post_processing_tools: Option<Vec<String>>,
450
451 #[serde(rename = "dev.vtcode", skip_serializing_if = "Option::is_none")]
453 pub vtcode: Option<VtCodeMetadata>,
454
455 #[serde(flatten)]
457 #[cfg_attr(feature = "schema-export", schemars(skip))]
458 pub extra: HashMap<String, Value>,
459}
460
461#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
463#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
464pub struct VtCodeMetadata {
465 #[serde(skip_serializing_if = "Option::is_none")]
467 pub session_id: Option<String>,
468
469 #[serde(skip_serializing_if = "Option::is_none")]
471 pub turn_number: Option<u32>,
472
473 #[serde(skip_serializing_if = "Option::is_none")]
475 pub workspace_path: Option<String>,
476
477 #[serde(skip_serializing_if = "Option::is_none")]
479 pub provider: Option<String>,
480}
481
482#[derive(Debug, Clone, Copy, Default)]
488pub enum HashAlgorithm {
489 #[default]
491 MurmurHash3,
492 Fnv1a,
494}
495
496pub fn compute_content_hash(content: &str) -> String {
500 compute_content_hash_with(content, HashAlgorithm::default())
501}
502
503pub fn compute_content_hash_with(content: &str, algorithm: HashAlgorithm) -> String {
505 match algorithm {
506 HashAlgorithm::MurmurHash3 => {
507 let hash = murmur3_32(content.as_bytes(), 0);
509 format!("murmur3:{hash:08x}")
510 }
511 HashAlgorithm::Fnv1a => {
512 const FNV_OFFSET: u64 = 14695981039346656037;
513 const FNV_PRIME: u64 = 1099511628211;
514 let mut hash = FNV_OFFSET;
515 for byte in content.bytes() {
516 hash ^= byte as u64;
517 hash = hash.wrapping_mul(FNV_PRIME);
518 }
519 format!("fnv1a:{hash:016x}")
520 }
521 }
522}
523
524fn murmur3_32(data: &[u8], seed: u32) -> u32 {
526 const C1: u32 = 0xcc9e2d51;
527 const C2: u32 = 0x1b873593;
528 const R1: u32 = 15;
529 const R2: u32 = 13;
530 const M: u32 = 5;
531 const N: u32 = 0xe6546b64;
532
533 let mut hash = seed;
534 let len = data.len();
535
536 let mut chunks = data.chunks_exact(4);
538 for chunk in &mut chunks {
539 let mut k = u32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]);
540 k = k.wrapping_mul(C1);
541 k = k.rotate_left(R1);
542 k = k.wrapping_mul(C2);
543 hash ^= k;
544 hash = hash.rotate_left(R2);
545 hash = hash.wrapping_mul(M).wrapping_add(N);
546 }
547
548 let tail = chunks.remainder();
550 let mut k1: u32 = 0;
551 match tail.len() {
552 3 => {
553 k1 ^= (tail[2] as u32) << 16;
554 k1 ^= (tail[1] as u32) << 8;
555 k1 ^= tail[0] as u32;
556 }
557 2 => {
558 k1 ^= (tail[1] as u32) << 8;
559 k1 ^= tail[0] as u32;
560 }
561 1 => {
562 k1 ^= tail[0] as u32;
563 }
564 _ => {}
565 }
566 if !tail.is_empty() {
567 k1 = k1.wrapping_mul(C1);
568 k1 = k1.rotate_left(R1);
569 k1 = k1.wrapping_mul(C2);
570 hash ^= k1;
571 }
572
573 hash ^= len as u32;
575 hash ^= hash >> 16;
576 hash = hash.wrapping_mul(0x85ebca6b);
577 hash ^= hash >> 13;
578 hash = hash.wrapping_mul(0xc2b2ae35);
579 hash ^= hash >> 16;
580
581 hash
582}
583
584pub fn normalize_model_id(model: &str, provider: &str) -> String {
596 if model.contains('/') {
597 model.to_string()
598 } else {
599 format!("{provider}/{model}")
600 }
601}
602
603fn serialize_uuid<S>(uuid: &uuid::Uuid, serializer: S) -> Result<S::Ok, S::Error>
609where
610 S: serde::Serializer,
611{
612 serializer.serialize_str(&uuid.to_string())
613}
614
615fn deserialize_uuid<'de, D>(deserializer: D) -> Result<uuid::Uuid, D::Error>
617where
618 D: serde::Deserializer<'de>,
619{
620 let s = String::deserialize(deserializer)?;
621 uuid::Uuid::parse_str(&s).map_err(serde::de::Error::custom)
622}
623
624#[derive(Debug, Default)]
630pub struct TraceRecordBuilder {
631 vcs: Option<VcsInfo>,
632 tool: Option<ToolInfo>,
633 files: Vec<TraceFile>,
634 metadata: Option<TraceMetadata>,
635}
636
637impl TraceRecordBuilder {
638 pub fn new() -> Self {
640 Self::default()
641 }
642
643 pub fn vcs(mut self, vcs: VcsInfo) -> Self {
645 self.vcs = Some(vcs);
646 self
647 }
648
649 pub fn git_revision(mut self, revision: impl Into<String>) -> Self {
651 self.vcs = Some(VcsInfo::git(revision));
652 self
653 }
654
655 pub fn tool(mut self, tool: ToolInfo) -> Self {
657 self.tool = Some(tool);
658 self
659 }
660
661 pub fn file(mut self, file: TraceFile) -> Self {
663 self.files.push(file);
664 self
665 }
666
667 pub fn metadata(mut self, metadata: TraceMetadata) -> Self {
669 self.metadata = Some(metadata);
670 self
671 }
672
673 pub fn build(self) -> TraceRecord {
675 TraceRecord {
676 version: AGENT_TRACE_VERSION.to_string(),
677 id: uuid::Uuid::new_v4(),
678 timestamp: Utc::now(),
679 vcs: self.vcs,
680 tool: self.tool.or_else(|| Some(ToolInfo::vtcode())),
681 files: self.files,
682 metadata: self.metadata,
683 }
684 }
685}
686
687#[derive(Debug, Clone)]
693pub struct TraceContext {
694 pub revision: Option<String>,
696 pub session_id: Option<String>,
698 pub model_id: String,
700 pub provider: String,
702 pub turn_number: Option<u32>,
704 pub workspace_path: Option<PathBuf>,
706}
707
708impl TraceContext {
709 pub fn new(model_id: impl Into<String>, provider: impl Into<String>) -> Self {
711 Self {
712 revision: None,
713 session_id: None,
714 model_id: model_id.into(),
715 provider: provider.into(),
716 turn_number: None,
717 workspace_path: None,
718 }
719 }
720
721 pub fn with_revision(mut self, revision: impl Into<String>) -> Self {
723 self.revision = Some(revision.into());
724 self
725 }
726
727 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
729 self.session_id = Some(session_id.into());
730 self
731 }
732
733 pub fn with_turn_number(mut self, turn: u32) -> Self {
735 self.turn_number = Some(turn);
736 self
737 }
738
739 pub fn with_workspace_path(mut self, path: impl Into<PathBuf>) -> Self {
741 self.workspace_path = Some(path.into());
742 self
743 }
744
745 pub fn normalized_model_id(&self) -> String {
747 normalize_model_id(&self.model_id, &self.provider)
748 }
749}
750
751#[cfg(test)]
752#[allow(clippy::expect_used, clippy::unwrap_used)]
753mod tests {
754 use super::*;
755
756 #[test]
757 fn test_trace_record_creation() {
758 let trace = TraceRecord::new();
759 assert_eq!(trace.version, AGENT_TRACE_VERSION);
760 assert!(trace.tool.is_some());
761 assert!(trace.files.is_empty());
762 }
763
764 #[test]
765 fn test_trace_record_for_git() {
766 let trace = TraceRecord::for_git_revision("abc123");
767 assert!(trace.vcs.is_some());
768 let vcs = trace.vcs.as_ref().expect("trace.vcs is None");
769 assert_eq!(vcs.vcs_type, VcsType::Git);
770 assert_eq!(vcs.revision, "abc123");
771 }
772
773 #[test]
774 fn test_contributor_types() {
775 let ai = Contributor::ai("anthropic/claude-opus-4");
776 assert_eq!(ai.contributor_type, ContributorType::Ai);
777 assert_eq!(ai.model_id, Some("anthropic/claude-opus-4".to_string()));
778
779 let human = Contributor::human();
780 assert_eq!(human.contributor_type, ContributorType::Human);
781 assert!(human.model_id.is_none());
782 }
783
784 #[test]
785 fn test_trace_range() {
786 let range = TraceRange::new(10, 25);
787 assert_eq!(range.start_line, 10);
788 assert_eq!(range.end_line, 25);
789
790 let range_with_hash = range.with_content_hash("hello world");
791 assert!(range_with_hash.content_hash.is_some());
792 assert!(range_with_hash.content_hash.unwrap().starts_with("murmur3:"));
794 }
795
796 #[test]
797 fn test_hash_algorithms() {
798 let murmur = compute_content_hash_with("hello world", HashAlgorithm::MurmurHash3);
799 assert!(murmur.starts_with("murmur3:"));
800
801 let fnv = compute_content_hash_with("hello world", HashAlgorithm::Fnv1a);
802 assert!(fnv.starts_with("fnv1a:"));
803
804 let default_hash = compute_content_hash("hello world");
806 assert_eq!(default_hash, murmur);
807 }
808
809 #[test]
810 fn test_trace_file_builder() {
811 let file = TraceFile::with_ai_ranges(
812 "src/main.rs",
813 "anthropic/claude-opus-4",
814 vec![TraceRange::new(1, 50)],
815 );
816 assert_eq!(file.path, "src/main.rs");
817 assert_eq!(file.conversations.len(), 1);
818 }
819
820 #[test]
821 fn test_normalize_model_id() {
822 assert_eq!(normalize_model_id("claude-3-opus", "anthropic"), "anthropic/claude-3-opus");
823 assert_eq!(
824 normalize_model_id("anthropic/claude-3-opus", "anthropic"),
825 "anthropic/claude-3-opus"
826 );
827 }
828
829 #[test]
830 fn test_trace_record_builder() {
831 let trace = TraceRecordBuilder::new()
832 .git_revision("abc123def456")
833 .file(TraceFile::with_ai_ranges(
834 "src/lib.rs",
835 "openai/gpt-5",
836 vec![TraceRange::new(10, 20)],
837 ))
838 .build();
839
840 assert!(trace.vcs.is_some());
841 assert_eq!(trace.files.len(), 1);
842 assert!(trace.has_attributions());
843 }
844
845 #[test]
846 fn test_trace_serialization() {
847 let trace = TraceRecord::for_git_revision("abc123");
848 let json = serde_json::to_string_pretty(&trace).expect("Failed to serialize trace to JSON");
849 assert!(json.contains("\"version\": \"0.1.0\""));
850 assert!(json.contains("abc123"));
851
852 let restored: TraceRecord =
853 serde_json::from_str(&json).expect("Failed to deserialize trace from JSON");
854 assert_eq!(restored.version, trace.version);
855 }
856
857 #[test]
858 fn test_content_hash_consistency() {
859 let hash1 = compute_content_hash("hello world");
861 let hash2 = compute_content_hash("hello world");
862 assert_eq!(hash1, hash2);
863
864 let hash3 = compute_content_hash("hello world!");
865 assert_ne!(hash1, hash3);
866
867 let fnv1 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
869 let fnv2 = compute_content_hash_with("test", HashAlgorithm::Fnv1a);
870 assert_eq!(fnv1, fnv2);
871 }
872}