1use chrono::{DateTime, Utc};
31use serde::{Deserialize, Serialize};
32use serde_json::Value;
33
34use crate::{ThreadEvent, ThreadItemDetails, ToolCallStatus};
35
36const ATIF_SCHEMA_VERSION: &str = "ATIF-v1.4";
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Trajectory {
46 schema_version: String,
48 session_id: String,
50 agent: AtifAgent,
52 steps: Vec<Step>,
54 #[serde(skip_serializing_if = "Option::is_none")]
56 notes: Option<String>,
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub final_metrics: Option<FinalMetrics>,
60 #[serde(skip_serializing_if = "Option::is_none")]
62 extra: Option<Value>,
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct AtifAgent {
68 name: String,
70 version: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 model_name: Option<String>,
75 #[serde(skip_serializing_if = "Option::is_none")]
77 extra: Option<Value>,
78}
79
80impl AtifAgent {
81 pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
83 Self {
84 name: name.into(),
85 version: version.into(),
86 model_name: None,
87 extra: None,
88 }
89 }
90
91 pub fn vtcode() -> Self {
93 Self::new("vtcode", env!("CARGO_PKG_VERSION"))
94 }
95
96 pub fn with_model(mut self, model: impl Into<String>) -> Self {
98 self.model_name = Some(model.into());
99 self
100 }
101}
102
103#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "lowercase")]
106pub enum StepSource {
107 System,
109 User,
111 Agent,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117pub struct Step {
118 step_id: u64,
120 #[serde(skip_serializing_if = "Option::is_none")]
122 timestamp: Option<String>,
123 source: StepSource,
125 #[serde(skip_serializing_if = "Option::is_none")]
127 model_name: Option<String>,
128 #[serde(skip_serializing_if = "Option::is_none")]
130 message: Option<String>,
131 #[serde(skip_serializing_if = "Option::is_none")]
133 reasoning_content: Option<String>,
134 #[serde(skip_serializing_if = "Option::is_none")]
136 tool_calls: Option<Vec<AtifToolCall>>,
137 #[serde(skip_serializing_if = "Option::is_none")]
139 observation: Option<Observation>,
140 #[serde(skip_serializing_if = "Option::is_none")]
142 metrics: Option<StepMetrics>,
143 #[serde(skip_serializing_if = "Option::is_none")]
145 extra: Option<Value>,
146}
147
148impl Step {
149 fn user(step_id: u64, message: impl Into<String>) -> Self {
151 Self {
152 step_id,
153 timestamp: Some(Utc::now().to_rfc3339()),
154 source: StepSource::User,
155 model_name: None,
156 message: Some(message.into()),
157 reasoning_content: None,
158 tool_calls: None,
159 observation: None,
160 metrics: None,
161 extra: None,
162 }
163 }
164
165 fn agent(step_id: u64, message: impl Into<String>) -> Self {
167 Self {
168 step_id,
169 timestamp: Some(Utc::now().to_rfc3339()),
170 source: StepSource::Agent,
171 model_name: None,
172 message: Some(message.into()),
173 reasoning_content: None,
174 tool_calls: None,
175 observation: None,
176 metrics: None,
177 extra: None,
178 }
179 }
180
181 fn system(step_id: u64, message: impl Into<String>) -> Self {
183 Self {
184 step_id,
185 timestamp: Some(Utc::now().to_rfc3339()),
186 source: StepSource::System,
187 model_name: None,
188 message: Some(message.into()),
189 reasoning_content: None,
190 tool_calls: None,
191 observation: None,
192 metrics: None,
193 extra: None,
194 }
195 }
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct AtifToolCall {
201 tool_call_id: String,
203 function_name: String,
205 #[serde(skip_serializing_if = "Option::is_none")]
207 arguments: Option<Value>,
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct Observation {
213 results: Vec<ObservationResult>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct ObservationResult {
220 source_call_id: String,
222 content: String,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct StepMetrics {
229 #[serde(skip_serializing_if = "Option::is_none")]
231 prompt_tokens: Option<u64>,
232 #[serde(skip_serializing_if = "Option::is_none")]
234 completion_tokens: Option<u64>,
235 #[serde(skip_serializing_if = "Option::is_none")]
237 cached_tokens: Option<u64>,
238 #[serde(skip_serializing_if = "Option::is_none")]
240 cost_usd: Option<f64>,
241 #[serde(skip_serializing_if = "Option::is_none")]
243 logprobs: Option<Vec<f64>>,
244 #[serde(skip_serializing_if = "Option::is_none")]
246 completion_token_ids: Option<Vec<u64>>,
247 #[serde(skip_serializing_if = "Option::is_none")]
249 prompt_token_ids: Option<Vec<u64>>,
250 #[serde(skip_serializing_if = "Option::is_none")]
252 extra: Option<Value>,
253}
254
255impl StepMetrics {
256 fn from_usage(usage: &crate::Usage) -> Self {
258 Self {
259 prompt_tokens: Some(usage.input_tokens),
260 completion_tokens: Some(usage.output_tokens),
261 cached_tokens: if usage.cached_input_tokens > 0 {
262 Some(usage.cached_input_tokens)
263 } else {
264 None
265 },
266 cost_usd: None,
267 logprobs: None,
268 completion_token_ids: None,
269 prompt_token_ids: None,
270 extra: if usage.cache_creation_tokens > 0 {
271 Some(serde_json::json!({
272 "cache_creation_tokens": usage.cache_creation_tokens
273 }))
274 } else {
275 None
276 },
277 }
278 }
279}
280
281#[derive(Debug, Clone, Default, Serialize, Deserialize)]
283pub struct FinalMetrics {
284 #[serde(skip_serializing_if = "Option::is_none")]
286 pub total_prompt_tokens: Option<u64>,
287 #[serde(skip_serializing_if = "Option::is_none")]
289 pub total_completion_tokens: Option<u64>,
290 #[serde(skip_serializing_if = "Option::is_none")]
292 pub total_cached_tokens: Option<u64>,
293 #[serde(skip_serializing_if = "Option::is_none")]
295 total_cost_usd: Option<f64>,
296 #[serde(skip_serializing_if = "Option::is_none")]
298 total_steps: Option<u64>,
299 #[serde(skip_serializing_if = "Option::is_none")]
301 extra: Option<Value>,
302}
303
304#[must_use = "finish the trajectory before discarding the builder"]
316pub struct AtifTrajectoryBuilder {
317 completed_at: Option<String>,
318 agent: AtifAgent,
319 session_id: Option<String>,
320 steps: Vec<Step>,
321 next_step_id: u64,
322 total_input_tokens: u64,
324 total_output_tokens: u64,
325 total_cached_tokens: u64,
326 num_turns: usize,
327 saw_per_turn_usage: bool,
330 pending_tool_calls: Vec<PendingToolCall>,
332}
333
334struct PendingToolCall {
335 call_id: String,
336 tool_call_id: Option<String>,
337 tool_name: String,
338 arguments: Option<Value>,
339 timestamp: String,
340}
341
342impl AtifTrajectoryBuilder {
343 pub fn new(agent: AtifAgent) -> Self {
345 Self {
346 agent,
347 completed_at: None,
348 session_id: None,
349 steps: Vec::new(),
350 next_step_id: 1,
351 total_input_tokens: 0,
352 total_output_tokens: 0,
353 total_cached_tokens: 0,
354 num_turns: 0,
355 saw_per_turn_usage: false,
356 pending_tool_calls: Vec::new(),
357 }
358 }
359
360 pub fn set_session_id(&mut self, id: impl Into<String>) {
363 self.session_id = Some(id.into());
364 }
365
366 pub fn process_event(&mut self, event: &ThreadEvent) {
368 self.process_event_at(event, Utc::now());
369 }
370
371 pub fn process_event_at(&mut self, event: &ThreadEvent, ts: DateTime<Utc>) {
373 let first_step = self.steps.len();
374 let ts_str = ts.to_rfc3339();
375 match event {
376 ThreadEvent::ThreadStarted(e) => {
377 if self.session_id.is_none() {
378 self.session_id = Some(e.thread_id.clone());
379 }
380 }
381 ThreadEvent::ThreadCompleted(e) => {
382 self.completed_at = e.completed_at.clone();
383 if self.session_id.is_none() {
384 self.session_id = Some(e.session_id.clone());
385 }
386 self.num_turns = e.num_turns;
387 if !self.saw_per_turn_usage {
393 self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
394 self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
395 self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
396 }
397 }
398 ThreadEvent::TurnCompleted(e) => {
399 self.saw_per_turn_usage = true;
400 self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
401 self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
402 self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
403 self.num_turns += 1;
404
405 let mut step = Step::system(self.next_step_id, "turn_completed");
406 step.timestamp = Some(ts_str);
407 step.metrics = Some(StepMetrics::from_usage(&e.usage));
408 if !e.in_progress_exec_sessions.is_empty() {
409 step.extra = Some(serde_json::json!({
410 "in_progress_exec_sessions": e.in_progress_exec_sessions,
411 }));
412 }
413 self.push_step(step);
414 }
415 ThreadEvent::TurnFailed(e) => {
416 if let Some(usage) = &e.usage {
417 self.saw_per_turn_usage = true;
418 self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
419 self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
420 self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
421 }
422 let mut step = Step::system(self.next_step_id, &e.message);
423 step.timestamp = Some(ts_str);
424 step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
425 self.push_step(step);
426 }
427 ThreadEvent::TurnBlocked(e) => {
428 if let Some(usage) = &e.usage {
429 self.saw_per_turn_usage = true;
430 self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
431 self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
432 self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
433 }
434 let mut step = Step::system(self.next_step_id, &e.message);
435 step.timestamp = Some(ts_str);
436 step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
437 step.extra = Some(serde_json::json!({
438 "last_tool": e.last_tool,
439 "blocked_streak": e.blocked_streak,
440 "blocked_total": e.blocked_total,
441 "consecutive_cap": e.consecutive_cap,
442 "total_cap": e.total_cap,
443 "recovery_active": e.recovery_active,
444 }));
445 self.push_step(step);
446 }
447 ThreadEvent::ItemCompleted(e) => {
448 self.process_item_completed(&e.item.id, &e.item.details, &ts_str);
449 }
450 ThreadEvent::ThreadCompactBoundary(e) => {
451 let msg = format!(
452 "context_compaction: {} messages -> {} messages ({})",
453 e.original_message_count,
454 e.compacted_message_count,
455 e.trigger.as_str()
456 );
457 let mut step = Step::system(self.next_step_id, msg);
458 step.timestamp = Some(ts_str);
459 self.push_step(step);
460 }
461 ThreadEvent::ContextReset(e) => {
462 let msg = format!(
463 "context_reset: {}% context used; plan preserved: {}; tool budget reset: {}",
464 e.previous_context_usage_percent, e.plan_preserved, e.tool_budget_reset
465 );
466 let mut step = Step::system(self.next_step_id, msg);
467 step.timestamp = Some(ts_str);
468 step.extra = Some(serde_json::json!({
469 "thread_id": e.thread_id,
470 "turn_id": e.turn_id,
471 "trigger": e.trigger,
472 "plan_preserved": e.plan_preserved,
473 "previous_context_usage_percent": e.previous_context_usage_percent,
474 "tool_budget_reset": e.tool_budget_reset,
475 }));
476 self.push_step(step);
477 }
478 ThreadEvent::Error(e) => {
479 let mut step = Step::system(self.next_step_id, &e.message);
480 step.timestamp = Some(ts_str);
481 self.push_step(step);
482 }
483 ThreadEvent::TurnStarted(e) => {
485 if let Some(context) = &e.context {
486 let mut step = Step::agent(self.next_step_id, context.goal.as_deref().unwrap_or("Turn started"));
487 step.source = StepSource::User;
488 step.timestamp = Some(context.timestamp.clone());
489 step.extra = Some(serde_json::json!({"execution_context": context}));
490 self.push_step(step);
491 }
492 }
493 ThreadEvent::MatrixUpdated(_)
494 | ThreadEvent::ItemStarted(_)
495 | ThreadEvent::ItemUpdated(_)
496 | ThreadEvent::PlanDelta(_)
497 | ThreadEvent::PlanApprovalRequested(_)
498 | ThreadEvent::PlanApprovalResolved(_)
499 | ThreadEvent::PermissionRequested(_)
500 | ThreadEvent::PermissionResolved(_)
501 | ThreadEvent::Interjected(_)
502 | ThreadEvent::Unknown => {}
503 }
504 for step in self.steps.iter_mut().skip(first_step) {
505 let context = match event {
506 ThreadEvent::ItemCompleted(e) => e.item.context.as_deref(),
507 _ => None,
508 };
509 if let Some(context) = context {
510 step.timestamp = Some(context.timestamp.clone());
511 let extra = step.extra.get_or_insert_with(|| serde_json::json!({}));
512 if let Some(extra) = extra.as_object_mut() {
513 let _ = extra.insert("item_context".into(), serde_json::json!(context));
514 }
515 }
516 match event {
517 ThreadEvent::TurnCompleted(e) => {
518 if let Some(ts) = &e.completed_at {
519 step.timestamp = Some(ts.to_string());
520 }
521 }
522 ThreadEvent::TurnFailed(e) => {
523 if let Some(ts) = &e.completed_at {
524 step.timestamp = Some(ts.to_string());
525 }
526 }
527 ThreadEvent::TurnBlocked(e) => {
528 if let Some(ts) = &e.completed_at {
529 step.timestamp = Some(ts.clone());
530 }
531 }
532 _ => {}
533 }
534 }
535 }
536
537 fn process_item_completed(&mut self, item_id: &str, details: &ThreadItemDetails, ts: &str) {
538 match details {
539 ThreadItemDetails::Decision(d) => {
540 let mut step = Step::agent(self.next_step_id, &d.summary);
541 step.timestamp = Some(ts.to_owned());
542 step.extra = Some(
543 serde_json::json!({"vtcode_item_type": "decision", "public_rationale": d.rationale, "alternatives": d.alternatives, "evidence_ids": d.evidence_ids}),
544 );
545 self.push_step(step);
546 }
547 ThreadItemDetails::AgentMessage(msg) => {
548 let mut step = Step::agent(self.next_step_id, &msg.text);
549 step.timestamp = Some(ts.to_string());
550 self.push_step(step);
551 }
552 ThreadItemDetails::Plan(plan) => {
553 let mut step = Step::agent(self.next_step_id, &plan.text);
554 step.timestamp = Some(ts.to_string());
555 step.extra = Some(serde_json::json!({ "vtcode_item_type": "plan" }));
556 self.push_step(step);
557 }
558 ThreadItemDetails::Reasoning(r) => {
559 let mut step = Step::agent(self.next_step_id, "");
560 step.timestamp = Some(ts.to_string());
561 if let Some(diagnostic) = &r.diagnostic {
562 step.message = Some(diagnostic.summary.clone());
563 step.extra = Some(serde_json::json!({ "diagnostic": diagnostic }));
564 } else {
565 step.reasoning_content = Some(r.text.clone());
566 step.message = None;
567 }
568 self.push_step(step);
569 }
570 ThreadItemDetails::ToolInvocation(inv) => {
571 self.pending_tool_calls.push(PendingToolCall {
573 call_id: item_id.to_string(),
574 tool_call_id: inv.tool_call_id.clone(),
575 tool_name: inv.tool_name.clone(),
576 arguments: inv.arguments.clone(),
577 timestamp: ts.to_string(),
578 });
579 }
580 ThreadItemDetails::ToolOutput(output) => {
581 let pending_idx = self.pending_tool_calls.iter().position(|p| p.call_id == output.call_id);
583
584 let (tool_name, arguments, tool_call_id, inv_ts) = if let Some(idx) = pending_idx {
585 let p = self.pending_tool_calls.remove(idx);
586 (p.tool_name, p.arguments, p.tool_call_id, p.timestamp)
587 } else {
588 ("unknown".to_string(), None, output.tool_call_id.clone(), ts.to_string())
589 };
590
591 let call_id = tool_call_id.clone().unwrap_or_else(|| output.call_id.clone());
592
593 let mut step = Step::agent(self.next_step_id, "");
594 step.timestamp = Some(inv_ts);
595 step.message = None;
596 step.tool_calls = Some(vec![AtifToolCall {
597 tool_call_id: call_id.clone(),
598 function_name: tool_name,
599 arguments,
600 }]);
601
602 let status_suffix = match output.status {
603 ToolCallStatus::Failed => " [FAILED]",
604 ToolCallStatus::InProgress => " [IN_PROGRESS]",
605 ToolCallStatus::Completed => "",
606 };
607 let content = format!("{}{}", output.output, status_suffix);
608 step.observation = Some(Observation {
609 results: vec![ObservationResult { source_call_id: call_id, content }],
610 });
611 self.push_step(step);
612 }
613 ThreadItemDetails::CommandExecution(cmd) => {
614 let call_id = item_id.to_string();
615 let mut step = Step::agent(self.next_step_id, "");
616 step.timestamp = Some(ts.to_string());
617 step.message = None;
618 step.tool_calls = Some(vec![AtifToolCall {
619 tool_call_id: call_id.clone(),
620 function_name: "command_execution".to_string(),
621 arguments: Some(serde_json::json!({
622 "command": cmd.command,
623 "arguments": cmd.arguments,
624 })),
625 }]);
626 step.observation = Some(Observation {
627 results: vec![ObservationResult {
628 source_call_id: call_id,
629 content: cmd.aggregated_output.clone(),
630 }],
631 });
632 if let Some(exit_code) = cmd.exit_code {
633 step.extra = Some(serde_json::json!({ "exit_code": exit_code }));
634 }
635 self.push_step(step);
636 }
637 ThreadItemDetails::McpToolCall(mcp) => {
638 let call_id = item_id.to_string();
639 let mut step = Step::agent(self.next_step_id, "");
640 step.timestamp = Some(ts.to_string());
641 step.message = None;
642 step.tool_calls = Some(vec![AtifToolCall {
643 tool_call_id: call_id.clone(),
644 function_name: mcp.tool_name.clone(),
645 arguments: mcp.arguments.clone(),
646 }]);
647 if let Some(result) = &mcp.result {
648 step.observation = Some(Observation {
649 results: vec![ObservationResult { source_call_id: call_id, content: result.clone() }],
650 });
651 }
652 self.push_step(step);
653 }
654 ThreadItemDetails::FileChange(fc) => {
655 let changes: Vec<String> = fc.changes.iter().map(|c| format!("{}: {:?}", c.path, c.kind)).collect();
656 let msg = format!("file_changes: {}", changes.join(", "));
657 let mut step = Step::system(self.next_step_id, msg);
658 step.timestamp = Some(ts.to_string());
659 self.push_step(step);
660 }
661 ThreadItemDetails::WebSearch(ws) => {
662 let mut step = Step::system(self.next_step_id, format!("web_search: {}", ws.query));
663 step.timestamp = Some(ts.to_string());
664 if let Some(results) = &ws.results {
665 step.observation = Some(Observation {
666 results: results
667 .iter()
668 .enumerate()
669 .map(|(i, r)| ObservationResult {
670 source_call_id: format!("search_{i}"),
671 content: r.clone(),
672 })
673 .collect(),
674 });
675 }
676 self.push_step(step);
677 }
678 ThreadItemDetails::Harness(h) => {
679 let msg = format!("harness: {:?}", h.event);
680 let mut step = Step::system(self.next_step_id, msg);
681 step.timestamp = Some(ts.to_string());
682 let mut extra = serde_json::Map::new();
683 if let Some(m) = &h.message {
684 let _ = extra.insert("harness_message".to_string(), Value::String(m.clone()));
685 }
686 if h.event == crate::HarnessEventKind::BackgroundSubprocessCompleted {
687 for (key, value) in [
688 ("task_id", h.task_id.as_ref()),
689 ("session_id", h.session_id.as_ref()),
690 ("exec_session_id", h.exec_session_id.as_ref()),
691 ("status", h.status.as_ref()),
692 ("transcript_path", h.transcript_path.as_ref()),
693 ("archive_path", h.archive_path.as_ref()),
694 ("error_category", h.error_category.as_ref()),
695 ] {
696 if let Some(value) = value {
697 let _ = extra.insert(key.to_string(), Value::String(value.clone()));
698 }
699 }
700 if let Some(exit_code) = h.exit_code {
701 let _ = extra.insert("exit_code".to_string(), Value::from(exit_code));
702 }
703 }
704 if !extra.is_empty() {
705 step.extra = Some(Value::Object(extra));
706 }
707 self.push_step(step);
708 }
709 ThreadItemDetails::Error(e) => {
710 let mut step = Step::system(self.next_step_id, &e.message);
711 step.timestamp = Some(ts.to_string());
712 self.push_step(step);
713 }
714 }
715 }
716
717 fn push_step(&mut self, step: Step) {
718 self.next_step_id = step.step_id + 1;
719 self.steps.push(step);
720 }
721
722 #[must_use]
727 pub fn finish(self, override_metrics: Option<FinalMetrics>) -> Trajectory {
728 let final_metrics = override_metrics.unwrap_or_else(|| FinalMetrics {
729 total_prompt_tokens: Some(self.total_input_tokens),
730 total_completion_tokens: Some(self.total_output_tokens),
731 total_cached_tokens: if self.total_cached_tokens > 0 {
732 Some(self.total_cached_tokens)
733 } else {
734 None
735 },
736 total_cost_usd: None,
737 total_steps: Some(self.steps.len() as u64),
738 extra: Some(serde_json::json!({ "num_turns": self.num_turns })),
739 });
740
741 Trajectory {
742 schema_version: ATIF_SCHEMA_VERSION.to_string(),
743 session_id: self.session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
744 agent: self.agent,
745 steps: self.steps,
746 notes: None,
747 final_metrics: Some(final_metrics),
748 extra: self.completed_at.map(|timestamp| serde_json::json!({"completed_at":timestamp})),
749 }
750 }
751
752 pub fn step_count(&self) -> usize {
754 self.steps.len()
755 }
756}
757
758impl crate::EventEmitter for AtifTrajectoryBuilder {
759 fn emit(&mut self, event: &ThreadEvent) {
760 self.process_event(event);
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::{
768 AgentMessageItem, CompactionMode, CompactionTrigger, HarnessEventItem, HarnessEventKind, ItemCompletedEvent,
769 ThreadCompactBoundaryEvent, ThreadItem, ThreadStartedEvent, ToolInvocationItem, ToolOutputItem,
770 TurnCompletedEvent, TurnStartedEvent, Usage,
771 };
772
773 fn fixed_ts() -> DateTime<Utc> {
774 "2025-01-15T10:30:00Z".parse().unwrap()
775 }
776
777 #[test]
778 fn terminal_timestamps_survive_export_without_synthetic_steps() {
779 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
780 let timestamp = "2026-10-03T01:02:03Z";
781 let blocked = serde_json::from_value(
782 serde_json::json!({"type":"turn.blocked", "message":"Blocked", "completed_at":timestamp}),
783 )
784 .unwrap();
785 builder.process_event_at(&blocked, fixed_ts());
786 let completed = serde_json::from_value(serde_json::json!({"type":"thread.completed", "completed_at":timestamp, "thread_id":"thread", "session_id":"session", "subtype":"success", "outcome_code":"completed", "usage":Usage::default(), "num_turns":1})).unwrap();
787 builder.process_event_at(&completed, fixed_ts());
788 let trajectory = builder.finish(None);
789 assert_eq!(trajectory.steps.len(), 1);
790 assert_eq!(trajectory.steps[0].timestamp.as_deref(), Some(timestamp));
791 assert_eq!(trajectory.extra.as_ref().unwrap()["completed_at"], timestamp);
792 }
793
794 #[test]
795 fn explanation_metadata_and_public_rationale_preserve_recorded_time() {
796 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
797 let context = crate::ItemContext {
798 task_id: "task-1".into(),
799 turn_id: "turn-1".into(),
800 actor_id: "root".into(),
801 parent_actor_id: None,
802 timestamp: "2026-10-03T01:02:03Z".into(),
803 activity: None,
804 };
805 builder.process_event_at(
806 &ThreadEvent::ItemCompleted(ItemCompletedEvent {
807 item: ThreadItem {
808 id: "decision-1".into(),
809 context: Some(Box::new(context.clone())),
810 details: ThreadItemDetails::Decision(Box::new(crate::DecisionItem {
811 summary: "Reuse parser".into(),
812 rationale: "Preserve checks".into(),
813 alternatives: vec!["Replace parser".into()],
814 evidence_ids: vec!["read-1".into()],
815 })),
816 },
817 }),
818 fixed_ts(),
819 );
820 let trajectory = builder.finish(None);
821 assert_eq!(trajectory.steps.len(), 1);
822 let step = &trajectory.steps[0];
823 assert_eq!(step.timestamp.as_deref(), Some(context.timestamp.as_str()));
824 let extra = step.extra.as_ref().unwrap();
825 assert_eq!(extra["public_rationale"], "Preserve checks");
826 assert_eq!(extra["item_context"]["task_id"], "task-1");
827 assert_eq!(extra["evidence_ids"][0], "read-1");
828 }
829
830 #[test]
831 fn trajectory_round_trip() {
832 let trajectory = Trajectory {
833 schema_version: ATIF_SCHEMA_VERSION.to_string(),
834 session_id: "test-session".to_string(),
835 agent: AtifAgent::vtcode(),
836 steps: vec![Step::user(1, "hello")],
837 notes: None,
838 final_metrics: None,
839 extra: None,
840 };
841
842 let json = serde_json::to_string_pretty(&trajectory).unwrap();
843 let restored: Trajectory = serde_json::from_str(&json).unwrap();
844 assert_eq!(restored.schema_version, ATIF_SCHEMA_VERSION);
845 assert_eq!(restored.session_id, "test-session");
846 assert_eq!(restored.steps.len(), 1);
847 }
848
849 #[test]
850 fn builder_thread_started_sets_session_id() {
851 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
852 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread-abc".to_string() });
853 builder.process_event_at(&event, fixed_ts());
854 let trajectory = builder.finish(None);
855 assert_eq!(trajectory.session_id, "thread-abc");
856 }
857
858 #[test]
859 fn builder_agent_message_step() {
860 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
861 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
862 item: ThreadItem {
863 context: None,
864 id: "msg-1".to_string(),
865 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "Hello, world!".to_string() }),
866 },
867 });
868 builder.process_event_at(&event, fixed_ts());
869 let trajectory = builder.finish(None);
870
871 assert_eq!(trajectory.steps.len(), 1);
872 let step = &trajectory.steps[0];
873 assert_eq!(step.step_id, 1);
874 assert_eq!(step.source, StepSource::Agent);
875 assert_eq!(step.message.as_deref(), Some("Hello, world!"));
876 }
877
878 #[test]
879 fn background_completion_preserves_identity_in_atif_extra() {
880 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
881 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
882 item: ThreadItem {
883 context: None,
884 id: "background-completion:task-1:exec-1".to_string(),
885 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
886 event: HarnessEventKind::BackgroundSubprocessCompleted,
887 message: Some("completed successfully".to_string()),
888 command: None,
889 path: None,
890 exit_code: Some(0),
891 attempt: None,
892 error_category: Some("background_subprocess".to_string()),
893 duration_ms: None,
894 task_id: Some("task-1".to_string()),
895 session_id: Some("session-1".to_string()),
896 exec_session_id: Some("exec-1".to_string()),
897 status: Some("stopped".to_string()),
898 transcript_path: Some("/tmp/transcript.jsonl".to_string()),
899 archive_path: Some("/tmp/archive.json".to_string()),
900 })),
901 },
902 });
903
904 builder.process_event_at(&event, fixed_ts());
905 let trajectory = builder.finish(None);
906 let step = trajectory.steps.first().expect("background completion step");
907 let extra = step.extra.as_ref().expect("background completion metadata");
908 assert_eq!(step.message.as_deref(), Some("harness: BackgroundSubprocessCompleted"));
909 assert_eq!(extra["harness_message"], "completed successfully");
910 assert_eq!(extra["task_id"], "task-1");
911 assert_eq!(extra["session_id"], "session-1");
912 assert_eq!(extra["exec_session_id"], "exec-1");
913 assert_eq!(extra["status"], "stopped");
914 assert_eq!(extra["exit_code"], 0);
915 assert_eq!(extra["transcript_path"], "/tmp/transcript.jsonl");
916 assert_eq!(extra["archive_path"], "/tmp/archive.json");
917 assert_eq!(extra["error_category"], "background_subprocess");
918 }
919
920 #[test]
921 fn builder_tool_invocation_with_output() {
922 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
923 let ts = fixed_ts();
924
925 let inv_event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
927 item: ThreadItem {
928 context: None,
929 id: "tool_1".to_string(),
930 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
931 tool_name: "read_file".to_string(),
932 arguments: Some(serde_json::json!({"path": "README.md"})),
933 tool_call_id: Some("tc_0".to_string()),
934 status: ToolCallStatus::Completed,
935 outcome: None,
936 })),
937 },
938 });
939 builder.process_event_at(&inv_event, ts);
940
941 let out_event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
943 item: ThreadItem {
944 context: None,
945 id: "tool_1:output".to_string(),
946 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
947 call_id: "tool_1".to_string(),
948 tool_call_id: Some("tc_0".to_string()),
949 spool_path: None,
950 output: "file contents here".to_string(),
951 exit_code: Some(0),
952 status: ToolCallStatus::Completed,
953 })),
954 },
955 });
956 builder.process_event_at(&out_event, ts);
957
958 let trajectory = builder.finish(None);
959 assert_eq!(trajectory.steps.len(), 1);
961 let step = &trajectory.steps[0];
962 assert_eq!(step.source, StepSource::Agent);
963
964 let calls = step.tool_calls.as_ref().unwrap();
965 assert_eq!(calls.len(), 1);
966 assert_eq!(calls[0].function_name, "read_file");
967 assert_eq!(calls[0].tool_call_id, "tc_0");
968
969 let obs = step.observation.as_ref().unwrap();
970 assert_eq!(obs.results.len(), 1);
971 assert_eq!(obs.results[0].content, "file contents here");
972 }
973
974 #[test]
975 fn builder_turn_completed_accumulates_metrics() {
976 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
977 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
978 completed_at: None,
979 usage: Usage {
980 input_tokens: 500,
981 cached_input_tokens: 100,
982 cache_creation_tokens: 0,
983 output_tokens: 200,
984 },
985 in_progress_exec_sessions: Vec::new(),
986 });
987 builder.process_event_at(&event, fixed_ts());
988
989 let trajectory = builder.finish(None);
990 let fm = trajectory.final_metrics.as_ref().unwrap();
991 assert_eq!(fm.total_prompt_tokens, Some(500));
992 assert_eq!(fm.total_completion_tokens, Some(200));
993 assert_eq!(fm.total_cached_tokens, Some(100));
994 }
995
996 #[test]
997 fn builder_turn_completed_preserves_in_progress_sessions_for_resume() {
998 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
999 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1000 completed_at: None,
1001 usage: Usage::default(),
1002 in_progress_exec_sessions: vec!["run-7".to_string()],
1003 });
1004 builder.process_event_at(&event, fixed_ts());
1005
1006 let trajectory = builder.finish(None);
1007 let step = trajectory.steps.last().expect("turn_completed step");
1008 let extra = step.extra.clone().expect("extra carries resume ids");
1009 assert_eq!(extra["in_progress_exec_sessions"], serde_json::json!(["run-7"]));
1010
1011 let mut empty_builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
1013 let empty = ThreadEvent::TurnCompleted(TurnCompletedEvent {
1014 completed_at: None,
1015 usage: Usage::default(),
1016 in_progress_exec_sessions: Vec::new(),
1017 });
1018 empty_builder.process_event_at(&empty, fixed_ts());
1019 let empty_trajectory = empty_builder.finish(None);
1020 assert!(empty_trajectory.steps.last().expect("step").extra.is_none());
1021 }
1022
1023 #[test]
1024 fn step_metrics_from_usage() {
1025 let usage = Usage {
1026 input_tokens: 1000,
1027 cached_input_tokens: 200,
1028 cache_creation_tokens: 50,
1029 output_tokens: 300,
1030 };
1031 let metrics = StepMetrics::from_usage(&usage);
1032 assert_eq!(metrics.prompt_tokens, Some(1000));
1033 assert_eq!(metrics.completion_tokens, Some(300));
1034 assert_eq!(metrics.cached_tokens, Some(200));
1035 assert!(metrics.extra.is_some());
1036 }
1037
1038 #[test]
1039 fn builder_implements_event_emitter() {
1040 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
1041 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "t-1".to_string() });
1042 crate::EventEmitter::emit(&mut builder, &event);
1044 assert_eq!(builder.step_count(), 0); }
1046
1047 #[test]
1048 fn skips_lifecycle_events() {
1049 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
1050 builder.process_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default()));
1051 assert_eq!(builder.step_count(), 0);
1052 }
1053
1054 #[test]
1055 fn compact_boundary_with_segment_metadata_preserves_atif_export() {
1056 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
1057 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
1058 thread_id: "thread-1".to_string(),
1059 trigger: CompactionTrigger::Auto,
1060 mode: CompactionMode::Local,
1061 original_message_count: 12,
1062 compacted_message_count: 5,
1063 history_artifact_path: None,
1064 previous_segment_id: Some("segment-0001".to_string()),
1065 new_segment_id: Some("segment-0002".to_string()),
1066 previous_prefix_hash: Some("prefix-before".to_string()),
1067 new_prefix_hash: Some("prefix-after".to_string()),
1068 previous_catalog_hash: Some("catalog-before".to_string()),
1069 new_catalog_hash: Some("catalog-after".to_string()),
1070 }));
1071
1072 builder.process_event_at(&event, fixed_ts());
1073 let trajectory = builder.finish(None);
1074
1075 assert_eq!(trajectory.steps.len(), 1);
1076 assert_eq!(trajectory.steps[0].source, StepSource::System);
1077 assert_eq!(
1078 trajectory.steps[0].message.as_deref(),
1079 Some("context_compaction: 12 messages -> 5 messages (auto)")
1080 );
1081 }
1082}