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
304pub struct AtifTrajectoryBuilder {
316 agent: AtifAgent,
317 session_id: Option<String>,
318 steps: Vec<Step>,
319 next_step_id: u64,
320 total_input_tokens: u64,
322 total_output_tokens: u64,
323 total_cached_tokens: u64,
324 num_turns: usize,
325 saw_per_turn_usage: bool,
328 pending_tool_calls: Vec<PendingToolCall>,
330}
331
332struct PendingToolCall {
333 call_id: String,
334 tool_call_id: Option<String>,
335 tool_name: String,
336 arguments: Option<Value>,
337 timestamp: String,
338}
339
340impl AtifTrajectoryBuilder {
341 pub fn new(agent: AtifAgent) -> Self {
343 Self {
344 agent,
345 session_id: None,
346 steps: Vec::new(),
347 next_step_id: 1,
348 total_input_tokens: 0,
349 total_output_tokens: 0,
350 total_cached_tokens: 0,
351 num_turns: 0,
352 saw_per_turn_usage: false,
353 pending_tool_calls: Vec::new(),
354 }
355 }
356
357 pub fn set_session_id(&mut self, id: impl Into<String>) {
360 self.session_id = Some(id.into());
361 }
362
363 pub fn process_event(&mut self, event: &ThreadEvent) {
365 self.process_event_at(event, Utc::now());
366 }
367
368 pub fn process_event_at(&mut self, event: &ThreadEvent, ts: DateTime<Utc>) {
370 let ts_str = ts.to_rfc3339();
371 match event {
372 ThreadEvent::ThreadStarted(e) => {
373 if self.session_id.is_none() {
374 self.session_id = Some(e.thread_id.clone());
375 }
376 }
377 ThreadEvent::ThreadCompleted(e) => {
378 if self.session_id.is_none() {
379 self.session_id = Some(e.session_id.clone());
380 }
381 self.num_turns = e.num_turns;
382 if !self.saw_per_turn_usage {
388 self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
389 self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
390 self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
391 }
392 }
393 ThreadEvent::TurnCompleted(e) => {
394 self.saw_per_turn_usage = true;
395 self.total_input_tokens = self.total_input_tokens.saturating_add(e.usage.input_tokens);
396 self.total_output_tokens = self.total_output_tokens.saturating_add(e.usage.output_tokens);
397 self.total_cached_tokens = self.total_cached_tokens.saturating_add(e.usage.cached_input_tokens);
398 self.num_turns += 1;
399
400 let mut step = Step::system(self.next_step_id, "turn_completed");
401 step.timestamp = Some(ts_str);
402 step.metrics = Some(StepMetrics::from_usage(&e.usage));
403 if !e.in_progress_exec_sessions.is_empty() {
404 step.extra = Some(serde_json::json!({
405 "in_progress_exec_sessions": e.in_progress_exec_sessions,
406 }));
407 }
408 self.push_step(step);
409 }
410 ThreadEvent::TurnFailed(e) => {
411 if let Some(usage) = &e.usage {
412 self.saw_per_turn_usage = true;
413 self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
414 self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
415 self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
416 }
417 let mut step = Step::system(self.next_step_id, &e.message);
418 step.timestamp = Some(ts_str);
419 step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
420 self.push_step(step);
421 }
422 ThreadEvent::TurnBlocked(e) => {
423 if let Some(usage) = &e.usage {
424 self.saw_per_turn_usage = true;
425 self.total_input_tokens = self.total_input_tokens.saturating_add(usage.input_tokens);
426 self.total_output_tokens = self.total_output_tokens.saturating_add(usage.output_tokens);
427 self.total_cached_tokens = self.total_cached_tokens.saturating_add(usage.cached_input_tokens);
428 }
429 let mut step = Step::system(self.next_step_id, &e.message);
430 step.timestamp = Some(ts_str);
431 step.metrics = e.usage.as_ref().map(StepMetrics::from_usage);
432 step.extra = Some(serde_json::json!({
433 "last_tool": e.last_tool,
434 "blocked_streak": e.blocked_streak,
435 "blocked_total": e.blocked_total,
436 "consecutive_cap": e.consecutive_cap,
437 "total_cap": e.total_cap,
438 "recovery_active": e.recovery_active,
439 }));
440 self.push_step(step);
441 }
442 ThreadEvent::ItemCompleted(e) => {
443 self.process_item_completed(&e.item.id, &e.item.details, &ts_str);
444 }
445 ThreadEvent::ThreadCompactBoundary(e) => {
446 let msg = format!(
447 "context_compaction: {} messages -> {} messages ({})",
448 e.original_message_count,
449 e.compacted_message_count,
450 e.trigger.as_str()
451 );
452 let mut step = Step::system(self.next_step_id, msg);
453 step.timestamp = Some(ts_str);
454 self.push_step(step);
455 }
456 ThreadEvent::ContextReset(e) => {
457 let msg = format!(
458 "context_reset: {}% context used; plan preserved: {}; tool budget reset: {}",
459 e.previous_context_usage_percent, e.plan_preserved, e.tool_budget_reset
460 );
461 let mut step = Step::system(self.next_step_id, msg);
462 step.timestamp = Some(ts_str);
463 step.extra = Some(serde_json::json!({
464 "thread_id": e.thread_id,
465 "turn_id": e.turn_id,
466 "trigger": e.trigger,
467 "plan_preserved": e.plan_preserved,
468 "previous_context_usage_percent": e.previous_context_usage_percent,
469 "tool_budget_reset": e.tool_budget_reset,
470 }));
471 self.push_step(step);
472 }
473 ThreadEvent::Error(e) => {
474 let mut step = Step::system(self.next_step_id, &e.message);
475 step.timestamp = Some(ts_str);
476 self.push_step(step);
477 }
478 ThreadEvent::TurnStarted(_)
480 | ThreadEvent::ItemStarted(_)
481 | ThreadEvent::ItemUpdated(_)
482 | ThreadEvent::PlanDelta(_)
483 | ThreadEvent::PlanApprovalRequested(_)
484 | ThreadEvent::PlanApprovalResolved(_)
485 | ThreadEvent::PermissionRequested(_)
486 | ThreadEvent::PermissionResolved(_)
487 | ThreadEvent::Interjected(_)
488 | ThreadEvent::Unknown => {}
489 }
490 }
491
492 fn process_item_completed(&mut self, item_id: &str, details: &ThreadItemDetails, ts: &str) {
493 match details {
494 ThreadItemDetails::AgentMessage(msg) => {
495 let mut step = Step::agent(self.next_step_id, &msg.text);
496 step.timestamp = Some(ts.to_string());
497 self.push_step(step);
498 }
499 ThreadItemDetails::Plan(plan) => {
500 let mut step = Step::agent(self.next_step_id, &plan.text);
501 step.timestamp = Some(ts.to_string());
502 step.extra = Some(serde_json::json!({ "vtcode_item_type": "plan" }));
503 self.push_step(step);
504 }
505 ThreadItemDetails::Reasoning(r) => {
506 let mut step = Step::agent(self.next_step_id, "");
507 step.timestamp = Some(ts.to_string());
508 step.reasoning_content = Some(r.text.clone());
509 step.message = None;
510 self.push_step(step);
511 }
512 ThreadItemDetails::ToolInvocation(inv) => {
513 self.pending_tool_calls.push(PendingToolCall {
515 call_id: item_id.to_string(),
516 tool_call_id: inv.tool_call_id.clone(),
517 tool_name: inv.tool_name.clone(),
518 arguments: inv.arguments.clone(),
519 timestamp: ts.to_string(),
520 });
521 }
522 ThreadItemDetails::ToolOutput(output) => {
523 let pending_idx = self.pending_tool_calls.iter().position(|p| p.call_id == output.call_id);
525
526 let (tool_name, arguments, tool_call_id, inv_ts) = if let Some(idx) = pending_idx {
527 let p = self.pending_tool_calls.remove(idx);
528 (p.tool_name, p.arguments, p.tool_call_id, p.timestamp)
529 } else {
530 ("unknown".to_string(), None, output.tool_call_id.clone(), ts.to_string())
531 };
532
533 let call_id = tool_call_id.clone().unwrap_or_else(|| output.call_id.clone());
534
535 let mut step = Step::agent(self.next_step_id, "");
536 step.timestamp = Some(inv_ts);
537 step.message = None;
538 step.tool_calls = Some(vec![AtifToolCall {
539 tool_call_id: call_id.clone(),
540 function_name: tool_name,
541 arguments,
542 }]);
543
544 let status_suffix = match output.status {
545 ToolCallStatus::Failed => " [FAILED]",
546 ToolCallStatus::InProgress => " [IN_PROGRESS]",
547 ToolCallStatus::Completed => "",
548 };
549 let content = format!("{}{}", output.output, status_suffix);
550 step.observation = Some(Observation {
551 results: vec![ObservationResult { source_call_id: call_id, content }],
552 });
553 self.push_step(step);
554 }
555 ThreadItemDetails::CommandExecution(cmd) => {
556 let call_id = item_id.to_string();
557 let mut step = Step::agent(self.next_step_id, "");
558 step.timestamp = Some(ts.to_string());
559 step.message = None;
560 step.tool_calls = Some(vec![AtifToolCall {
561 tool_call_id: call_id.clone(),
562 function_name: "command_execution".to_string(),
563 arguments: Some(serde_json::json!({
564 "command": cmd.command,
565 "arguments": cmd.arguments,
566 })),
567 }]);
568 step.observation = Some(Observation {
569 results: vec![ObservationResult {
570 source_call_id: call_id,
571 content: cmd.aggregated_output.clone(),
572 }],
573 });
574 if let Some(exit_code) = cmd.exit_code {
575 step.extra = Some(serde_json::json!({ "exit_code": exit_code }));
576 }
577 self.push_step(step);
578 }
579 ThreadItemDetails::McpToolCall(mcp) => {
580 let call_id = item_id.to_string();
581 let mut step = Step::agent(self.next_step_id, "");
582 step.timestamp = Some(ts.to_string());
583 step.message = None;
584 step.tool_calls = Some(vec![AtifToolCall {
585 tool_call_id: call_id.clone(),
586 function_name: mcp.tool_name.clone(),
587 arguments: mcp.arguments.clone(),
588 }]);
589 if let Some(result) = &mcp.result {
590 step.observation = Some(Observation {
591 results: vec![ObservationResult { source_call_id: call_id, content: result.clone() }],
592 });
593 }
594 self.push_step(step);
595 }
596 ThreadItemDetails::FileChange(fc) => {
597 let changes: Vec<String> = fc.changes.iter().map(|c| format!("{}: {:?}", c.path, c.kind)).collect();
598 let msg = format!("file_changes: {}", changes.join(", "));
599 let mut step = Step::system(self.next_step_id, msg);
600 step.timestamp = Some(ts.to_string());
601 self.push_step(step);
602 }
603 ThreadItemDetails::WebSearch(ws) => {
604 let mut step = Step::system(self.next_step_id, format!("web_search: {}", ws.query));
605 step.timestamp = Some(ts.to_string());
606 if let Some(results) = &ws.results {
607 step.observation = Some(Observation {
608 results: results
609 .iter()
610 .enumerate()
611 .map(|(i, r)| ObservationResult {
612 source_call_id: format!("search_{i}"),
613 content: r.clone(),
614 })
615 .collect(),
616 });
617 }
618 self.push_step(step);
619 }
620 ThreadItemDetails::Harness(h) => {
621 let msg = format!("harness: {:?}", h.event);
622 let mut step = Step::system(self.next_step_id, msg);
623 step.timestamp = Some(ts.to_string());
624 let mut extra = serde_json::Map::new();
625 if let Some(m) = &h.message {
626 let _ = extra.insert("harness_message".to_string(), Value::String(m.clone()));
627 }
628 if h.event == crate::HarnessEventKind::BackgroundSubprocessCompleted {
629 for (key, value) in [
630 ("task_id", h.task_id.as_ref()),
631 ("session_id", h.session_id.as_ref()),
632 ("exec_session_id", h.exec_session_id.as_ref()),
633 ("status", h.status.as_ref()),
634 ("transcript_path", h.transcript_path.as_ref()),
635 ("archive_path", h.archive_path.as_ref()),
636 ("error_category", h.error_category.as_ref()),
637 ] {
638 if let Some(value) = value {
639 let _ = extra.insert(key.to_string(), Value::String(value.clone()));
640 }
641 }
642 if let Some(exit_code) = h.exit_code {
643 let _ = extra.insert("exit_code".to_string(), Value::from(exit_code));
644 }
645 }
646 if !extra.is_empty() {
647 step.extra = Some(Value::Object(extra));
648 }
649 self.push_step(step);
650 }
651 ThreadItemDetails::Error(e) => {
652 let mut step = Step::system(self.next_step_id, &e.message);
653 step.timestamp = Some(ts.to_string());
654 self.push_step(step);
655 }
656 }
657 }
658
659 fn push_step(&mut self, step: Step) {
660 self.next_step_id = step.step_id + 1;
661 self.steps.push(step);
662 }
663
664 pub fn finish(self, override_metrics: Option<FinalMetrics>) -> Trajectory {
669 let final_metrics = override_metrics.unwrap_or_else(|| FinalMetrics {
670 total_prompt_tokens: Some(self.total_input_tokens),
671 total_completion_tokens: Some(self.total_output_tokens),
672 total_cached_tokens: if self.total_cached_tokens > 0 {
673 Some(self.total_cached_tokens)
674 } else {
675 None
676 },
677 total_cost_usd: None,
678 total_steps: Some(self.steps.len() as u64),
679 extra: Some(serde_json::json!({ "num_turns": self.num_turns })),
680 });
681
682 Trajectory {
683 schema_version: ATIF_SCHEMA_VERSION.to_string(),
684 session_id: self.session_id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
685 agent: self.agent,
686 steps: self.steps,
687 notes: None,
688 final_metrics: Some(final_metrics),
689 extra: None,
690 }
691 }
692
693 pub fn step_count(&self) -> usize {
695 self.steps.len()
696 }
697}
698
699impl crate::EventEmitter for AtifTrajectoryBuilder {
700 fn emit(&mut self, event: &ThreadEvent) {
701 self.process_event(event);
702 }
703}
704
705#[cfg(test)]
706mod tests {
707 use super::*;
708 use crate::{
709 AgentMessageItem, CompactionMode, CompactionTrigger, HarnessEventItem, HarnessEventKind, ItemCompletedEvent,
710 ThreadCompactBoundaryEvent, ThreadItem, ThreadStartedEvent, ToolInvocationItem, ToolOutputItem,
711 TurnCompletedEvent, TurnStartedEvent, Usage,
712 };
713
714 fn fixed_ts() -> DateTime<Utc> {
715 "2025-01-15T10:30:00Z".parse().unwrap()
716 }
717
718 #[test]
719 fn trajectory_round_trip() {
720 let trajectory = Trajectory {
721 schema_version: ATIF_SCHEMA_VERSION.to_string(),
722 session_id: "test-session".to_string(),
723 agent: AtifAgent::vtcode(),
724 steps: vec![Step::user(1, "hello")],
725 notes: None,
726 final_metrics: None,
727 extra: None,
728 };
729
730 let json = serde_json::to_string_pretty(&trajectory).unwrap();
731 let restored: Trajectory = serde_json::from_str(&json).unwrap();
732 assert_eq!(restored.schema_version, ATIF_SCHEMA_VERSION);
733 assert_eq!(restored.session_id, "test-session");
734 assert_eq!(restored.steps.len(), 1);
735 }
736
737 #[test]
738 fn builder_thread_started_sets_session_id() {
739 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
740 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "thread-abc".to_string() });
741 builder.process_event_at(&event, fixed_ts());
742 let trajectory = builder.finish(None);
743 assert_eq!(trajectory.session_id, "thread-abc");
744 }
745
746 #[test]
747 fn builder_agent_message_step() {
748 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
749 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
750 item: ThreadItem {
751 id: "msg-1".to_string(),
752 details: ThreadItemDetails::AgentMessage(AgentMessageItem { text: "Hello, world!".to_string() }),
753 },
754 });
755 builder.process_event_at(&event, fixed_ts());
756 let trajectory = builder.finish(None);
757
758 assert_eq!(trajectory.steps.len(), 1);
759 let step = &trajectory.steps[0];
760 assert_eq!(step.step_id, 1);
761 assert_eq!(step.source, StepSource::Agent);
762 assert_eq!(step.message.as_deref(), Some("Hello, world!"));
763 }
764
765 #[test]
766 fn background_completion_preserves_identity_in_atif_extra() {
767 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
768 let event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
769 item: ThreadItem {
770 id: "background-completion:task-1:exec-1".to_string(),
771 details: ThreadItemDetails::Harness(Box::new(HarnessEventItem {
772 event: HarnessEventKind::BackgroundSubprocessCompleted,
773 message: Some("completed successfully".to_string()),
774 command: None,
775 path: None,
776 exit_code: Some(0),
777 attempt: None,
778 error_category: Some("background_subprocess".to_string()),
779 duration_ms: None,
780 task_id: Some("task-1".to_string()),
781 session_id: Some("session-1".to_string()),
782 exec_session_id: Some("exec-1".to_string()),
783 status: Some("stopped".to_string()),
784 transcript_path: Some("/tmp/transcript.jsonl".to_string()),
785 archive_path: Some("/tmp/archive.json".to_string()),
786 })),
787 },
788 });
789
790 builder.process_event_at(&event, fixed_ts());
791 let trajectory = builder.finish(None);
792 let step = trajectory.steps.first().expect("background completion step");
793 let extra = step.extra.as_ref().expect("background completion metadata");
794 assert_eq!(step.message.as_deref(), Some("harness: BackgroundSubprocessCompleted"));
795 assert_eq!(extra["harness_message"], "completed successfully");
796 assert_eq!(extra["task_id"], "task-1");
797 assert_eq!(extra["session_id"], "session-1");
798 assert_eq!(extra["exec_session_id"], "exec-1");
799 assert_eq!(extra["status"], "stopped");
800 assert_eq!(extra["exit_code"], 0);
801 assert_eq!(extra["transcript_path"], "/tmp/transcript.jsonl");
802 assert_eq!(extra["archive_path"], "/tmp/archive.json");
803 assert_eq!(extra["error_category"], "background_subprocess");
804 }
805
806 #[test]
807 fn builder_tool_invocation_with_output() {
808 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
809 let ts = fixed_ts();
810
811 let inv_event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
813 item: ThreadItem {
814 id: "tool_1".to_string(),
815 details: ThreadItemDetails::ToolInvocation(Box::new(ToolInvocationItem {
816 tool_name: "read_file".to_string(),
817 arguments: Some(serde_json::json!({"path": "README.md"})),
818 tool_call_id: Some("tc_0".to_string()),
819 status: ToolCallStatus::Completed,
820 outcome: None,
821 })),
822 },
823 });
824 builder.process_event_at(&inv_event, ts);
825
826 let out_event = ThreadEvent::ItemCompleted(ItemCompletedEvent {
828 item: ThreadItem {
829 id: "tool_1:output".to_string(),
830 details: ThreadItemDetails::ToolOutput(Box::new(ToolOutputItem {
831 call_id: "tool_1".to_string(),
832 tool_call_id: Some("tc_0".to_string()),
833 spool_path: None,
834 output: "file contents here".to_string(),
835 exit_code: Some(0),
836 status: ToolCallStatus::Completed,
837 })),
838 },
839 });
840 builder.process_event_at(&out_event, ts);
841
842 let trajectory = builder.finish(None);
843 assert_eq!(trajectory.steps.len(), 1);
845 let step = &trajectory.steps[0];
846 assert_eq!(step.source, StepSource::Agent);
847
848 let calls = step.tool_calls.as_ref().unwrap();
849 assert_eq!(calls.len(), 1);
850 assert_eq!(calls[0].function_name, "read_file");
851 assert_eq!(calls[0].tool_call_id, "tc_0");
852
853 let obs = step.observation.as_ref().unwrap();
854 assert_eq!(obs.results.len(), 1);
855 assert_eq!(obs.results[0].content, "file contents here");
856 }
857
858 #[test]
859 fn builder_turn_completed_accumulates_metrics() {
860 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
861 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
862 usage: Usage {
863 input_tokens: 500,
864 cached_input_tokens: 100,
865 cache_creation_tokens: 0,
866 output_tokens: 200,
867 },
868 in_progress_exec_sessions: Vec::new(),
869 });
870 builder.process_event_at(&event, fixed_ts());
871
872 let trajectory = builder.finish(None);
873 let fm = trajectory.final_metrics.as_ref().unwrap();
874 assert_eq!(fm.total_prompt_tokens, Some(500));
875 assert_eq!(fm.total_completion_tokens, Some(200));
876 assert_eq!(fm.total_cached_tokens, Some(100));
877 }
878
879 #[test]
880 fn builder_turn_completed_preserves_in_progress_sessions_for_resume() {
881 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
882 let event = ThreadEvent::TurnCompleted(TurnCompletedEvent {
883 usage: Usage::default(),
884 in_progress_exec_sessions: vec!["run-7".to_string()],
885 });
886 builder.process_event_at(&event, fixed_ts());
887
888 let trajectory = builder.finish(None);
889 let step = trajectory.steps.last().expect("turn_completed step");
890 let extra = step.extra.clone().expect("extra carries resume ids");
891 assert_eq!(extra["in_progress_exec_sessions"], serde_json::json!(["run-7"]));
892
893 let mut empty_builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
895 let empty = ThreadEvent::TurnCompleted(TurnCompletedEvent {
896 usage: Usage::default(),
897 in_progress_exec_sessions: Vec::new(),
898 });
899 empty_builder.process_event_at(&empty, fixed_ts());
900 let empty_trajectory = empty_builder.finish(None);
901 assert!(empty_trajectory.steps.last().expect("step").extra.is_none());
902 }
903
904 #[test]
905 fn step_metrics_from_usage() {
906 let usage = Usage {
907 input_tokens: 1000,
908 cached_input_tokens: 200,
909 cache_creation_tokens: 50,
910 output_tokens: 300,
911 };
912 let metrics = StepMetrics::from_usage(&usage);
913 assert_eq!(metrics.prompt_tokens, Some(1000));
914 assert_eq!(metrics.completion_tokens, Some(300));
915 assert_eq!(metrics.cached_tokens, Some(200));
916 assert!(metrics.extra.is_some());
917 }
918
919 #[test]
920 fn builder_implements_event_emitter() {
921 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
922 let event = ThreadEvent::ThreadStarted(ThreadStartedEvent { thread_id: "t-1".to_string() });
923 crate::EventEmitter::emit(&mut builder, &event);
925 assert_eq!(builder.step_count(), 0); }
927
928 #[test]
929 fn skips_lifecycle_events() {
930 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
931 builder.process_event(&ThreadEvent::TurnStarted(TurnStartedEvent::default()));
932 assert_eq!(builder.step_count(), 0);
933 }
934
935 #[test]
936 fn compact_boundary_with_segment_metadata_preserves_atif_export() {
937 let mut builder = AtifTrajectoryBuilder::new(AtifAgent::vtcode());
938 let event = ThreadEvent::ThreadCompactBoundary(Box::new(ThreadCompactBoundaryEvent {
939 thread_id: "thread-1".to_string(),
940 trigger: CompactionTrigger::Auto,
941 mode: CompactionMode::Local,
942 original_message_count: 12,
943 compacted_message_count: 5,
944 history_artifact_path: None,
945 previous_segment_id: Some("segment-0001".to_string()),
946 new_segment_id: Some("segment-0002".to_string()),
947 previous_prefix_hash: Some("prefix-before".to_string()),
948 new_prefix_hash: Some("prefix-after".to_string()),
949 previous_catalog_hash: Some("catalog-before".to_string()),
950 new_catalog_hash: Some("catalog-after".to_string()),
951 }));
952
953 builder.process_event_at(&event, fixed_ts());
954 let trajectory = builder.finish(None);
955
956 assert_eq!(trajectory.steps.len(), 1);
957 assert_eq!(trajectory.steps[0].source, StepSource::System);
958 assert_eq!(
959 trajectory.steps[0].message.as_deref(),
960 Some("context_compaction: 12 messages -> 5 messages (auto)")
961 );
962 }
963}