1use std::ops::ControlFlow;
6
7use crate::config::Config;
8use crate::conversation::{Message, ToolEvent, Transcript};
9use crate::error::{Error, Result};
10use crate::eval::{BooleanEval, CalledEval, Eval, JudgeValue, NotCalledEval, NumericEval};
11use crate::mock::{describe_records, where_matches, MockCall, MockPlan, MockSet};
12use crate::provider::{JudgeKind, JudgeQuery, Provider, SkillRef, Usage};
13use crate::report::{CaseRun, Report};
14use crate::skill::{load_skill, SkillDefinition};
15use crate::testcase::TestCase;
16
17pub struct StreamEvent<'a> {
21 pub case: &'a str,
23 pub platform: &'a str,
25 pub model: &'a str,
27 pub turn: usize,
29 pub event: &'a ToolEvent,
31}
32
33struct Streaming<'s> {
36 on: bool,
37 sink: &'s mut (dyn FnMut(&StreamEvent) -> ControlFlow<()> + 's),
38}
39
40struct ConverseOutcome {
42 transcript: Transcript,
43 mock_calls: Option<Vec<MockCall>>,
45 history_command: Option<String>,
47 flow: ControlFlow<()>,
49}
50
51pub struct Runner<'a> {
53 provider: &'a dyn Provider,
54 config: &'a Config,
55}
56
57impl<'a> Runner<'a> {
58 #[must_use]
60 pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
61 Self { provider, config }
62 }
63
64 pub fn run_all(&self, cases: &[TestCase]) -> Result<Report> {
71 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
72 self.run_all_inner(
73 cases,
74 &mut Streaming {
75 on: false,
76 sink: &mut sink,
77 },
78 )
79 }
80
81 pub fn run_all_streaming(
91 &self,
92 cases: &[TestCase],
93 on_event: &mut dyn FnMut(&StreamEvent) -> ControlFlow<()>,
94 ) -> Result<Report> {
95 self.run_all_inner(
96 cases,
97 &mut Streaming {
98 on: true,
99 sink: on_event,
100 },
101 )
102 }
103
104 fn run_all_inner(&self, cases: &[TestCase], streaming: &mut Streaming) -> Result<Report> {
108 let mut runs = Vec::new();
109 for case in cases {
110 let skill = load_skill(&case.skill)?;
111 for platform in &self.config.platforms {
112 for model in &self.config.models {
113 let (run, flow) = self.run_case_on(case, &skill, platform, model, streaming)?;
114 runs.push(run);
115 if flow.is_break() {
116 return Ok(Report::new(runs));
117 }
118 }
119 }
120 }
121 Ok(Report::new(runs))
122 }
123
124 pub fn run_case(&self, case: &TestCase) -> Result<Vec<CaseRun>> {
129 let skill = load_skill(&case.skill)?;
130 let mut runs = Vec::new();
131 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
132 let mut streaming = Streaming {
133 on: false,
134 sink: &mut sink,
135 };
136 for platform in &self.config.platforms {
137 for model in &self.config.models {
138 let (run, _flow) =
139 self.run_case_on(case, &skill, platform, model, &mut streaming)?;
140 runs.push(run);
141 }
142 }
143 Ok(runs)
144 }
145
146 fn run_case_on(
149 &self,
150 case: &TestCase,
151 skill: &SkillDefinition,
152 platform: &str,
153 model: &str,
154 streaming: &mut Streaming,
155 ) -> Result<(CaseRun, ControlFlow<()>)> {
156 let mut totals = Usage::default();
157 let mock_set =
160 MockSet::build(&self.config.mocks, &case.mocks, self.config.spy || case.spy)?;
161 let ConverseOutcome {
162 transcript,
163 mock_calls,
164 history_command,
165 flow,
166 } = self.converse(
167 case,
168 skill,
169 platform,
170 model,
171 &mock_set,
172 &mut totals,
173 streaming,
174 )?;
175 let mock_calls = mock_calls.map(|records| mock_set.resolve(records));
176 let evals = if flow.is_break() {
178 Vec::new()
179 } else {
180 self.score(
181 case,
182 &transcript,
183 &mock_set,
184 mock_calls.as_deref(),
185 &mut totals,
186 )?
187 };
188 let passed = flow.is_continue() && evals.iter().all(|e| e.passed);
189 Ok((
190 CaseRun {
191 case: case.name.clone(),
192 skill: skill.dir.to_string_lossy().into_owned(),
193 platform: platform.to_string(),
194 model: model.to_string(),
195 passed,
196 turns: transcript.assistant_turns(),
197 evals,
198 transcript,
199 usage: (!totals.is_empty()).then_some(totals),
200 mock_calls,
201 history_command,
202 },
203 flow,
204 ))
205 }
206
207 #[allow(clippy::too_many_arguments)]
212 fn converse(
213 &self,
214 case: &TestCase,
215 skill: &SkillDefinition,
216 platform: &str,
217 model: &str,
218 mock_set: &MockSet,
219 totals: &mut Usage,
220 streaming: &mut Streaming,
221 ) -> Result<ConverseOutcome> {
222 let dir = skill.dir.to_string_lossy().into_owned();
223 let skill_ref = SkillRef {
224 name: &skill.name,
225 dir: &dir,
226 instructions: &skill.instructions,
227 };
228 let judge_model = self.config.effective_judge_model();
229 let max_turns = case
230 .user
231 .as_ref()
232 .and_then(|u| u.max_turns)
233 .unwrap_or(self.config.max_turns) as usize;
234 let resume_supported = self.provider.supports_resume(platform);
235
236 let mut transcript = Transcript::from_input(&case.input);
237 let mut session: Option<String> = None;
241 let plan = mock_set.active().then(|| MockPlan {
246 rules: mock_set.rules(),
247 });
248 let mut mock_calls: Option<Vec<MockCall>> = None;
249 let mut history_command: Option<String> = None;
253
254 loop {
255 let session_arg = if resume_supported {
256 session.as_deref()
257 } else {
258 None
259 };
260 let case_name = case.name.as_str();
266 let turn_index = transcript.assistant_turns() + 1;
267 let mut turn_flow = ControlFlow::Continue(());
268 let turn = if streaming.on {
269 let sink = &mut streaming.sink;
270 self.provider.respond_streaming_with_mocks(
271 platform,
272 model,
273 &skill_ref,
274 &transcript.messages,
275 session_arg,
276 plan.as_ref(),
277 &mut |event| {
278 let flow = sink(&StreamEvent {
279 case: case_name,
280 platform,
281 model,
282 turn: turn_index,
283 event,
284 });
285 if flow.is_break() {
286 turn_flow = ControlFlow::Break(());
287 }
288 flow
289 },
290 )?
291 } else {
292 self.provider.respond_with_mocks(
293 platform,
294 model,
295 &skill_ref,
296 &transcript.messages,
297 session_arg,
298 plan.as_ref(),
299 )?
300 };
301 if let Some(records) = turn.mock_calls {
302 mock_calls.get_or_insert_with(Vec::new).extend(records);
303 }
304 if let Some(u) = &turn.usage {
305 totals.add(u);
306 }
307 if let Some(id) = turn.session_id {
309 session = Some(id);
310 }
311 if let Some(cmd) = turn.history_command {
313 history_command = Some(cmd);
314 }
315 let skill_done = turn.done;
316 transcript.push(Message::assistant(turn.message).with_events(turn.events));
319
320 if turn_flow.is_break() {
322 return Ok(ConverseOutcome {
323 transcript,
324 mock_calls,
325 history_command,
326 flow: ControlFlow::Break(()),
327 });
328 }
329
330 let Some(user) = &case.user else {
332 break;
333 };
334
335 if skill_done || transcript.assistant_turns() >= max_turns {
336 break;
337 }
338
339 if let Some(done_when) = &user.done_when {
341 let query = JudgeQuery {
342 kind: JudgeKind::Boolean,
343 criterion: done_when,
344 scale: None,
345 };
346 let verdict = self
347 .provider
348 .judge(judge_model, &query, &transcript.messages)?;
349 if let Some(u) = &verdict.usage {
350 totals.add(u);
351 }
352 if matches!(verdict.value, JudgeValue::Bool(true)) {
353 break;
354 }
355 }
356
357 let user_turn =
359 self.provider
360 .simulate_user(judge_model, &user.persona, &transcript.messages)?;
361 if let Some(u) = &user_turn.usage {
362 totals.add(u);
363 }
364 let stop = user_turn.stop;
365 transcript.push(Message::user(user_turn.message));
366 if stop {
367 break;
368 }
369 }
370
371 Ok(ConverseOutcome {
372 transcript,
373 mock_calls,
374 history_command,
375 flow: ControlFlow::Continue(()),
376 })
377 }
378
379 fn score(
383 &self,
384 case: &TestCase,
385 transcript: &Transcript,
386 mock_set: &MockSet,
387 mock_calls: Option<&[MockCall]>,
388 totals: &mut Usage,
389 ) -> Result<Vec<crate::eval::EvalOutcome>> {
390 let judge_model = self.config.effective_judge_model();
391 let mut outcomes = Vec::with_capacity(case.evals.len());
392 for eval in &case.evals {
393 let query = match eval {
394 Eval::Boolean(BooleanEval { criterion, .. }) => JudgeQuery {
395 kind: JudgeKind::Boolean,
396 criterion,
397 scale: None,
398 },
399 Eval::Numeric(NumericEval {
400 criterion,
401 min,
402 max,
403 ..
404 }) => JudgeQuery {
405 kind: JudgeKind::Numeric,
406 criterion,
407 scale: Some((*min, *max)),
408 },
409 Eval::Called(CalledEval { mock, r#where, .. })
410 | Eval::NotCalled(NotCalledEval { mock, r#where, .. }) => {
411 let records = mock_calls.ok_or_else(|| {
414 Error::Invalid(format!(
415 "eval `{}` needs the mock/spy channel, but the provider reported no observations for this run",
416 eval.label()
417 ))
418 })?;
419 let matching = mock_set
420 .records_for(mock, records)?
421 .into_iter()
422 .filter(|r| where_matches(r#where, r.input.as_ref()))
423 .count();
424 outcomes.push(eval.outcome_for_calls(matching, &describe_records(records))?);
425 continue;
426 }
427 };
428 let verdict = self
429 .provider
430 .judge(judge_model, &query, &transcript.messages)?;
431 if let Some(u) = &verdict.usage {
432 totals.add(u);
433 }
434 outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
435 }
436 Ok(outcomes)
437 }
438}
439
440#[cfg(test)]
441mod tests {
442 use super::*;
443 use crate::conversation::Message;
444 use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
445 use std::cell::RefCell;
446
447 struct ScriptedProvider {
450 assistant: Vec<AssistantTurn>,
451 user: Vec<UserTurn>,
452 judge: Vec<JudgeVerdict>,
453 calls: RefCell<Calls>,
454 }
455
456 #[derive(Default)]
457 struct Calls {
458 assistant: usize,
459 user: usize,
460 judge: usize,
461 }
462
463 impl Provider for ScriptedProvider {
464 fn respond(
465 &self,
466 _platform: &str,
467 _model: &str,
468 _skill: &SkillRef<'_>,
469 _messages: &[Message],
470 _session: Option<&str>,
471 ) -> Result<AssistantTurn> {
472 let i = self.calls.borrow().assistant;
473 self.calls.borrow_mut().assistant += 1;
474 Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
475 }
476
477 fn simulate_user(
478 &self,
479 _model: &str,
480 _persona: &str,
481 _messages: &[Message],
482 ) -> Result<UserTurn> {
483 let i = self.calls.borrow().user;
484 self.calls.borrow_mut().user += 1;
485 Ok(self.user[i.min(self.user.len() - 1)].clone())
486 }
487
488 fn judge(
489 &self,
490 _model: &str,
491 _query: &JudgeQuery<'_>,
492 _messages: &[Message],
493 ) -> Result<JudgeVerdict> {
494 let i = self.calls.borrow().judge;
495 self.calls.borrow_mut().judge += 1;
496 let v = &self.judge[i.min(self.judge.len() - 1)];
497 Ok(JudgeVerdict {
498 value: v.value,
499 reason: v.reason.clone(),
500 usage: v.usage.clone(),
501 })
502 }
503 }
504
505 fn temp_skill(tag: &str) -> std::path::PathBuf {
508 let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
509 std::fs::create_dir_all(&dir).unwrap();
510 std::fs::write(
511 dir.join("SKILL.md"),
512 "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
513 )
514 .unwrap();
515 dir
516 }
517
518 fn boolean_case(skill: std::path::PathBuf) -> TestCase {
519 TestCase {
520 name: "greets".into(),
521 skill,
522 input: "Greet Dr. Smith".into(),
523 user: None,
524 mocks: Vec::new(),
525 spy: false,
526 evals: vec![Eval::Boolean(BooleanEval {
527 criterion: "greets Dr. Smith".into(),
528 expected: true,
529 name: None,
530 })],
531 }
532 }
533
534 #[test]
535 fn single_turn_runs_one_assistant_turn_and_scores() {
536 let provider = ScriptedProvider {
537 assistant: vec![AssistantTurn {
538 message: "Hello, Dr. Smith!".into(),
539 done: false,
540 ..Default::default()
541 }],
542 user: vec![],
543 judge: vec![JudgeVerdict {
544 value: JudgeValue::Bool(true),
545 reason: "names her".into(),
546 usage: None,
547 }],
548 calls: RefCell::new(Calls::default()),
549 };
550 let config = Config::default();
551 let runner = Runner::new(&provider, &config);
552 let runs = runner
553 .run_case(&boolean_case(temp_skill("single")))
554 .unwrap();
555 assert_eq!(runs.len(), 1);
556 assert!(runs[0].passed);
557 assert_eq!(runs[0].turns, 1);
558 assert_eq!(provider.calls.borrow().assistant, 1);
559 assert!(runs[0].history_command.is_none());
561 }
562
563 #[test]
564 fn history_command_from_a_turn_lands_on_the_run() {
565 let provider = ScriptedProvider {
568 assistant: vec![AssistantTurn {
569 message: "Hello, Dr. Smith!".into(),
570 history_command: Some("oneharness history show sess --history-dir /h".into()),
571 ..Default::default()
572 }],
573 user: vec![],
574 judge: vec![JudgeVerdict {
575 value: JudgeValue::Bool(true),
576 reason: "names her".into(),
577 usage: None,
578 }],
579 calls: RefCell::new(Calls::default()),
580 };
581 let config = Config::default();
582 let runner = Runner::new(&provider, &config);
583 let runs = runner
584 .run_case(&boolean_case(temp_skill("history")))
585 .unwrap();
586 assert_eq!(
587 runs[0].history_command.as_deref(),
588 Some("oneharness history show sess --history-dir /h"),
589 );
590 }
591
592 #[test]
593 fn multi_turn_run_surfaces_one_history_command() {
594 let mut case = boolean_case(temp_skill("history-multi"));
597 case.user = Some(crate::testcase::SimulatedUser {
598 persona: "a patient".into(),
599 done_when: None,
600 max_turns: Some(2),
601 });
602 let cmd = "oneharness history show skilltest-p-m-x-1a2b --history-dir /h";
603 let provider = ScriptedProvider {
604 assistant: vec![
605 AssistantTurn {
606 message: "turn 1".into(),
607 history_command: Some(cmd.into()),
608 ..Default::default()
609 },
610 AssistantTurn {
611 message: "turn 2".into(),
612 history_command: Some(cmd.into()),
613 ..Default::default()
614 },
615 ],
616 user: vec![UserTurn {
617 message: "go on".into(),
618 stop: false,
619 ..Default::default()
620 }],
621 judge: vec![JudgeVerdict {
622 value: JudgeValue::Bool(true),
623 reason: String::new(),
624 usage: None,
625 }],
626 calls: RefCell::new(Calls::default()),
627 };
628 let config = Config::default();
629 let runner = Runner::new(&provider, &config);
630 let runs = runner.run_case(&case).unwrap();
631 assert_eq!(runs[0].turns, 2);
632 assert_eq!(runs[0].history_command.as_deref(), Some(cmd));
633 }
634
635 #[test]
636 fn multi_turn_stops_when_done_when_holds() {
637 let mut case = boolean_case(temp_skill("multi"));
638 case.user = Some(crate::testcase::SimulatedUser {
639 persona: "a terse patient".into(),
640 done_when: Some("the assistant has greeted".into()),
641 max_turns: Some(5),
642 });
643 let provider = ScriptedProvider {
644 assistant: vec![AssistantTurn {
645 message: "Hi there".into(),
646 done: false,
647 ..Default::default()
648 }],
649 user: vec![UserTurn {
650 message: "continue".into(),
651 stop: false,
652 ..Default::default()
653 }],
654 judge: vec![
657 JudgeVerdict {
658 value: JudgeValue::Bool(true),
659 reason: "done".into(),
660 usage: None,
661 },
662 JudgeVerdict {
663 value: JudgeValue::Bool(true),
664 reason: "greeted".into(),
665 usage: None,
666 },
667 ],
668 calls: RefCell::new(Calls::default()),
669 };
670 let config = Config::default();
671 let runner = Runner::new(&provider, &config);
672 let runs = runner.run_case(&case).unwrap();
673 assert!(runs[0].passed);
674 assert_eq!(provider.calls.borrow().assistant, 1);
676 assert_eq!(provider.calls.borrow().user, 0);
677 }
678
679 #[test]
680 fn failing_eval_marks_run_failed() {
681 let provider = ScriptedProvider {
682 assistant: vec![AssistantTurn {
683 message: "Hello".into(),
684 done: false,
685 ..Default::default()
686 }],
687 user: vec![],
688 judge: vec![JudgeVerdict {
689 value: JudgeValue::Bool(false),
690 reason: "no name".into(),
691 usage: None,
692 }],
693 calls: RefCell::new(Calls::default()),
694 };
695 let config = Config::default();
696 let runner = Runner::new(&provider, &config);
697 let report = runner
698 .run_all(&[boolean_case(temp_skill("faileval"))])
699 .unwrap();
700 assert!(!report.passed);
701 assert_eq!(report.summary.failed, 1);
702 }
703
704 #[test]
705 fn matrix_fans_out_over_platforms_and_models() {
706 let provider = ScriptedProvider {
707 assistant: vec![AssistantTurn {
708 message: "Hello".into(),
709 done: false,
710 ..Default::default()
711 }],
712 user: vec![],
713 judge: vec![JudgeVerdict {
714 value: JudgeValue::Bool(true),
715 reason: String::new(),
716 usage: None,
717 }],
718 calls: RefCell::new(Calls::default()),
719 };
720 let config = Config {
721 platforms: vec!["a".into(), "b".into()],
722 models: vec!["m1".into(), "m2".into()],
723 ..Config::default()
724 };
725 let runner = Runner::new(&provider, &config);
726 let runs = runner
727 .run_case(&boolean_case(temp_skill("matrix")))
728 .unwrap();
729 assert_eq!(runs.len(), 4);
730 }
731
732 #[test]
733 fn run_all_streaming_short_circuits_on_break() {
734 use crate::conversation::ToolEvent;
735 let provider = ScriptedProvider {
737 assistant: vec![AssistantTurn {
738 message: "did a bad thing".into(),
739 done: false,
740 usage: None,
741 session_id: None,
742 mock_calls: None,
743 events: vec![ToolEvent {
744 kind: "tool_call".into(),
745 name: Some("bash".into()),
746 input: Some(serde_json::json!({ "command": "rm -rf /" })),
747 output: None,
748 index: 0,
749 }],
750 history_command: None,
751 }],
752 user: vec![],
753 judge: vec![JudgeVerdict {
754 value: JudgeValue::Bool(true),
755 reason: String::new(),
756 usage: None,
757 }],
758 calls: RefCell::new(Calls::default()),
759 };
760 let config = Config {
761 platforms: vec!["a".into(), "b".into()],
762 ..Config::default()
763 };
764 let runner = Runner::new(&provider, &config);
765 let mut seen = 0usize;
766 let report = runner
767 .run_all_streaming(
768 &[boolean_case(temp_skill("stream-abort"))],
769 &mut |ev: &StreamEvent| {
770 seen += 1;
771 assert_eq!(ev.event.name.as_deref(), Some("bash"));
772 assert_eq!(ev.turn, 1);
773 ControlFlow::Break(())
774 },
775 )
776 .unwrap();
777 assert_eq!(seen, 1);
780 assert_eq!(report.runs.len(), 1);
781 assert!(!report.runs[0].passed);
782 assert_eq!(provider.calls.borrow().judge, 0);
784 }
785
786 fn usage(input: u64) -> Option<Usage> {
787 Some(Usage {
788 input_tokens: Some(input),
789 output_tokens: None,
790 cost_usd: None,
791 })
792 }
793
794 #[test]
795 fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
796 let mut case = boolean_case(temp_skill("loop"));
800 case.user = Some(crate::testcase::SimulatedUser {
801 persona: "a chatty patient".into(),
802 done_when: Some("the booking is confirmed".into()),
803 max_turns: Some(8),
804 });
805 let provider = ScriptedProvider {
806 assistant: vec![
807 AssistantTurn {
808 message: "Hello, how can I help?".into(),
809 done: false,
810 usage: usage(3),
811 session_id: Some("sess-1".into()),
813 events: Vec::new(),
814 mock_calls: None,
815 history_command: None,
816 },
817 AssistantTurn {
818 message: "Booked!".into(),
819 done: false,
820 usage: usage(4),
821 session_id: Some("sess-2".into()),
822 events: Vec::new(),
823 mock_calls: None,
824 history_command: None,
825 },
826 ],
827 user: vec![UserTurn {
828 message: "Please book me in.".into(),
829 stop: false,
830 usage: usage(2),
831 }],
832 judge: vec![
833 JudgeVerdict {
835 value: JudgeValue::Bool(false),
836 reason: "not yet".into(),
837 usage: usage(1),
838 },
839 JudgeVerdict {
841 value: JudgeValue::Bool(true),
842 reason: "confirmed".into(),
843 usage: usage(1),
844 },
845 JudgeVerdict {
847 value: JudgeValue::Bool(true),
848 reason: "greeted".into(),
849 usage: usage(5),
850 },
851 ],
852 calls: RefCell::new(Calls::default()),
853 };
854 let config = Config::default();
855 let runner = Runner::new(&provider, &config);
856 let runs = runner.run_case(&case).unwrap();
857 assert!(runs[0].passed);
858 assert_eq!(provider.calls.borrow().assistant, 2);
860 assert_eq!(provider.calls.borrow().user, 1);
861 assert_eq!(provider.calls.borrow().judge, 3);
862 assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
865 }
866
867 struct MockingProvider {
871 records: Vec<crate::mock::MockCall>,
872 seen_rules: RefCell<Vec<Option<serde_json::Value>>>,
873 judge_calls: RefCell<usize>,
874 }
875
876 impl Provider for MockingProvider {
877 fn respond(
878 &self,
879 _platform: &str,
880 _model: &str,
881 _skill: &SkillRef<'_>,
882 _messages: &[Message],
883 _session: Option<&str>,
884 ) -> Result<AssistantTurn> {
885 unreachable!("the runner must route through respond_with_mocks")
886 }
887
888 fn respond_with_mocks(
889 &self,
890 _platform: &str,
891 _model: &str,
892 _skill: &SkillRef<'_>,
893 _messages: &[Message],
894 _session: Option<&str>,
895 mocks: Option<&crate::mock::MockPlan<'_>>,
896 ) -> Result<AssistantTurn> {
897 self.seen_rules
898 .borrow_mut()
899 .push(mocks.and_then(|p| p.rules.cloned()));
900 Ok(AssistantTurn {
901 message: "did things".into(),
902 mock_calls: mocks.map(|_| self.records.clone()),
903 ..Default::default()
904 })
905 }
906
907 fn simulate_user(
908 &self,
909 _model: &str,
910 _persona: &str,
911 _messages: &[Message],
912 ) -> Result<UserTurn> {
913 unreachable!("single-turn case")
914 }
915
916 fn judge(
917 &self,
918 _model: &str,
919 _query: &JudgeQuery<'_>,
920 _messages: &[Message],
921 ) -> Result<JudgeVerdict> {
922 *self.judge_calls.borrow_mut() += 1;
923 Ok(JudgeVerdict {
924 value: JudgeValue::Bool(true),
925 reason: "fine".into(),
926 usage: None,
927 })
928 }
929 }
930
931 fn mock_record(
932 tool: &str,
933 command: &str,
934 action: &str,
935 rule: Option<usize>,
936 ) -> crate::mock::MockCall {
937 crate::mock::MockCall {
938 tool: Some(tool.into()),
939 input: Some(serde_json::json!({ "command": command })),
940 action: action.into(),
941 rule,
942 mock: None,
943 }
944 }
945
946 #[test]
947 fn mocked_case_scores_call_evals_deterministically() {
948 let mut case = boolean_case(temp_skill("mocked"));
949 case.mocks = serde_yaml::from_str(
950 r#"
951- name: push
952 match: { tool: bash, pattern: "git push( --force)?\\b" }
953 stub: Everything up-to-date
954- name: danger
955 match: { contains: "rm -rf" }
956 deny: blocked
957- name: git
958 match: { tool: bash, pattern: "\\bgit\\b" }
959"#,
960 )
961 .unwrap();
962 case.evals = vec![
963 serde_yaml::from_str("type: called\nmock: push\ntimes: 1\n").unwrap(),
964 serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap(),
965 serde_yaml::from_str(
966 "type: called\nmock: git\nwhere: { command: { contains: status } }\n",
967 )
968 .unwrap(),
969 ];
970 let provider = MockingProvider {
971 records: vec![
972 mock_record("bash", "git push origin", "stub", Some(0)),
973 mock_record("bash", "git status", "allow", None),
974 ],
975 seen_rules: RefCell::new(Vec::new()),
976 judge_calls: RefCell::new(0),
977 };
978 let config = Config::default();
979 let runner = Runner::new(&provider, &config);
980 let report = runner.run_all(&[case]).unwrap();
981 assert!(report.passed, "all call evals hold: {report:?}");
982 assert_eq!(*provider.judge_calls.borrow(), 0);
984 let seen = provider.seen_rules.borrow();
987 let rules = seen[0].as_ref().expect("plan carried rules");
988 assert_eq!(rules["rules"].as_array().unwrap().len(), 2);
989 let run = &report.runs[0];
991 let records = run.mock_calls.as_ref().expect("channel was on");
992 assert_eq!(records[0].mock.as_deref(), Some("push"));
993 assert_eq!(records[1].mock, None);
994 }
995
996 #[test]
997 fn failing_not_called_eval_reports_the_observed_calls() {
998 let mut case = boolean_case(temp_skill("mock-violate"));
999 case.mocks = serde_yaml::from_str(
1000 "- name: danger\n match: { contains: \"rm -rf\" }\n deny: blocked\n",
1001 )
1002 .unwrap();
1003 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
1004 let provider = MockingProvider {
1005 records: vec![mock_record("bash", "rm -rf /", "deny", Some(0))],
1006 seen_rules: RefCell::new(Vec::new()),
1007 judge_calls: RefCell::new(0),
1008 };
1009 let config = Config::default();
1010 let runner = Runner::new(&provider, &config);
1011 let report = runner.run_all(&[case]).unwrap();
1012 assert!(!report.passed);
1013 let outcome = &report.runs[0].evals[0];
1014 assert!(!outcome.passed);
1015 assert!(
1017 outcome.reason.contains("rm -rf /") && outcome.reason.contains("[deny]"),
1018 "reason: {}",
1019 outcome.reason
1020 );
1021 }
1022
1023 #[test]
1024 fn call_eval_without_a_channel_is_loud_never_vacuous() {
1025 let mut case = boolean_case(temp_skill("mock-nochannel"));
1028 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
1029 let provider = ScriptedProvider {
1030 assistant: vec![AssistantTurn {
1031 message: "hi".into(),
1032 ..Default::default()
1033 }],
1034 user: vec![],
1035 judge: vec![],
1036 calls: RefCell::new(Calls::default()),
1037 };
1038 let config = Config::default();
1039 let runner = Runner::new(&provider, &config);
1040 let err = runner.run_all(&[case]).unwrap_err();
1041 assert!(
1042 err.to_string().contains("needs the mock/spy channel"),
1043 "{err}"
1044 );
1045 }
1046
1047 #[test]
1048 fn spy_flag_activates_channel_without_declarations() {
1049 let mut case = boolean_case(temp_skill("spy-flag"));
1052 case.spy = true;
1053 let provider = MockingProvider {
1054 records: vec![mock_record("bash", "ls", "allow", None)],
1055 seen_rules: RefCell::new(Vec::new()),
1056 judge_calls: RefCell::new(0),
1057 };
1058 let config = Config::default();
1059 let runner = Runner::new(&provider, &config);
1060 let report = runner.run_all(&[case]).unwrap();
1061 assert_eq!(provider.seen_rules.borrow()[0], None);
1063 let records = report.runs[0].mock_calls.as_ref().unwrap();
1064 assert_eq!(records.len(), 1);
1065 assert_eq!(records[0].action, "allow");
1066 }
1067
1068 #[test]
1069 fn multi_turn_threads_session_when_resume_supported() {
1070 #[derive(Default)]
1073 struct Sessions(RefCell<Vec<Option<String>>>);
1074 struct ResumeProvider {
1075 sessions: Sessions,
1076 }
1077 impl Provider for ResumeProvider {
1078 fn respond(
1079 &self,
1080 _platform: &str,
1081 _model: &str,
1082 _skill: &SkillRef<'_>,
1083 _messages: &[Message],
1084 session: Option<&str>,
1085 ) -> Result<AssistantTurn> {
1086 self.sessions
1087 .0
1088 .borrow_mut()
1089 .push(session.map(str::to_string));
1090 let n = self.sessions.0.borrow().len();
1091 Ok(AssistantTurn {
1092 message: format!("turn {n}"),
1093 done: false,
1094 usage: None,
1095 session_id: Some(format!("sess-{n}")),
1096 events: Vec::new(),
1097 mock_calls: None,
1098 history_command: None,
1099 })
1100 }
1101 fn simulate_user(
1102 &self,
1103 _model: &str,
1104 _persona: &str,
1105 _messages: &[Message],
1106 ) -> Result<UserTurn> {
1107 Ok(UserTurn {
1108 message: "go on".into(),
1109 stop: false,
1110 usage: None,
1111 })
1112 }
1113 fn judge(
1114 &self,
1115 _model: &str,
1116 _query: &JudgeQuery<'_>,
1117 _messages: &[Message],
1118 ) -> Result<JudgeVerdict> {
1119 Ok(JudgeVerdict {
1120 value: JudgeValue::Bool(true),
1121 reason: String::new(),
1122 usage: None,
1123 })
1124 }
1125 fn supports_resume(&self, _platform: &str) -> bool {
1126 true
1127 }
1128 }
1129 let mut case = boolean_case(temp_skill("resume"));
1130 case.user = Some(crate::testcase::SimulatedUser {
1131 persona: "a patient".into(),
1132 done_when: None,
1134 max_turns: Some(2),
1135 });
1136 let provider = ResumeProvider {
1137 sessions: Sessions::default(),
1138 };
1139 let config = Config::default();
1140 let runner = Runner::new(&provider, &config);
1141 let runs = runner.run_case(&case).unwrap();
1142 assert_eq!(runs[0].turns, 2);
1143 let seen = provider.sessions.0.borrow();
1145 assert_eq!(seen[0], None);
1146 assert_eq!(seen[1].as_deref(), Some("sess-1"));
1147 }
1148}