use serde::{Deserialize, Serialize};
use crate::{Metadata, Transcript, Usage};
pub const ATIF_VERSION: &str = "ATIF-v1.7";
pub const ATIF_FORMAT: &str = "ATIF";
pub fn is_supported_schema_version(version: &str) -> bool {
version == "ATIF-v1" || version.starts_with("ATIF-v1.")
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Trajectory {
pub schema_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trajectory_id: Option<String>,
pub agent: Agent,
pub steps: Vec<Step>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub final_metrics: Option<FinalMetrics>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub continued_trajectory_ref: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagent_trajectories: Vec<Trajectory>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Agent {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_definitions: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
impl Agent {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
..Default::default()
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum StepSource {
System,
User,
Agent,
Other(String),
}
impl StepSource {
pub fn as_str(&self) -> &str {
match self {
StepSource::System => "system",
StepSource::User => "user",
StepSource::Agent => "agent",
StepSource::Other(s) => s,
}
}
}
impl From<String> for StepSource {
fn from(s: String) -> Self {
match s.as_str() {
"system" => StepSource::System,
"user" => StepSource::User,
"agent" => StepSource::Agent,
_ => StepSource::Other(s),
}
}
}
impl From<StepSource> for String {
fn from(s: StepSource) -> Self {
s.as_str().to_string()
}
}
#[cfg(feature = "schema")]
impl schemars::JsonSchema for StepSource {
fn inline_schema() -> bool {
true
}
fn schema_name() -> std::borrow::Cow<'static, str> {
"StepSource".into()
}
fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"description": "Step originator: \"system\", \"user\", or \"agent\" (open vocabulary; unknown values are carried through)."
})
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[serde(untagged)]
pub enum StepContent {
Text(String),
Parts(Vec<ContentPart>),
}
impl Default for StepContent {
fn default() -> Self {
StepContent::Text(String::new())
}
}
impl From<String> for StepContent {
fn from(s: String) -> Self {
StepContent::Text(s)
}
}
impl From<&str> for StepContent {
fn from(s: &str) -> Self {
StepContent::Text(s.to_string())
}
}
impl StepContent {
pub fn text(&self) -> String {
match self {
StepContent::Text(s) => s.clone(),
StepContent::Parts(parts) => parts
.iter()
.filter_map(|p| p.text.as_deref())
.collect::<Vec<_>>()
.join("\n"),
}
}
pub fn is_empty(&self) -> bool {
match self {
StepContent::Text(s) => s.is_empty(),
StepContent::Parts(parts) => parts.is_empty(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ContentPart {
#[serde(rename = "type")]
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<ImageSource>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
impl ContentPart {
pub fn text(text: impl Into<String>) -> Self {
Self {
kind: "text".into(),
text: Some(text.into()),
..Default::default()
}
}
pub fn image(media_type: impl Into<String>, path: impl Into<String>) -> Self {
Self {
kind: "image".into(),
source: Some(ImageSource {
media_type: media_type.into(),
path: path.into(),
}),
..Default::default()
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ImageSource {
pub media_type: String,
pub path: String,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Step {
pub step_id: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
pub source: StepSource,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub model_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_effort: Option<serde_json::Value>,
#[serde(default)]
pub message: StepContent,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reasoning_content: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tool_calls: Vec<ToolCall>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub observation: Option<Observation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub metrics: Option<StepMetrics>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub llm_call_count: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub is_copied_context: Option<bool>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
impl Step {
pub fn new(step_id: u64, source: StepSource, message: impl Into<StepContent>) -> Self {
Self {
step_id,
timestamp: None,
source,
model_name: None,
reasoning_effort: None,
message: message.into(),
reasoning_content: None,
tool_calls: Vec::new(),
observation: None,
metrics: None,
llm_call_count: None,
is_copied_context: None,
extra: Metadata::new(),
}
}
pub fn is_agent(&self) -> bool {
self.source == StepSource::Agent
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ToolCall {
pub tool_call_id: String,
pub function_name: String,
pub arguments: serde_json::Value,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
impl ToolCall {
pub fn new(
tool_call_id: impl Into<String>,
function_name: impl Into<String>,
arguments: serde_json::Value,
) -> Self {
Self {
tool_call_id: tool_call_id.into(),
function_name: function_name.into(),
arguments,
extra: Metadata::new(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct Observation {
pub results: Vec<ObservationResult>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ObservationResult {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_call_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub content: Option<StepContent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub subagent_trajectory_ref: Vec<SubagentTrajectoryRef>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct SubagentTrajectoryRef {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trajectory_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub trajectory_path: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct StepMetrics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cached_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cost_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prompt_token_ids: Option<Vec<u64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub completion_token_ids: Option<Vec<u64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logprobs: Option<Vec<f64>>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct FinalMetrics {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_prompt_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_completion_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cached_tokens: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost_usd: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_steps: Option<u64>,
#[serde(default, skip_serializing_if = "Metadata::is_empty")]
pub extra: Metadata,
}
fn extra_u64(extra: &Metadata, key: &str) -> u64 {
extra
.get(key)
.and_then(serde_json::Value::as_u64)
.unwrap_or(0)
}
impl Trajectory {
pub fn new(agent: Agent) -> Self {
Self {
schema_version: ATIF_VERSION.into(),
session_id: None,
trajectory_id: None,
agent,
steps: Vec::new(),
notes: None,
final_metrics: None,
continued_trajectory_ref: None,
subagent_trajectories: Vec::new(),
extra: Metadata::new(),
}
}
pub fn from_json(json: &str) -> Result<Self, String> {
let value: serde_json::Value =
serde_json::from_str(json).map_err(|e| format!("invalid ATIF trajectory: {e}"))?;
Self::from_value(value)
}
pub fn from_value(value: serde_json::Value) -> Result<Self, String> {
let trajectory: Trajectory =
serde_json::from_value(value).map_err(|e| format!("invalid ATIF trajectory: {e}"))?;
if !is_supported_schema_version(&trajectory.schema_version) {
return Err(format!(
"unsupported ATIF schema_version {:?}: this build reads ATIF-v1.x \
(emits {ATIF_VERSION})",
trajectory.schema_version,
));
}
Ok(trajectory)
}
pub fn final_agent_text(&self) -> Option<String> {
self.steps
.iter()
.rev()
.find(|s| s.is_agent())
.map(|s| s.message.text())
}
pub fn tool_call_names(&self) -> Vec<String> {
self.steps
.iter()
.flat_map(|s| s.tool_calls.iter().map(|c| c.function_name.clone()))
.collect()
}
pub fn agent_iterations(&self) -> usize {
self.steps
.iter()
.filter(|s| s.is_agent() && s.llm_call_count != Some(0))
.count()
}
pub fn usage(&self) -> Usage {
if let Some(fm) = &self.final_metrics {
return Usage {
input_tokens: fm.total_prompt_tokens.unwrap_or(0),
output_tokens: fm.total_completion_tokens.unwrap_or(0),
cache_read_tokens: fm.total_cached_tokens.unwrap_or(0),
reasoning_tokens: extra_u64(&fm.extra, "reasoning_tokens"),
cost_usd: fm.total_cost_usd.unwrap_or(0.0),
};
}
let mut usage = Usage::default();
for m in self.steps.iter().filter_map(|s| s.metrics.as_ref()) {
usage.input_tokens += m.prompt_tokens.unwrap_or(0);
usage.output_tokens += m.completion_tokens.unwrap_or(0);
usage.cache_read_tokens += m.cached_tokens.unwrap_or(0);
usage.reasoning_tokens += extra_u64(&m.extra, "reasoning_tokens");
usage.cost_usd += m.cost_usd.unwrap_or(0.0);
}
usage
}
pub fn project_into(&self, t: &mut Transcript) {
if t.final_response.is_empty()
&& let Some(text) = self.final_agent_text()
{
t.final_response = text;
}
if t.tool_calls.is_empty() {
t.tool_calls = self.tool_call_names();
}
if t.tool_calls_count == 0 {
t.tool_calls_count = t.tool_calls.len();
}
if t.iterations == 0 {
t.iterations = self.agent_iterations();
}
if t.usage == Usage::default() {
t.usage = self.usage();
}
}
pub fn from_transcript(t: &Transcript) -> Trajectory {
let mut traj = Trajectory::new(Agent::new(SYNTH_AGENT_NAME, SYNTH_AGENT_VERSION));
let mut step_id = 1u64;
for name in &t.tool_calls {
let mut step = Step::new(step_id, StepSource::Agent, "");
step.tool_calls = vec![ToolCall::new(
format!("call-{step_id}"),
name.clone(),
serde_json::json!({}),
)];
traj.steps.push(step);
step_id += 1;
}
if !t.final_response.is_empty() {
traj.steps.push(Step::new(
step_id,
StepSource::Agent,
t.final_response.clone(),
));
}
let u = &t.usage;
if *u != Usage::default() {
let mut extra = Metadata::new();
if u.reasoning_tokens > 0 {
extra.insert("reasoning_tokens".into(), u.reasoning_tokens.into());
}
traj.final_metrics = Some(FinalMetrics {
total_prompt_tokens: (u.input_tokens != 0).then_some(u.input_tokens),
total_completion_tokens: (u.output_tokens != 0).then_some(u.output_tokens),
total_cached_tokens: (u.cache_read_tokens != 0).then_some(u.cache_read_tokens),
total_cost_usd: (u.cost_usd > 0.0).then_some(u.cost_usd),
total_steps: Some(traj.steps.len() as u64),
extra,
});
}
traj
}
}
const SYNTH_AGENT_NAME: &str = "mira-export";
const SYNTH_AGENT_VERSION: &str = "0";
#[derive(Clone, Copy, Debug)]
pub struct ToolInvocation<'a> {
pub name: &'a str,
pub arguments: Option<&'a serde_json::Value>,
pub result: Option<&'a StepContent>,
}
impl Transcript {
pub fn tool_invocations(&self) -> Vec<ToolInvocation<'_>> {
let Some(trajectory) = &self.trajectory else {
return self
.tool_calls
.iter()
.map(|name| ToolInvocation {
name,
arguments: None,
result: None,
})
.collect();
};
let mut out = Vec::new();
for step in &trajectory.steps {
for call in &step.tool_calls {
let result = step
.observation
.as_ref()
.and_then(|o| {
o.results
.iter()
.find(|r| r.source_call_id.as_deref() == Some(&call.tool_call_id))
})
.and_then(|r| r.content.as_ref());
out.push(ToolInvocation {
name: &call.function_name,
arguments: Some(&call.arguments),
result,
});
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const FIXTURE: &str = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../schema/v1/conformance/trajectory.json"
));
fn rfc_example() -> serde_json::Value {
let doc: serde_json::Value = serde_json::from_str(FIXTURE).unwrap();
doc["cases"]
.as_array()
.unwrap()
.iter()
.find(|c| c["name"] == "rfc worked example")
.expect("fixture carries the RFC worked example")["trajectory"]
.clone()
}
#[test]
fn schema_version_gate() {
assert!(is_supported_schema_version("ATIF-v1.7"));
assert!(is_supported_schema_version("ATIF-v1.0"));
assert!(is_supported_schema_version("ATIF-v1.99")); assert!(!is_supported_schema_version("ATIF-v2.0"));
assert!(!is_supported_schema_version("v1.7"));
assert!(!is_supported_schema_version(""));
}
#[test]
fn rfc_example_parses_and_round_trips() {
let t = Trajectory::from_value(rfc_example()).unwrap();
assert_eq!(t.schema_version, "ATIF-v1.5"); assert_eq!(t.agent.name, "harbor-agent");
assert_eq!(t.steps.len(), 3);
assert_eq!(t.steps[0].source, StepSource::User);
assert_eq!(t.steps[1].tool_calls.len(), 2);
assert_eq!(
t.steps[1].tool_calls[0].arguments,
json!({"ticker": "GOOGL", "metric": "price"})
);
let m3 = t.steps[2].metrics.as_ref().unwrap();
assert_eq!(m3.completion_token_ids.as_ref().unwrap().len(), 37);
assert_eq!(m3.logprobs.as_ref().unwrap().len(), 44);
assert_eq!(extra_u64(&m3.extra, "reasoning_tokens"), 12);
let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(back, t);
}
#[test]
fn rfc_example_projects_flat_fields() {
let t = Trajectory::from_value(rfc_example()).unwrap();
let transcript = Transcript::from_trajectory(t);
assert!(
transcript
.final_response
.starts_with("As of October 11, 2025")
);
assert_eq!(
transcript.tool_calls,
vec!["financial_search", "financial_search"]
);
assert_eq!(transcript.tool_calls_count, 2);
assert_eq!(transcript.iterations, 2);
assert_eq!(transcript.usage.input_tokens, 1120);
assert_eq!(transcript.usage.output_tokens, 124);
assert_eq!(transcript.usage.cache_read_tokens, 200);
assert!((transcript.usage.cost_usd - 0.00078).abs() < 1e-12);
}
#[test]
fn unknown_fields_and_sources_are_tolerated() {
let json = json!({
"schema_version": "ATIF-v1.42",
"agent": {"name": "a", "version": "1", "future_agent_field": true},
"steps": [
{"step_id": 1, "source": "environment", "message": "hi",
"future_step_field": {"x": 1}},
{"step_id": 2, "source": "agent", "message": "ok"}
],
"brand_new_root_field": [1, 2, 3]
});
let t = Trajectory::from_value(json).unwrap();
assert_eq!(t.steps[0].source, StepSource::Other("environment".into()));
let line = serde_json::to_string(&t).unwrap();
assert!(line.contains(r#""source":"environment""#));
}
#[test]
fn non_v1_schema_version_is_rejected_gracefully() {
let doc = json!({
"schema_version": "ATIF-v2.0",
"agent": {"name": "a", "version": "1"},
"steps": []
});
let err = Trajectory::from_value(doc).unwrap_err();
assert!(err.contains("ATIF-v2.0"), "got: {err}");
assert!(Trajectory::from_json("{not json").is_err());
assert!(Trajectory::from_json(r#"{"schema_version": 7}"#).is_err());
}
#[test]
fn extra_maps_are_preserved() {
let mut t = Trajectory::new(Agent::new("a", "1"));
t.extra.insert("harness".into(), json!({"run": 3}));
let mut step = Step::new(1, StepSource::Agent, "done");
step.extra.insert("note".into(), json!("custom"));
t.steps.push(step);
let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(back.extra["harness"]["run"], json!(3));
assert_eq!(back.steps[0].extra["note"], json!("custom"));
assert_eq!(back, t);
}
#[test]
fn multimodal_message_projects_text_parts() {
let content = StepContent::Parts(vec![
ContentPart::text("a cat"),
ContentPart::image("image/png", "images/cat.png"),
ContentPart::text("on a mat"),
]);
assert_eq!(content.text(), "a cat\non a mat");
let s: StepContent = serde_json::from_str(r#""plain""#).unwrap();
assert_eq!(s, StepContent::Text("plain".into()));
let p: StepContent = serde_json::from_str(r#"[{"type": "text", "text": "hi"}]"#).unwrap();
assert_eq!(p, StepContent::Parts(vec![ContentPart::text("hi")]));
}
#[test]
fn usage_sums_step_metrics_when_no_final_metrics() {
let mut t = Trajectory::new(Agent::new("a", "1"));
let mut s1 = Step::new(1, StepSource::Agent, "one");
s1.metrics = Some(StepMetrics {
prompt_tokens: Some(100),
completion_tokens: Some(20),
cached_tokens: Some(10),
cost_usd: Some(0.001),
extra: Metadata::from([("reasoning_tokens".into(), json!(5))]),
..Default::default()
});
let mut s2 = Step::new(2, StepSource::Agent, "two");
s2.metrics = Some(StepMetrics {
prompt_tokens: Some(200),
completion_tokens: Some(30),
cost_usd: Some(0.002),
..Default::default()
});
t.steps = vec![s1, s2];
let usage = t.usage();
assert_eq!(usage.input_tokens, 300);
assert_eq!(usage.output_tokens, 50);
assert_eq!(usage.cache_read_tokens, 10);
assert_eq!(usage.reasoning_tokens, 5);
assert!((usage.cost_usd - 0.003).abs() < 1e-12);
}
#[test]
fn iterations_exclude_deterministic_dispatch_steps() {
let mut t = Trajectory::new(Agent::new("a", "1"));
t.steps = vec![
Step::new(1, StepSource::User, "go"),
Step::new(2, StepSource::Agent, "inferring"), {
let mut s = Step::new(3, StepSource::Agent, "");
s.llm_call_count = Some(0); s
},
{
let mut s = Step::new(4, StepSource::Agent, "done");
s.llm_call_count = Some(2); s
},
];
assert_eq!(t.agent_iterations(), 2);
}
#[test]
fn project_into_fills_defaults_but_never_overwrites() {
let mut t = Trajectory::new(Agent::new("a", "1"));
let mut step = Step::new(1, StepSource::Agent, "derived response");
step.tool_calls = vec![ToolCall::new("c1", "grep", json!({"q": "x"}))];
t.steps.push(step);
let mut fresh = Transcript::default();
t.project_into(&mut fresh);
assert_eq!(fresh.final_response, "derived response");
assert_eq!(fresh.tool_calls, vec!["grep"]);
assert_eq!(fresh.tool_calls_count, 1);
assert_eq!(fresh.iterations, 1);
let mut set = Transcript::response("explicit answer");
set.iterations = 7;
set.usage.input_tokens = 9;
t.project_into(&mut set);
assert_eq!(set.final_response, "explicit answer");
assert_eq!(set.iterations, 7);
assert_eq!(set.usage.input_tokens, 9);
assert_eq!(set.tool_calls, vec!["grep"]);
}
#[test]
fn from_trajectory_needs_no_client_calls() {
let mut t = Trajectory::new(Agent::new("a", "1"));
let mut step = Step::new(1, StepSource::Agent, "hi there");
step.tool_calls = vec![ToolCall::new("c1", "search", json!({}))];
t.steps.push(step);
let transcript = Transcript::from_trajectory(t.clone());
assert_eq!(transcript.final_response, "hi there");
assert_eq!(transcript.tool_calls, vec!["search"]);
assert_eq!(transcript.trajectory, Some(t));
assert!(transcript.events.is_empty());
}
#[test]
fn tool_invocations_prefer_trajectory_then_fall_back_to_names() {
let mut t = Trajectory::new(Agent::new("a", "1"));
let mut step = Step::new(1, StepSource::Agent, "");
step.tool_calls = vec![
ToolCall::new("c1", "search", json!({"q": "price"})),
ToolCall::new("c2", "fetch", json!({"url": "u"})),
];
step.observation = Some(Observation {
results: vec![ObservationResult {
source_call_id: Some("c1".into()),
content: Some("$185.35".into()),
..Default::default()
}],
});
t.steps.push(step);
let transcript = Transcript::from_trajectory(t);
let calls = transcript.tool_invocations();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "search");
assert_eq!(calls[0].arguments.unwrap()["q"], "price");
assert_eq!(calls[0].result.unwrap().text(), "$185.35");
assert_eq!(calls[1].name, "fetch");
assert!(calls[1].result.is_none());
let legacy = Transcript {
tool_calls: vec!["read".into(), "calc".into()],
..Default::default()
};
let calls = legacy.tool_invocations();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].name, "read");
assert!(calls[0].arguments.is_none());
assert!(calls[0].result.is_none());
}
#[test]
fn from_transcript_synthesizes_and_reprojects_recoverable_fields() {
let summary = Transcript {
final_response: "the answer is 42".into(),
tool_calls: vec!["search".into(), "calc".into()],
tool_calls_count: 2,
iterations: 3,
usage: Usage {
input_tokens: 100,
output_tokens: 20,
cache_read_tokens: 10,
reasoning_tokens: 5,
cost_usd: 0.001,
},
..Default::default()
};
let traj = Trajectory::from_transcript(&summary);
assert_eq!(traj.schema_version, ATIF_VERSION);
assert_eq!(traj.agent.name, "mira-export");
let back = Trajectory::from_json(&serde_json::to_string(&traj).unwrap()).unwrap();
assert_eq!(back, traj);
let mut reprojected = Transcript::default();
traj.project_into(&mut reprojected);
assert_eq!(reprojected.final_response, "the answer is 42");
assert_eq!(reprojected.tool_calls, vec!["search", "calc"]);
assert_eq!(reprojected.usage, summary.usage); assert_eq!(traj.tool_call_names(), vec!["search", "calc"]);
assert!(
traj.steps
.iter()
.all(|s| s.tool_calls.iter().all(|c| c.arguments == json!({})))
);
}
#[test]
fn from_transcript_handles_empty_summary() {
let traj = Trajectory::from_transcript(&Transcript::response("hi"));
assert_eq!(traj.steps.len(), 1);
assert_eq!(traj.final_agent_text().as_deref(), Some("hi"));
assert!(traj.final_metrics.is_none());
let empty = Trajectory::from_transcript(&Transcript::default());
assert!(empty.steps.is_empty());
assert!(empty.final_agent_text().is_none());
}
#[test]
fn subagent_trajectories_round_trip_but_stay_opaque_to_projections() {
let mut sub = Trajectory::new(Agent::new("sub", "1"));
sub.trajectory_id = Some("sub-1".into());
let mut sub_step = Step::new(1, StepSource::Agent, "sub work");
sub_step.tool_calls = vec![ToolCall::new("s1", "sub_tool", json!({}))];
sub.steps.push(sub_step);
let mut t = Trajectory::new(Agent::new("parent", "1"));
t.steps.push(Step::new(1, StepSource::Agent, "delegated"));
t.subagent_trajectories.push(sub);
let back = Trajectory::from_json(&serde_json::to_string(&t).unwrap()).unwrap();
assert_eq!(back, t);
let transcript = Transcript::from_trajectory(t);
assert!(transcript.tool_calls.is_empty());
assert_eq!(transcript.iterations, 1);
}
}