1use crate::agent::{run_agent_loop, AgentLoopConfig};
26use crate::error::{RavenClawsError, Result};
27use crate::llm::LLMProviderTrait;
28use serde::{Deserialize, Serialize};
29use std::sync::Arc;
30use tracing::{info, instrument, warn};
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct EvalConfig {
37 #[serde(default = "default_suite_name")]
39 pub name: String,
40 #[serde(default)]
42 pub description: String,
43 #[serde(default = "default_system_prompt")]
45 pub system_prompt: String,
46 #[serde(default = "default_max_iterations")]
48 pub max_iterations: usize,
49 #[serde(default)]
51 pub tasks: Vec<EvalTask>,
52}
53
54fn default_suite_name() -> String {
55 "unnamed".to_string()
56}
57
58fn default_system_prompt() -> String {
59 "You are a helpful assistant. Be concise and accurate.".to_string()
60}
61
62fn default_max_iterations() -> usize {
63 5
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct EvalTask {
69 pub name: String,
71 #[serde(default)]
73 pub description: String,
74 pub prompt: String,
76 #[serde(default)]
78 pub golden: String,
79 #[serde(default)]
81 pub assertions: Vec<Assertion>,
82 #[serde(default = "default_weight")]
84 pub weight: f64,
85 #[serde(default)]
87 pub required: bool,
88}
89
90fn default_weight() -> f64 {
91 1.0
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96#[serde(tag = "type", content = "value")]
97pub enum Assertion {
98 #[serde(rename = "contains")]
100 Contains(String),
101 #[serde(rename = "not_contains")]
103 NotContains(String),
104 #[serde(rename = "exact")]
106 Exact(String),
107 #[serde(rename = "regex")]
109 Regex(String),
110 #[serde(rename = "non_empty")]
112 NonEmpty,
113 #[serde(rename = "min_length")]
115 MinLength(usize),
116 #[serde(rename = "max_length")]
118 MaxLength(usize),
119 #[serde(rename = "tool_called")]
121 ToolCalled(String),
122 #[serde(rename = "tool_not_called")]
124 ToolNotCalled(String),
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct RunTrace {
132 pub task_name: String,
134 pub started_at: String,
136 pub ended_at: String,
138 pub duration_ms: u64,
140 pub iterations: usize,
142 pub steps: Vec<TraceStep>,
144 pub llm_calls: Vec<LlmCallTrace>,
146 pub tool_calls: Vec<ToolCallTrace>,
148 pub final_response: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
154pub struct TraceStep {
155 pub number: usize,
157 pub step_type: StepType,
159 pub content: String,
161 pub duration_ms: u64,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
167pub enum StepType {
168 Thought,
170 ToolCall,
172 Observation,
174 Final,
176 Error,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
182pub struct LlmCallTrace {
183 pub iteration: usize,
185 pub provider: String,
187 pub model: String,
189 pub prompt_tokens: Option<u32>,
191 pub completion_tokens: Option<u32>,
193 pub duration_ms: u64,
195 pub response_preview: String,
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct ToolCallTrace {
202 pub iteration: usize,
204 pub tool_name: String,
206 pub arguments: serde_json::Value,
208 pub success: bool,
210 pub output_preview: String,
212 pub duration_ms: u64,
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
220pub struct EvalResult {
221 pub task_name: String,
223 pub passed: bool,
225 pub score: f64,
227 pub assertions_passed: usize,
229 pub assertions_failed: usize,
231 pub assertion_results: Vec<AssertionResult>,
233 pub trace: RunTrace,
235 pub error: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
241pub struct AssertionResult {
242 pub assertion: String,
244 pub passed: bool,
246 pub details: String,
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct EvalReport {
253 pub suite_name: String,
255 pub ran_at: String,
257 pub duration_ms: u64,
259 pub overall_score: f64,
261 pub total_tasks: usize,
263 pub passed_tasks: usize,
265 pub failed_tasks: usize,
267 pub results: Vec<EvalResult>,
269}
270
271pub struct EvalRunner {
275 llm: Arc<dyn LLMProviderTrait>,
277 config: EvalConfig,
279}
280
281impl EvalRunner {
282 pub fn new(llm: Arc<dyn LLMProviderTrait>, config: EvalConfig) -> Self {
284 Self { llm, config }
285 }
286
287 #[instrument(skip(self), fields(suite = %self.config.name, task_count = self.config.tasks.len()))]
289 pub async fn run_suite(&self) -> EvalReport {
290 let started_at = chrono::Utc::now().to_rfc3339();
291 let suite_start = std::time::Instant::now();
292 let mut results = Vec::with_capacity(self.config.tasks.len());
293
294 info!(
295 suite = %self.config.name,
296 task_count = self.config.tasks.len(),
297 "Starting eval suite"
298 );
299
300 for task in &self.config.tasks {
301 let result = self.run_task(task).await;
302 let passed = result.passed;
303 let name = &result.task_name;
304
305 if passed {
306 info!(task = %name, score = result.score, "Eval task passed");
307 } else {
308 warn!(
309 task = %name,
310 score = result.score,
311 passed = result.assertions_passed,
312 failed = result.assertions_failed,
313 "Eval task failed"
314 );
315 }
316
317 results.push(result);
318 }
319
320 let duration_ms = suite_start.elapsed().as_millis() as u64;
321 let total_tasks = results.len();
322 let passed_tasks = results.iter().filter(|r| r.passed).count();
323 let failed_tasks = total_tasks - passed_tasks;
324 let overall_score = if total_tasks > 0 {
325 results
326 .iter()
327 .map(|r| r.score * r.trace.iterations as f64)
328 .sum::<f64>()
329 / results
330 .iter()
331 .map(|r| r.trace.iterations as f64)
332 .sum::<f64>()
333 } else {
334 0.0
335 };
336
337 info!(
338 suite = %self.config.name,
339 passed = passed_tasks,
340 failed = failed_tasks,
341 overall_score = overall_score,
342 duration_ms = duration_ms,
343 "Eval suite completed"
344 );
345
346 EvalReport {
347 suite_name: self.config.name.clone(),
348 ran_at: started_at,
349 duration_ms,
350 overall_score,
351 total_tasks,
352 passed_tasks,
353 failed_tasks,
354 results,
355 }
356 }
357
358 #[instrument(skip(self), fields(task = %task.name))]
364 async fn run_task(&self, task: &EvalTask) -> EvalResult {
365 let task_start = std::time::Instant::now();
366 let started_at = chrono::Utc::now().to_rfc3339();
367
368 let agent_config = AgentLoopConfig {
370 max_iterations: self.config.max_iterations,
371 enable_tools: true,
372 require_approval: false,
373 prompt_injection_protection: true,
374 token_lifetime_secs: 0,
375 no_final_required: false,
376 fallback_chain: None,
377 token_budget: None,
378 ravenfabric: None,
379 checkpoint_dir: None,
380 session_id: None,
381 metrics_callback: None,
382 load_manager: None,
383 retry_config: None,
384 healing_engine: None,
385 };
386
387 let result = run_agent_loop(
389 self.llm.clone(),
390 &task.prompt,
391 &self.config.system_prompt,
392 agent_config,
393 )
394 .await;
395
396 let duration_ms = task_start.elapsed().as_millis() as u64;
397
398 match result {
399 Ok(final_response) => {
400 let trace = RunTrace {
401 task_name: task.name.clone(),
402 started_at,
403 ended_at: chrono::Utc::now().to_rfc3339(),
404 duration_ms,
405 iterations: self.config.max_iterations, steps: vec![TraceStep {
407 number: 0,
408 step_type: StepType::Final,
409 content: final_response.clone(),
410 duration_ms,
411 }],
412 llm_calls: Vec::new(), tool_calls: Vec::new(), final_response: final_response.clone(),
415 };
416
417 let (assertion_results, assertions_passed, assertions_failed) =
419 check_assertions(&final_response, &task.assertions, Some(&trace));
420
421 let score = if task.assertions.is_empty() {
423 if final_response.is_empty() || final_response.len() < 10 {
424 0.0
425 } else {
426 1.0
427 }
428 } else if task.assertions.len() == assertions_passed + assertions_failed {
429 assertions_passed as f64 / task.assertions.len() as f64
430 } else {
431 0.0
432 };
433
434 let passed = assertions_failed == 0 && !final_response.is_empty();
435
436 EvalResult {
437 task_name: task.name.clone(),
438 passed,
439 score,
440 assertions_passed,
441 assertions_failed,
442 assertion_results,
443 trace,
444 error: None,
445 }
446 }
447 Err(e) => {
448 let trace = RunTrace {
449 task_name: task.name.clone(),
450 started_at,
451 ended_at: chrono::Utc::now().to_rfc3339(),
452 duration_ms,
453 iterations: 0,
454 steps: vec![TraceStep {
455 number: 0,
456 step_type: StepType::Error,
457 content: format!("Agent loop failed: {}", e),
458 duration_ms,
459 }],
460 llm_calls: Vec::new(),
461 tool_calls: Vec::new(),
462 final_response: String::new(),
463 };
464
465 EvalResult {
466 task_name: task.name.clone(),
467 passed: false,
468 score: 0.0,
469 assertions_passed: 0,
470 assertions_failed: 1,
471 assertion_results: vec![AssertionResult {
472 assertion: "agent_loop".to_string(),
473 passed: false,
474 details: format!("Agent loop failed: {}", e),
475 }],
476 trace,
477 error: Some(e.to_string()),
478 }
479 }
480 }
481 }
482}
483
484fn check_assertions(
488 response: &str,
489 assertions: &[Assertion],
490 run_trace: Option<&RunTrace>,
491) -> (Vec<AssertionResult>, usize, usize) {
492 let mut results = Vec::with_capacity(assertions.len());
493 let mut passed = 0;
494 let mut failed = 0;
495
496 for assertion in assertions {
497 let result = check_single_assertion(response, assertion, run_trace);
498 if result.passed {
499 passed += 1;
500 } else {
501 failed += 1;
502 }
503 results.push(result);
504 }
505
506 (results, passed, failed)
507}
508
509fn check_single_assertion(
511 response: &str,
512 assertion: &Assertion,
513 run_trace: Option<&RunTrace>,
514) -> AssertionResult {
515 match assertion {
516 Assertion::Contains(pattern) => {
517 let passed = response.contains(pattern);
518 AssertionResult {
519 assertion: format!("contains: {}", pattern),
520 passed,
521 details: if passed {
522 format!("Response contains '{}'", pattern)
523 } else {
524 format!("Response does not contain '{}'", pattern)
525 },
526 }
527 }
528 Assertion::NotContains(pattern) => {
529 let passed = !response.contains(pattern);
530 AssertionResult {
531 assertion: format!("not_contains: {}", pattern),
532 passed,
533 details: if passed {
534 format!("Response does not contain '{}'", pattern)
535 } else {
536 format!("Response contains '{}'", pattern)
537 },
538 }
539 }
540 Assertion::Exact(expected) => {
541 let trimmed_response = response.trim();
542 let passed = trimmed_response == expected.as_str();
543 AssertionResult {
544 assertion: format!("exact: {}", expected),
545 passed,
546 details: if passed {
547 "Response matches exactly".to_string()
548 } else {
549 format!(
550 "Expected '{}', got '{}'",
551 expected,
552 trimmed_response.chars().take(100).collect::<String>()
553 )
554 },
555 }
556 }
557 Assertion::Regex(pattern) => {
558 let re = regex_lite::Regex::new(pattern);
559 match re {
560 Ok(re) => {
561 let passed = re.is_match(response);
562 AssertionResult {
563 assertion: format!("regex: {}", pattern),
564 passed,
565 details: if passed {
566 format!("Response matches pattern '{}'", pattern)
567 } else {
568 format!("Response does not match pattern '{}'", pattern)
569 },
570 }
571 }
572 Err(e) => AssertionResult {
573 assertion: format!("regex: {}", pattern),
574 passed: false,
575 details: format!("Invalid regex pattern: {}", e),
576 },
577 }
578 }
579 Assertion::NonEmpty => {
580 let passed = !response.is_empty();
581 AssertionResult {
582 assertion: "non_empty".to_string(),
583 passed,
584 details: if passed {
585 format!("Response is non-empty ({} chars)", response.len())
586 } else {
587 "Response is empty".to_string()
588 },
589 }
590 }
591 Assertion::MinLength(min) => {
592 let passed = response.len() >= *min;
593 AssertionResult {
594 assertion: format!("min_length: {}", min),
595 passed,
596 details: if passed {
597 format!("Response length {} >= {}", response.len(), min)
598 } else {
599 format!("Response length {} < {}", response.len(), min)
600 },
601 }
602 }
603 Assertion::MaxLength(max) => {
604 let passed = response.len() <= *max;
605 AssertionResult {
606 assertion: format!("max_length: {}", max),
607 passed,
608 details: if passed {
609 format!("Response length {} <= {}", response.len(), max)
610 } else {
611 format!("Response length {} > {}", response.len(), max)
612 },
613 }
614 }
615 Assertion::ToolCalled(tool_name) => {
616 let tool_calls = run_trace
617 .map(|t| &t.tool_calls)
618 .filter(|calls| calls.iter().any(|tc| tc.tool_name == *tool_name));
619 let passed = tool_calls.is_some();
620 AssertionResult {
621 assertion: format!("tool_called: {}", tool_name),
622 passed,
623 details: if passed {
624 format!("Tool '{}' was called", tool_name)
625 } else {
626 let all_tools: Vec<&str> = run_trace
627 .map(|t| {
628 t.tool_calls
629 .iter()
630 .map(|tc| tc.tool_name.as_str())
631 .collect()
632 })
633 .unwrap_or_default();
634 if all_tools.is_empty() {
635 format!("Tool '{}' was not called (no tools were called)", tool_name)
636 } else {
637 format!(
638 "Tool '{}' was not called (called: {})",
639 tool_name,
640 all_tools.join(", ")
641 )
642 }
643 },
644 }
645 }
646 Assertion::ToolNotCalled(tool_name) => {
647 let tool_calls = run_trace
648 .map(|t| &t.tool_calls)
649 .filter(|calls| calls.iter().any(|tc| tc.tool_name == *tool_name));
650 let passed = tool_calls.is_none();
651 AssertionResult {
652 assertion: format!("tool_not_called: {}", tool_name),
653 passed,
654 details: if passed {
655 format!("Tool '{}' was not called", tool_name)
656 } else {
657 format!("Tool '{}' was called but should not have been", tool_name)
658 },
659 }
660 }
661 }
662}
663
664impl EvalReport {
667 pub fn format_text(&self) -> String {
669 let mut output = String::new();
670
671 output.push_str(&format!("\n🐦⬛ Eval Report: {}\n", self.suite_name));
672 output.push_str(&format!("{:-^60}\n", ""));
673 output.push_str(&format!(
674 "Ran at: {}\n",
675 self.ran_at[..19].replace('T', " ")
676 ));
677 output.push_str(&format!("Duration: {} ms\n", self.duration_ms));
678 output.push_str(&format!(
679 "Overall score: {:.1}%\n",
680 self.overall_score * 100.0
681 ));
682 output.push_str(&format!(
683 "Tasks: {}/{} passed\n",
684 self.passed_tasks, self.total_tasks
685 ));
686 output.push_str(&format!("{:-^60}\n", ""));
687
688 for result in &self.results {
689 output.push_str(&format!(
690 "\n {} {} — {:.1}%\n",
691 if result.passed { "✅" } else { "❌" },
692 result.task_name,
693 result.score * 100.0
694 ));
695
696 if let Some(ref error) = result.error {
697 output.push_str(&format!(" Error: {}\n", error));
698 }
699
700 if !result.assertion_results.is_empty() {
701 for ar in &result.assertion_results {
702 output.push_str(&format!(
703 " {} {}\n",
704 if ar.passed { " ✅" } else { " ❌" },
705 ar.details
706 ));
707 }
708 }
709
710 let trace = &result.trace;
712 output.push_str(&format!(
713 " Iterations: {} · LLM calls: {} · Tool calls: {} · Duration: {} ms\n",
714 trace.iterations,
715 trace.llm_calls.len(),
716 trace.tool_calls.len(),
717 trace.duration_ms
718 ));
719
720 let preview: String = trace.final_response.chars().take(200).collect();
722 if !preview.is_empty() {
723 output.push_str(&format!(" Response: {}\n", preview));
724 }
725 }
726
727 output
728 }
729
730 pub fn format_json(&self) -> serde_json::Value {
732 serde_json::to_value(self).unwrap_or(serde_json::json!({"error": "serialization failed"}))
733 }
734}
735
736impl EvalConfig {
739 pub fn from_file(path: &str) -> Result<Self> {
741 let content = std::fs::read_to_string(path).map_err(|e| {
742 RavenClawsError::CommandExecution(format!("Failed to read eval config: {}", e))
743 })?;
744
745 if content.trim().is_empty() {
746 return Err(RavenClawsError::CommandExecution(format!(
747 "Eval config file '{}' is empty — no tasks to run",
748 path
749 )));
750 }
751
752 let config: EvalConfig = toml::from_str(&content).map_err(|e| {
753 RavenClawsError::CommandExecution(format!("Failed to parse eval config: {}", e))
754 })?;
755
756 if config.tasks.is_empty() {
757 return Err(RavenClawsError::CommandExecution(format!(
758 "Eval config file '{}' has no tasks defined",
759 path
760 )));
761 }
762
763 Ok(config)
764 }
765}
766
767#[cfg(test)]
770mod tests {
771 use super::*;
772
773 #[test]
774 fn test_assertion_contains_pass() {
775 let result = check_single_assertion(
776 "hello world",
777 &Assertion::Contains("world".to_string()),
778 None,
779 );
780 assert!(result.passed);
781 assert!(result.details.contains("contains"));
782 }
783
784 #[test]
785 fn test_assertion_contains_fail() {
786 let result =
787 check_single_assertion("hello world", &Assertion::Contains("foo".to_string()), None);
788 assert!(!result.passed);
789 }
790
791 #[test]
792 fn test_assertion_not_contains_pass() {
793 let result = check_single_assertion(
794 "hello world",
795 &Assertion::NotContains("foo".to_string()),
796 None,
797 );
798 assert!(result.passed);
799 }
800
801 #[test]
802 fn test_assertion_not_contains_fail() {
803 let result = check_single_assertion(
804 "hello world",
805 &Assertion::NotContains("world".to_string()),
806 None,
807 );
808 assert!(!result.passed);
809 }
810
811 #[test]
812 fn test_assertion_exact_pass() {
813 let result = check_single_assertion("hello", &Assertion::Exact("hello".to_string()), None);
814 assert!(result.passed);
815 }
816
817 #[test]
818 fn test_assertion_exact_fail() {
819 let result = check_single_assertion("world", &Assertion::Exact("hello".to_string()), None);
820 assert!(!result.passed);
821 }
822
823 #[test]
824 fn test_assertion_regex_pass() {
825 let result =
826 check_single_assertion("hello 123", &Assertion::Regex(r"\d+".to_string()), None);
827 assert!(result.passed);
828 }
829
830 #[test]
831 fn test_assertion_regex_fail() {
832 let result = check_single_assertion("hello", &Assertion::Regex(r"\d+".to_string()), None);
833 assert!(!result.passed);
834 }
835
836 #[test]
837 fn test_assertion_non_empty_pass() {
838 let result = check_single_assertion("hello", &Assertion::NonEmpty, None);
839 assert!(result.passed);
840 }
841
842 #[test]
843 fn test_assertion_non_empty_fail() {
844 let result = check_single_assertion("", &Assertion::NonEmpty, None);
845 assert!(!result.passed);
846 }
847
848 #[test]
849 fn test_assertion_min_length_pass() {
850 let result = check_single_assertion("hello", &Assertion::MinLength(3), None);
851 assert!(result.passed);
852 }
853
854 #[test]
855 fn test_assertion_min_length_fail() {
856 let result = check_single_assertion("hi", &Assertion::MinLength(5), None);
857 assert!(!result.passed);
858 }
859
860 #[test]
861 fn test_assertion_max_length_pass() {
862 let result = check_single_assertion("hi", &Assertion::MaxLength(5), None);
863 assert!(result.passed);
864 }
865
866 #[test]
867 fn test_assertion_max_length_fail() {
868 let result = check_single_assertion("hello world", &Assertion::MaxLength(5), None);
869 assert!(!result.passed);
870 }
871
872 #[test]
873 fn test_check_assertions_empty() {
874 let (results, passed, failed) = check_assertions("hello", &[], None);
875 assert!(results.is_empty());
876 assert_eq!(passed, 0);
877 assert_eq!(failed, 0);
878 }
879
880 #[test]
881 fn test_check_assertions_multiple() {
882 let assertions = vec![
883 Assertion::Contains("hello".to_string()),
884 Assertion::Contains("world".to_string()),
885 Assertion::NonEmpty,
886 ];
887 let (results, passed, failed) = check_assertions("hello world", &assertions, None);
888 assert_eq!(passed, 3);
889 assert_eq!(failed, 0);
890 assert_eq!(results.len(), 3);
891 }
892
893 #[test]
894 fn test_check_assertions_tool_called() {
895 let trace = RunTrace {
896 task_name: "test".to_string(),
897 started_at: "2026-01-01T00:00:00Z".to_string(),
898 ended_at: "2026-01-01T00:00:01Z".to_string(),
899 duration_ms: 1000,
900 iterations: 1,
901 steps: vec![],
902 llm_calls: vec![],
903 tool_calls: vec![
904 ToolCallTrace {
905 iteration: 0,
906 tool_name: "web_search".to_string(),
907 arguments: serde_json::json!({"query": "test"}),
908 success: true,
909 output_preview: "results".to_string(),
910 duration_ms: 100,
911 },
912 ToolCallTrace {
913 iteration: 0,
914 tool_name: "read_file".to_string(),
915 arguments: serde_json::json!({"path": "/tmp/test"}),
916 success: true,
917 output_preview: "content".to_string(),
918 duration_ms: 50,
919 },
920 ],
921 final_response: "response".to_string(),
922 };
923
924 let (results, passed, failed) = check_assertions(
926 "response",
927 &[Assertion::ToolCalled("web_search".to_string())],
928 Some(&trace),
929 );
930 assert_eq!(passed, 1);
931 assert_eq!(failed, 0);
932 assert!(results[0].passed);
933
934 let (results, passed, failed) = check_assertions(
936 "response",
937 &[Assertion::ToolCalled("nonexistent".to_string())],
938 Some(&trace),
939 );
940 assert_eq!(passed, 0);
941 assert_eq!(failed, 1);
942 assert!(!results[0].passed);
943
944 let (results, passed, failed) = check_assertions(
946 "response",
947 &[Assertion::ToolNotCalled("nonexistent".to_string())],
948 Some(&trace),
949 );
950 assert_eq!(passed, 1);
951 assert_eq!(failed, 0);
952 assert!(results[0].passed);
953
954 let (results, passed, failed) = check_assertions(
956 "response",
957 &[Assertion::ToolNotCalled("web_search".to_string())],
958 Some(&trace),
959 );
960 assert_eq!(passed, 0);
961 assert_eq!(failed, 1);
962 assert!(!results[0].passed);
963
964 let (results, passed, failed) = check_assertions(
966 "response",
967 &[Assertion::ToolCalled("web_search".to_string())],
968 None,
969 );
970 assert_eq!(passed, 0);
971 assert_eq!(failed, 1);
972 assert!(!results[0].passed);
973 }
974
975 #[test]
976 fn test_eval_config_from_toml() {
977 let toml_str = r#"
978name = "test-suite"
979description = "A test suite"
980system_prompt = "Be concise"
981max_iterations = 3
982
983[[tasks]]
984name = "test-1"
985prompt = "What is 2+2?"
986golden = "4"
987assertions = [{ type = "contains", value = "4" }]
988weight = 1.0
989required = true
990"#;
991
992 let config: EvalConfig = toml::from_str(toml_str).unwrap();
993 assert_eq!(config.name, "test-suite");
994 assert_eq!(config.tasks.len(), 1);
995 assert_eq!(config.tasks[0].name, "test-1");
996 assert_eq!(config.tasks[0].prompt, "What is 2+2?");
997 assert_eq!(config.tasks[0].golden, "4");
998 assert_eq!(config.tasks[0].assertions.len(), 1);
999 }
1000
1001 #[test]
1002 fn test_eval_config_defaults() {
1003 let toml_str = r#"
1004[[tasks]]
1005name = "simple"
1006prompt = "Say hello"
1007"#;
1008
1009 let config: EvalConfig = toml::from_str(toml_str).unwrap();
1010 assert_eq!(config.name, "unnamed");
1011 assert_eq!(config.system_prompt, default_system_prompt());
1012 assert_eq!(config.max_iterations, 5);
1013 assert_eq!(config.tasks[0].weight, 1.0);
1014 assert!(!config.tasks[0].required);
1015 }
1016
1017 #[test]
1018 fn test_report_format_text() {
1019 let report = EvalReport {
1020 suite_name: "test".to_string(),
1021 ran_at: "2026-06-22T12:00:00+00:00".to_string(),
1022 duration_ms: 100,
1023 overall_score: 0.75,
1024 total_tasks: 2,
1025 passed_tasks: 1,
1026 failed_tasks: 1,
1027 results: vec![
1028 EvalResult {
1029 task_name: "pass-task".to_string(),
1030 passed: true,
1031 score: 1.0,
1032 assertions_passed: 2,
1033 assertions_failed: 0,
1034 assertion_results: vec![AssertionResult {
1035 assertion: "contains: hello".to_string(),
1036 passed: true,
1037 details: "Response contains 'hello'".to_string(),
1038 }],
1039 trace: RunTrace {
1040 task_name: "pass-task".to_string(),
1041 started_at: "2026-06-22T12:00:00+00:00".to_string(),
1042 ended_at: "2026-06-22T12:00:01+00:00".to_string(),
1043 duration_ms: 50,
1044 iterations: 1,
1045 steps: vec![],
1046 llm_calls: vec![],
1047 tool_calls: vec![],
1048 final_response: "hello world".to_string(),
1049 },
1050 error: None,
1051 },
1052 EvalResult {
1053 task_name: "fail-task".to_string(),
1054 passed: false,
1055 score: 0.0,
1056 assertions_passed: 0,
1057 assertions_failed: 1,
1058 assertion_results: vec![AssertionResult {
1059 assertion: "contains: foo".to_string(),
1060 passed: false,
1061 details: "Response does not contain 'foo'".to_string(),
1062 }],
1063 trace: RunTrace {
1064 task_name: "fail-task".to_string(),
1065 started_at: "2026-06-22T12:00:01+00:00".to_string(),
1066 ended_at: "2026-06-22T12:00:02+00:00".to_string(),
1067 duration_ms: 50,
1068 iterations: 1,
1069 steps: vec![],
1070 llm_calls: vec![],
1071 tool_calls: vec![],
1072 final_response: "bar".to_string(),
1073 },
1074 error: None,
1075 },
1076 ],
1077 };
1078
1079 let text = report.format_text();
1080 assert!(text.contains("Eval Report: test"));
1081 assert!(text.contains("75.0%"));
1082 assert!(text.contains("1/2 passed"));
1083 assert!(text.contains("✅ pass-task"));
1084 assert!(text.contains("❌ fail-task"));
1085 }
1086
1087 #[test]
1088 fn test_report_format_json() {
1089 let report = EvalReport {
1090 suite_name: "test".to_string(),
1091 ran_at: "2026-06-22T12:00:00+00:00".to_string(),
1092 duration_ms: 100,
1093 overall_score: 1.0,
1094 total_tasks: 1,
1095 passed_tasks: 1,
1096 failed_tasks: 0,
1097 results: vec![],
1098 };
1099
1100 let json = report.format_json();
1101 assert_eq!(json["suite_name"], "test");
1102 assert_eq!(json["overall_score"], 1.0);
1103 }
1104
1105 #[test]
1106 fn test_eval_config_from_file_not_found() {
1107 let result = EvalConfig::from_file("/tmp/nonexistent-eval-config.toml");
1108 assert!(result.is_err());
1109 }
1110
1111 #[test]
1112 fn test_assertion_regex_invalid_pattern() {
1113 let result =
1114 check_single_assertion("hello", &Assertion::Regex(r"[invalid".to_string()), None);
1115 assert!(!result.passed);
1116 assert!(result.details.contains("Invalid regex"));
1117 }
1118
1119 #[test]
1120 fn test_trace_step_serialization() {
1121 let step = TraceStep {
1122 number: 0,
1123 step_type: StepType::Thought,
1124 content: "test".to_string(),
1125 duration_ms: 100,
1126 };
1127 let json = serde_json::to_string(&step).unwrap();
1128 assert!(json.contains("Thought"));
1129 }
1130
1131 #[test]
1132 fn test_tool_call_trace_serialization() {
1133 let trace = ToolCallTrace {
1134 iteration: 0,
1135 tool_name: "shell_exec".to_string(),
1136 arguments: serde_json::json!({"command": "echo hello"}),
1137 success: true,
1138 output_preview: "hello".to_string(),
1139 duration_ms: 50,
1140 };
1141 let json = serde_json::to_string(&trace).unwrap();
1142 assert!(json.contains("shell_exec"));
1143 assert!(json.contains("echo hello"));
1144 }
1145}