use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmTrace {
pub model_name: String,
pub turns: Vec<TraceTurn>,
#[serde(default)]
pub expects: TraceExpects,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceTurn {
pub user_input: String,
pub steps: Vec<TraceStep>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceStep {
pub response: TraceResponse,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum TraceResponse {
#[serde(rename = "text")]
Text {
content: String,
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
},
#[serde(rename = "tool_calls")]
ToolCalls {
tool_calls: Vec<TraceToolCall>,
#[serde(default)]
input_tokens: u64,
#[serde(default)]
output_tokens: u64,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct TraceExpects {
#[serde(default)]
pub response_contains: Vec<String>,
#[serde(default)]
pub response_not_contains: Vec<String>,
#[serde(default)]
pub tools_used: Vec<String>,
#[serde(default)]
pub tools_not_used: Vec<String>,
#[serde(default)]
pub max_tool_calls: Option<usize>,
#[serde(default)]
pub all_tools_succeeded: Option<bool>,
#[serde(default)]
pub response_matches: Vec<String>,
}
impl LlmTrace {
pub fn from_file(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("reading trace fixture {}", path.display()))?;
let trace: LlmTrace = serde_json::from_str(&content)
.with_context(|| format!("parsing trace fixture {}", path.display()))?;
Ok(trace)
}
}
pub fn load_suite(dir: &Path) -> anyhow::Result<Vec<(PathBuf, LlmTrace)>> {
let read = std::fs::read_dir(dir)
.with_context(|| format!("reading eval suite directory {}", dir.display()))?;
let mut paths: Vec<PathBuf> = read
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("json"))
.collect();
paths.sort();
let mut out = Vec::with_capacity(paths.len());
for path in paths {
let trace = LlmTrace::from_file(&path)?;
out.push((path, trace));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trace_response_text_variant_defaults_tokens_to_zero() {
let r: TraceResponse = serde_json::from_str(r#"{"type":"text","content":"hi"}"#).unwrap();
match r {
TraceResponse::Text {
content,
input_tokens,
output_tokens,
} => {
assert_eq!(content, "hi");
assert_eq!(input_tokens, 0);
assert_eq!(output_tokens, 0);
}
_ => panic!("expected Text variant"),
}
}
#[test]
fn trace_response_tool_calls_variant_parses() {
let j = r#"{"type":"tool_calls","tool_calls":[{"id":"1","name":"search","arguments":{"q":"x"}}],"input_tokens":5}"#;
let r: TraceResponse = serde_json::from_str(j).unwrap();
match r {
TraceResponse::ToolCalls {
tool_calls,
input_tokens,
output_tokens,
} => {
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].id, "1");
assert_eq!(tool_calls[0].name, "search");
assert_eq!(input_tokens, 5);
assert_eq!(output_tokens, 0);
}
_ => panic!("expected ToolCalls variant"),
}
}
#[test]
fn llm_trace_uses_default_expects_when_omitted() {
let t: LlmTrace = serde_json::from_str(r#"{"model_name":"m","turns":[]}"#).unwrap();
assert_eq!(t.model_name, "m");
assert!(t.turns.is_empty());
assert!(t.expects.response_contains.is_empty());
assert!(t.expects.max_tool_calls.is_none());
}
#[test]
fn from_file_reads_and_parses_trace() {
let path = std::env::temp_dir().join("zeroclaw_eval_case_from_file_test.json");
std::fs::write(&path, r#"{"model_name":"demo","turns":[]}"#).unwrap();
let t = LlmTrace::from_file(&path).unwrap();
assert_eq!(t.model_name, "demo");
let _ = std::fs::remove_file(&path);
}
#[test]
fn load_suite_filters_json_and_sorts_by_path() {
let dir = std::env::temp_dir().join("zeroclaw_eval_case_suite_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("b.json"), r#"{"model_name":"b","turns":[]}"#).unwrap();
std::fs::write(dir.join("a.json"), r#"{"model_name":"a","turns":[]}"#).unwrap();
std::fs::write(dir.join("note.txt"), "ignored").unwrap();
let suite = load_suite(&dir).unwrap();
assert_eq!(suite.len(), 2); assert_eq!(suite[0].1.model_name, "a"); assert_eq!(suite[1].1.model_name, "b");
let _ = std::fs::remove_dir_all(&dir);
}
}