1use std::ops::ControlFlow;
6
7use crate::config::Config;
8use crate::conversation::{Message, ToolEvent, Transcript};
9use crate::error::Result;
10use crate::eval::{Eval, JudgeValue};
11use crate::provider::{JudgeKind, JudgeQuery, Provider, SkillRef, Usage};
12use crate::report::{CaseRun, Report};
13use crate::skill::{load_skill, SkillDefinition};
14use crate::testcase::TestCase;
15
16pub struct StreamEvent<'a> {
20 pub case: &'a str,
22 pub platform: &'a str,
24 pub model: &'a str,
26 pub turn: usize,
28 pub event: &'a ToolEvent,
30}
31
32struct Streaming<'s> {
35 on: bool,
36 sink: &'s mut (dyn FnMut(&StreamEvent) -> ControlFlow<()> + 's),
37}
38
39pub struct Runner<'a> {
41 provider: &'a dyn Provider,
42 config: &'a Config,
43}
44
45impl<'a> Runner<'a> {
46 #[must_use]
48 pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
49 Self { provider, config }
50 }
51
52 pub fn run_all(&self, cases: &[TestCase]) -> Result<Report> {
59 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
60 self.run_all_inner(
61 cases,
62 &mut Streaming {
63 on: false,
64 sink: &mut sink,
65 },
66 )
67 }
68
69 pub fn run_all_streaming(
79 &self,
80 cases: &[TestCase],
81 on_event: &mut dyn FnMut(&StreamEvent) -> ControlFlow<()>,
82 ) -> Result<Report> {
83 self.run_all_inner(
84 cases,
85 &mut Streaming {
86 on: true,
87 sink: on_event,
88 },
89 )
90 }
91
92 fn run_all_inner(&self, cases: &[TestCase], streaming: &mut Streaming) -> Result<Report> {
96 let mut runs = Vec::new();
97 for case in cases {
98 let skill = load_skill(&case.skill)?;
99 for platform in &self.config.platforms {
100 for model in &self.config.models {
101 let (run, flow) = self.run_case_on(case, &skill, platform, model, streaming)?;
102 runs.push(run);
103 if flow.is_break() {
104 return Ok(Report::new(runs));
105 }
106 }
107 }
108 }
109 Ok(Report::new(runs))
110 }
111
112 pub fn run_case(&self, case: &TestCase) -> Result<Vec<CaseRun>> {
117 let skill = load_skill(&case.skill)?;
118 let mut runs = Vec::new();
119 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
120 let mut streaming = Streaming {
121 on: false,
122 sink: &mut sink,
123 };
124 for platform in &self.config.platforms {
125 for model in &self.config.models {
126 let (run, _flow) =
127 self.run_case_on(case, &skill, platform, model, &mut streaming)?;
128 runs.push(run);
129 }
130 }
131 Ok(runs)
132 }
133
134 fn run_case_on(
137 &self,
138 case: &TestCase,
139 skill: &SkillDefinition,
140 platform: &str,
141 model: &str,
142 streaming: &mut Streaming,
143 ) -> Result<(CaseRun, ControlFlow<()>)> {
144 let mut totals = Usage::default();
145 let (transcript, flow) =
146 self.converse(case, skill, platform, model, &mut totals, streaming)?;
147 let evals = if flow.is_break() {
149 Vec::new()
150 } else {
151 self.score(case, &transcript, &mut totals)?
152 };
153 let passed = flow.is_continue() && evals.iter().all(|e| e.passed);
154 Ok((
155 CaseRun {
156 case: case.name.clone(),
157 skill: skill.dir.to_string_lossy().into_owned(),
158 platform: platform.to_string(),
159 model: model.to_string(),
160 passed,
161 turns: transcript.assistant_turns(),
162 evals,
163 transcript,
164 usage: (!totals.is_empty()).then_some(totals),
165 },
166 flow,
167 ))
168 }
169
170 fn converse(
175 &self,
176 case: &TestCase,
177 skill: &SkillDefinition,
178 platform: &str,
179 model: &str,
180 totals: &mut Usage,
181 streaming: &mut Streaming,
182 ) -> Result<(Transcript, ControlFlow<()>)> {
183 let dir = skill.dir.to_string_lossy().into_owned();
184 let skill_ref = SkillRef {
185 name: &skill.name,
186 dir: &dir,
187 instructions: &skill.instructions,
188 };
189 let judge_model = self.config.effective_judge_model();
190 let max_turns = case
191 .user
192 .as_ref()
193 .and_then(|u| u.max_turns)
194 .unwrap_or(self.config.max_turns) as usize;
195 let resume_supported = self.provider.supports_resume(platform);
196
197 let mut transcript = Transcript::from_input(&case.input);
198 let mut session: Option<String> = None;
202
203 loop {
204 let session_arg = if resume_supported {
205 session.as_deref()
206 } else {
207 None
208 };
209 let case_name = case.name.as_str();
215 let turn_index = transcript.assistant_turns() + 1;
216 let mut turn_flow = ControlFlow::Continue(());
217 let turn = if streaming.on {
218 let sink = &mut streaming.sink;
219 self.provider.respond_streaming(
220 platform,
221 model,
222 &skill_ref,
223 &transcript.messages,
224 session_arg,
225 &mut |event| {
226 let flow = sink(&StreamEvent {
227 case: case_name,
228 platform,
229 model,
230 turn: turn_index,
231 event,
232 });
233 if flow.is_break() {
234 turn_flow = ControlFlow::Break(());
235 }
236 flow
237 },
238 )?
239 } else {
240 self.provider.respond(
241 platform,
242 model,
243 &skill_ref,
244 &transcript.messages,
245 session_arg,
246 )?
247 };
248 if let Some(u) = &turn.usage {
249 totals.add(u);
250 }
251 if let Some(id) = turn.session_id {
253 session = Some(id);
254 }
255 let skill_done = turn.done;
256 transcript.push(Message::assistant(turn.message).with_events(turn.events));
259
260 if turn_flow.is_break() {
262 return Ok((transcript, ControlFlow::Break(())));
263 }
264
265 let Some(user) = &case.user else {
267 break;
268 };
269
270 if skill_done || transcript.assistant_turns() >= max_turns {
271 break;
272 }
273
274 if let Some(done_when) = &user.done_when {
276 let query = JudgeQuery {
277 kind: JudgeKind::Boolean,
278 criterion: done_when,
279 scale: None,
280 };
281 let verdict = self
282 .provider
283 .judge(judge_model, &query, &transcript.messages)?;
284 if let Some(u) = &verdict.usage {
285 totals.add(u);
286 }
287 if matches!(verdict.value, JudgeValue::Bool(true)) {
288 break;
289 }
290 }
291
292 let user_turn =
294 self.provider
295 .simulate_user(judge_model, &user.persona, &transcript.messages)?;
296 if let Some(u) = &user_turn.usage {
297 totals.add(u);
298 }
299 let stop = user_turn.stop;
300 transcript.push(Message::user(user_turn.message));
301 if stop {
302 break;
303 }
304 }
305
306 Ok((transcript, ControlFlow::Continue(())))
307 }
308
309 fn score(
311 &self,
312 case: &TestCase,
313 transcript: &Transcript,
314 totals: &mut Usage,
315 ) -> Result<Vec<crate::eval::EvalOutcome>> {
316 let judge_model = self.config.effective_judge_model();
317 let mut outcomes = Vec::with_capacity(case.evals.len());
318 for eval in &case.evals {
319 let query = match eval {
320 Eval::Boolean { criterion, .. } => JudgeQuery {
321 kind: JudgeKind::Boolean,
322 criterion,
323 scale: None,
324 },
325 Eval::Numeric {
326 criterion,
327 min,
328 max,
329 ..
330 } => JudgeQuery {
331 kind: JudgeKind::Numeric,
332 criterion,
333 scale: Some((*min, *max)),
334 },
335 };
336 let verdict = self
337 .provider
338 .judge(judge_model, &query, &transcript.messages)?;
339 if let Some(u) = &verdict.usage {
340 totals.add(u);
341 }
342 outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
343 }
344 Ok(outcomes)
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use crate::conversation::Message;
352 use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
353 use std::cell::RefCell;
354
355 struct ScriptedProvider {
358 assistant: Vec<AssistantTurn>,
359 user: Vec<UserTurn>,
360 judge: Vec<JudgeVerdict>,
361 calls: RefCell<Calls>,
362 }
363
364 #[derive(Default)]
365 struct Calls {
366 assistant: usize,
367 user: usize,
368 judge: usize,
369 }
370
371 impl Provider for ScriptedProvider {
372 fn respond(
373 &self,
374 _platform: &str,
375 _model: &str,
376 _skill: &SkillRef<'_>,
377 _messages: &[Message],
378 _session: Option<&str>,
379 ) -> Result<AssistantTurn> {
380 let i = self.calls.borrow().assistant;
381 self.calls.borrow_mut().assistant += 1;
382 Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
383 }
384
385 fn simulate_user(
386 &self,
387 _model: &str,
388 _persona: &str,
389 _messages: &[Message],
390 ) -> Result<UserTurn> {
391 let i = self.calls.borrow().user;
392 self.calls.borrow_mut().user += 1;
393 Ok(self.user[i.min(self.user.len() - 1)].clone())
394 }
395
396 fn judge(
397 &self,
398 _model: &str,
399 _query: &JudgeQuery<'_>,
400 _messages: &[Message],
401 ) -> Result<JudgeVerdict> {
402 let i = self.calls.borrow().judge;
403 self.calls.borrow_mut().judge += 1;
404 let v = &self.judge[i.min(self.judge.len() - 1)];
405 Ok(JudgeVerdict {
406 value: v.value,
407 reason: v.reason.clone(),
408 usage: v.usage.clone(),
409 })
410 }
411 }
412
413 fn temp_skill(tag: &str) -> std::path::PathBuf {
416 let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
417 std::fs::create_dir_all(&dir).unwrap();
418 std::fs::write(
419 dir.join("SKILL.md"),
420 "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
421 )
422 .unwrap();
423 dir
424 }
425
426 fn boolean_case(skill: std::path::PathBuf) -> TestCase {
427 TestCase {
428 name: "greets".into(),
429 skill,
430 input: "Greet Dr. Smith".into(),
431 user: None,
432 evals: vec![Eval::Boolean {
433 criterion: "greets Dr. Smith".into(),
434 expected: true,
435 name: None,
436 }],
437 }
438 }
439
440 #[test]
441 fn single_turn_runs_one_assistant_turn_and_scores() {
442 let provider = ScriptedProvider {
443 assistant: vec![AssistantTurn {
444 message: "Hello, Dr. Smith!".into(),
445 done: false,
446 ..Default::default()
447 }],
448 user: vec![],
449 judge: vec![JudgeVerdict {
450 value: JudgeValue::Bool(true),
451 reason: "names her".into(),
452 usage: None,
453 }],
454 calls: RefCell::new(Calls::default()),
455 };
456 let config = Config::default();
457 let runner = Runner::new(&provider, &config);
458 let runs = runner
459 .run_case(&boolean_case(temp_skill("single")))
460 .unwrap();
461 assert_eq!(runs.len(), 1);
462 assert!(runs[0].passed);
463 assert_eq!(runs[0].turns, 1);
464 assert_eq!(provider.calls.borrow().assistant, 1);
465 }
466
467 #[test]
468 fn multi_turn_stops_when_done_when_holds() {
469 let mut case = boolean_case(temp_skill("multi"));
470 case.user = Some(crate::testcase::SimulatedUser {
471 persona: "a terse patient".into(),
472 done_when: Some("the assistant has greeted".into()),
473 max_turns: Some(5),
474 });
475 let provider = ScriptedProvider {
476 assistant: vec![AssistantTurn {
477 message: "Hi there".into(),
478 done: false,
479 ..Default::default()
480 }],
481 user: vec![UserTurn {
482 message: "continue".into(),
483 stop: false,
484 ..Default::default()
485 }],
486 judge: vec![
489 JudgeVerdict {
490 value: JudgeValue::Bool(true),
491 reason: "done".into(),
492 usage: None,
493 },
494 JudgeVerdict {
495 value: JudgeValue::Bool(true),
496 reason: "greeted".into(),
497 usage: None,
498 },
499 ],
500 calls: RefCell::new(Calls::default()),
501 };
502 let config = Config::default();
503 let runner = Runner::new(&provider, &config);
504 let runs = runner.run_case(&case).unwrap();
505 assert!(runs[0].passed);
506 assert_eq!(provider.calls.borrow().assistant, 1);
508 assert_eq!(provider.calls.borrow().user, 0);
509 }
510
511 #[test]
512 fn failing_eval_marks_run_failed() {
513 let provider = ScriptedProvider {
514 assistant: vec![AssistantTurn {
515 message: "Hello".into(),
516 done: false,
517 ..Default::default()
518 }],
519 user: vec![],
520 judge: vec![JudgeVerdict {
521 value: JudgeValue::Bool(false),
522 reason: "no name".into(),
523 usage: None,
524 }],
525 calls: RefCell::new(Calls::default()),
526 };
527 let config = Config::default();
528 let runner = Runner::new(&provider, &config);
529 let report = runner
530 .run_all(&[boolean_case(temp_skill("faileval"))])
531 .unwrap();
532 assert!(!report.passed);
533 assert_eq!(report.summary.failed, 1);
534 }
535
536 #[test]
537 fn matrix_fans_out_over_platforms_and_models() {
538 let provider = ScriptedProvider {
539 assistant: vec![AssistantTurn {
540 message: "Hello".into(),
541 done: false,
542 ..Default::default()
543 }],
544 user: vec![],
545 judge: vec![JudgeVerdict {
546 value: JudgeValue::Bool(true),
547 reason: String::new(),
548 usage: None,
549 }],
550 calls: RefCell::new(Calls::default()),
551 };
552 let config = Config {
553 platforms: vec!["a".into(), "b".into()],
554 models: vec!["m1".into(), "m2".into()],
555 ..Config::default()
556 };
557 let runner = Runner::new(&provider, &config);
558 let runs = runner
559 .run_case(&boolean_case(temp_skill("matrix")))
560 .unwrap();
561 assert_eq!(runs.len(), 4);
562 }
563
564 #[test]
565 fn run_all_streaming_short_circuits_on_break() {
566 use crate::conversation::ToolEvent;
567 let provider = ScriptedProvider {
569 assistant: vec![AssistantTurn {
570 message: "did a bad thing".into(),
571 done: false,
572 usage: None,
573 session_id: None,
574 events: vec![ToolEvent {
575 kind: "tool_call".into(),
576 name: Some("bash".into()),
577 input: Some(serde_json::json!({ "command": "rm -rf /" })),
578 output: None,
579 index: 0,
580 }],
581 }],
582 user: vec![],
583 judge: vec![JudgeVerdict {
584 value: JudgeValue::Bool(true),
585 reason: String::new(),
586 usage: None,
587 }],
588 calls: RefCell::new(Calls::default()),
589 };
590 let config = Config {
591 platforms: vec!["a".into(), "b".into()],
592 ..Config::default()
593 };
594 let runner = Runner::new(&provider, &config);
595 let mut seen = 0usize;
596 let report = runner
597 .run_all_streaming(
598 &[boolean_case(temp_skill("stream-abort"))],
599 &mut |ev: &StreamEvent| {
600 seen += 1;
601 assert_eq!(ev.event.name.as_deref(), Some("bash"));
602 assert_eq!(ev.turn, 1);
603 ControlFlow::Break(())
604 },
605 )
606 .unwrap();
607 assert_eq!(seen, 1);
610 assert_eq!(report.runs.len(), 1);
611 assert!(!report.runs[0].passed);
612 assert_eq!(provider.calls.borrow().judge, 0);
614 }
615
616 fn usage(input: u64) -> Option<Usage> {
617 Some(Usage {
618 input_tokens: Some(input),
619 output_tokens: None,
620 cost_usd: None,
621 })
622 }
623
624 #[test]
625 fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
626 let mut case = boolean_case(temp_skill("loop"));
630 case.user = Some(crate::testcase::SimulatedUser {
631 persona: "a chatty patient".into(),
632 done_when: Some("the booking is confirmed".into()),
633 max_turns: Some(8),
634 });
635 let provider = ScriptedProvider {
636 assistant: vec![
637 AssistantTurn {
638 message: "Hello, how can I help?".into(),
639 done: false,
640 usage: usage(3),
641 session_id: Some("sess-1".into()),
643 events: Vec::new(),
644 },
645 AssistantTurn {
646 message: "Booked!".into(),
647 done: false,
648 usage: usage(4),
649 session_id: Some("sess-2".into()),
650 events: Vec::new(),
651 },
652 ],
653 user: vec![UserTurn {
654 message: "Please book me in.".into(),
655 stop: false,
656 usage: usage(2),
657 }],
658 judge: vec![
659 JudgeVerdict {
661 value: JudgeValue::Bool(false),
662 reason: "not yet".into(),
663 usage: usage(1),
664 },
665 JudgeVerdict {
667 value: JudgeValue::Bool(true),
668 reason: "confirmed".into(),
669 usage: usage(1),
670 },
671 JudgeVerdict {
673 value: JudgeValue::Bool(true),
674 reason: "greeted".into(),
675 usage: usage(5),
676 },
677 ],
678 calls: RefCell::new(Calls::default()),
679 };
680 let config = Config::default();
681 let runner = Runner::new(&provider, &config);
682 let runs = runner.run_case(&case).unwrap();
683 assert!(runs[0].passed);
684 assert_eq!(provider.calls.borrow().assistant, 2);
686 assert_eq!(provider.calls.borrow().user, 1);
687 assert_eq!(provider.calls.borrow().judge, 3);
688 assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
691 }
692
693 #[test]
694 fn multi_turn_threads_session_when_resume_supported() {
695 #[derive(Default)]
698 struct Sessions(RefCell<Vec<Option<String>>>);
699 struct ResumeProvider {
700 sessions: Sessions,
701 }
702 impl Provider for ResumeProvider {
703 fn respond(
704 &self,
705 _platform: &str,
706 _model: &str,
707 _skill: &SkillRef<'_>,
708 _messages: &[Message],
709 session: Option<&str>,
710 ) -> Result<AssistantTurn> {
711 self.sessions
712 .0
713 .borrow_mut()
714 .push(session.map(str::to_string));
715 let n = self.sessions.0.borrow().len();
716 Ok(AssistantTurn {
717 message: format!("turn {n}"),
718 done: false,
719 usage: None,
720 session_id: Some(format!("sess-{n}")),
721 events: Vec::new(),
722 })
723 }
724 fn simulate_user(
725 &self,
726 _model: &str,
727 _persona: &str,
728 _messages: &[Message],
729 ) -> Result<UserTurn> {
730 Ok(UserTurn {
731 message: "go on".into(),
732 stop: false,
733 usage: None,
734 })
735 }
736 fn judge(
737 &self,
738 _model: &str,
739 _query: &JudgeQuery<'_>,
740 _messages: &[Message],
741 ) -> Result<JudgeVerdict> {
742 Ok(JudgeVerdict {
743 value: JudgeValue::Bool(true),
744 reason: String::new(),
745 usage: None,
746 })
747 }
748 fn supports_resume(&self, _platform: &str) -> bool {
749 true
750 }
751 }
752 let mut case = boolean_case(temp_skill("resume"));
753 case.user = Some(crate::testcase::SimulatedUser {
754 persona: "a patient".into(),
755 done_when: None,
757 max_turns: Some(2),
758 });
759 let provider = ResumeProvider {
760 sessions: Sessions::default(),
761 };
762 let config = Config::default();
763 let runner = Runner::new(&provider, &config);
764 let runs = runner.run_case(&case).unwrap();
765 assert_eq!(runs[0].turns, 2);
766 let seen = provider.sessions.0.borrow();
768 assert_eq!(seen[0], None);
769 assert_eq!(seen[1].as_deref(), Some("sess-1"));
770 }
771}