1use std::ops::ControlFlow;
6
7use crate::config::Config;
8use crate::conversation::{Message, ToolEvent, Transcript};
9use crate::error::{Error, Result};
10use crate::eval::{Eval, JudgeValue};
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
40pub struct Runner<'a> {
42 provider: &'a dyn Provider,
43 config: &'a Config,
44}
45
46impl<'a> Runner<'a> {
47 #[must_use]
49 pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
50 Self { provider, config }
51 }
52
53 pub fn run_all(&self, cases: &[TestCase]) -> Result<Report> {
60 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
61 self.run_all_inner(
62 cases,
63 &mut Streaming {
64 on: false,
65 sink: &mut sink,
66 },
67 )
68 }
69
70 pub fn run_all_streaming(
80 &self,
81 cases: &[TestCase],
82 on_event: &mut dyn FnMut(&StreamEvent) -> ControlFlow<()>,
83 ) -> Result<Report> {
84 self.run_all_inner(
85 cases,
86 &mut Streaming {
87 on: true,
88 sink: on_event,
89 },
90 )
91 }
92
93 fn run_all_inner(&self, cases: &[TestCase], streaming: &mut Streaming) -> Result<Report> {
97 let mut runs = Vec::new();
98 for case in cases {
99 let skill = load_skill(&case.skill)?;
100 for platform in &self.config.platforms {
101 for model in &self.config.models {
102 let (run, flow) = self.run_case_on(case, &skill, platform, model, streaming)?;
103 runs.push(run);
104 if flow.is_break() {
105 return Ok(Report::new(runs));
106 }
107 }
108 }
109 }
110 Ok(Report::new(runs))
111 }
112
113 pub fn run_case(&self, case: &TestCase) -> Result<Vec<CaseRun>> {
118 let skill = load_skill(&case.skill)?;
119 let mut runs = Vec::new();
120 let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
121 let mut streaming = Streaming {
122 on: false,
123 sink: &mut sink,
124 };
125 for platform in &self.config.platforms {
126 for model in &self.config.models {
127 let (run, _flow) =
128 self.run_case_on(case, &skill, platform, model, &mut streaming)?;
129 runs.push(run);
130 }
131 }
132 Ok(runs)
133 }
134
135 fn run_case_on(
138 &self,
139 case: &TestCase,
140 skill: &SkillDefinition,
141 platform: &str,
142 model: &str,
143 streaming: &mut Streaming,
144 ) -> Result<(CaseRun, ControlFlow<()>)> {
145 let mut totals = Usage::default();
146 let mock_set =
149 MockSet::build(&self.config.mocks, &case.mocks, self.config.spy || case.spy)?;
150 let (transcript, mock_calls, flow) = self.converse(
151 case,
152 skill,
153 platform,
154 model,
155 &mock_set,
156 &mut totals,
157 streaming,
158 )?;
159 let mock_calls = mock_calls.map(|records| mock_set.resolve(records));
160 let evals = if flow.is_break() {
162 Vec::new()
163 } else {
164 self.score(
165 case,
166 &transcript,
167 &mock_set,
168 mock_calls.as_deref(),
169 &mut totals,
170 )?
171 };
172 let passed = flow.is_continue() && evals.iter().all(|e| e.passed);
173 Ok((
174 CaseRun {
175 case: case.name.clone(),
176 skill: skill.dir.to_string_lossy().into_owned(),
177 platform: platform.to_string(),
178 model: model.to_string(),
179 passed,
180 turns: transcript.assistant_turns(),
181 evals,
182 transcript,
183 usage: (!totals.is_empty()).then_some(totals),
184 mock_calls,
185 },
186 flow,
187 ))
188 }
189
190 #[allow(clippy::too_many_arguments)]
195 fn converse(
196 &self,
197 case: &TestCase,
198 skill: &SkillDefinition,
199 platform: &str,
200 model: &str,
201 mock_set: &MockSet,
202 totals: &mut Usage,
203 streaming: &mut Streaming,
204 ) -> Result<(Transcript, Option<Vec<MockCall>>, ControlFlow<()>)> {
205 let dir = skill.dir.to_string_lossy().into_owned();
206 let skill_ref = SkillRef {
207 name: &skill.name,
208 dir: &dir,
209 instructions: &skill.instructions,
210 };
211 let judge_model = self.config.effective_judge_model();
212 let max_turns = case
213 .user
214 .as_ref()
215 .and_then(|u| u.max_turns)
216 .unwrap_or(self.config.max_turns) as usize;
217 let resume_supported = self.provider.supports_resume(platform);
218
219 let mut transcript = Transcript::from_input(&case.input);
220 let mut session: Option<String> = None;
224 let plan = mock_set.active().then(|| MockPlan {
229 rules: mock_set.rules(),
230 });
231 let mut mock_calls: Option<Vec<MockCall>> = None;
232
233 loop {
234 let session_arg = if resume_supported {
235 session.as_deref()
236 } else {
237 None
238 };
239 let case_name = case.name.as_str();
245 let turn_index = transcript.assistant_turns() + 1;
246 let mut turn_flow = ControlFlow::Continue(());
247 let turn = if streaming.on {
248 let sink = &mut streaming.sink;
249 self.provider.respond_streaming_with_mocks(
250 platform,
251 model,
252 &skill_ref,
253 &transcript.messages,
254 session_arg,
255 plan.as_ref(),
256 &mut |event| {
257 let flow = sink(&StreamEvent {
258 case: case_name,
259 platform,
260 model,
261 turn: turn_index,
262 event,
263 });
264 if flow.is_break() {
265 turn_flow = ControlFlow::Break(());
266 }
267 flow
268 },
269 )?
270 } else {
271 self.provider.respond_with_mocks(
272 platform,
273 model,
274 &skill_ref,
275 &transcript.messages,
276 session_arg,
277 plan.as_ref(),
278 )?
279 };
280 if let Some(records) = turn.mock_calls {
281 mock_calls.get_or_insert_with(Vec::new).extend(records);
282 }
283 if let Some(u) = &turn.usage {
284 totals.add(u);
285 }
286 if let Some(id) = turn.session_id {
288 session = Some(id);
289 }
290 let skill_done = turn.done;
291 transcript.push(Message::assistant(turn.message).with_events(turn.events));
294
295 if turn_flow.is_break() {
297 return Ok((transcript, mock_calls, ControlFlow::Break(())));
298 }
299
300 let Some(user) = &case.user else {
302 break;
303 };
304
305 if skill_done || transcript.assistant_turns() >= max_turns {
306 break;
307 }
308
309 if let Some(done_when) = &user.done_when {
311 let query = JudgeQuery {
312 kind: JudgeKind::Boolean,
313 criterion: done_when,
314 scale: None,
315 };
316 let verdict = self
317 .provider
318 .judge(judge_model, &query, &transcript.messages)?;
319 if let Some(u) = &verdict.usage {
320 totals.add(u);
321 }
322 if matches!(verdict.value, JudgeValue::Bool(true)) {
323 break;
324 }
325 }
326
327 let user_turn =
329 self.provider
330 .simulate_user(judge_model, &user.persona, &transcript.messages)?;
331 if let Some(u) = &user_turn.usage {
332 totals.add(u);
333 }
334 let stop = user_turn.stop;
335 transcript.push(Message::user(user_turn.message));
336 if stop {
337 break;
338 }
339 }
340
341 Ok((transcript, mock_calls, ControlFlow::Continue(())))
342 }
343
344 fn score(
348 &self,
349 case: &TestCase,
350 transcript: &Transcript,
351 mock_set: &MockSet,
352 mock_calls: Option<&[MockCall]>,
353 totals: &mut Usage,
354 ) -> Result<Vec<crate::eval::EvalOutcome>> {
355 let judge_model = self.config.effective_judge_model();
356 let mut outcomes = Vec::with_capacity(case.evals.len());
357 for eval in &case.evals {
358 let query = match eval {
359 Eval::Boolean { criterion, .. } => JudgeQuery {
360 kind: JudgeKind::Boolean,
361 criterion,
362 scale: None,
363 },
364 Eval::Numeric {
365 criterion,
366 min,
367 max,
368 ..
369 } => JudgeQuery {
370 kind: JudgeKind::Numeric,
371 criterion,
372 scale: Some((*min, *max)),
373 },
374 Eval::Called { mock, r#where, .. } | Eval::NotCalled { mock, r#where, .. } => {
375 let records = mock_calls.ok_or_else(|| {
378 Error::Invalid(format!(
379 "eval `{}` needs the mock/spy channel, but the provider reported no observations for this run",
380 eval.label()
381 ))
382 })?;
383 let matching = mock_set
384 .records_for(mock, records)?
385 .into_iter()
386 .filter(|r| where_matches(r#where, r.input.as_ref()))
387 .count();
388 outcomes.push(eval.outcome_for_calls(matching, &describe_records(records))?);
389 continue;
390 }
391 };
392 let verdict = self
393 .provider
394 .judge(judge_model, &query, &transcript.messages)?;
395 if let Some(u) = &verdict.usage {
396 totals.add(u);
397 }
398 outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
399 }
400 Ok(outcomes)
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 use super::*;
407 use crate::conversation::Message;
408 use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
409 use std::cell::RefCell;
410
411 struct ScriptedProvider {
414 assistant: Vec<AssistantTurn>,
415 user: Vec<UserTurn>,
416 judge: Vec<JudgeVerdict>,
417 calls: RefCell<Calls>,
418 }
419
420 #[derive(Default)]
421 struct Calls {
422 assistant: usize,
423 user: usize,
424 judge: usize,
425 }
426
427 impl Provider for ScriptedProvider {
428 fn respond(
429 &self,
430 _platform: &str,
431 _model: &str,
432 _skill: &SkillRef<'_>,
433 _messages: &[Message],
434 _session: Option<&str>,
435 ) -> Result<AssistantTurn> {
436 let i = self.calls.borrow().assistant;
437 self.calls.borrow_mut().assistant += 1;
438 Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
439 }
440
441 fn simulate_user(
442 &self,
443 _model: &str,
444 _persona: &str,
445 _messages: &[Message],
446 ) -> Result<UserTurn> {
447 let i = self.calls.borrow().user;
448 self.calls.borrow_mut().user += 1;
449 Ok(self.user[i.min(self.user.len() - 1)].clone())
450 }
451
452 fn judge(
453 &self,
454 _model: &str,
455 _query: &JudgeQuery<'_>,
456 _messages: &[Message],
457 ) -> Result<JudgeVerdict> {
458 let i = self.calls.borrow().judge;
459 self.calls.borrow_mut().judge += 1;
460 let v = &self.judge[i.min(self.judge.len() - 1)];
461 Ok(JudgeVerdict {
462 value: v.value,
463 reason: v.reason.clone(),
464 usage: v.usage.clone(),
465 })
466 }
467 }
468
469 fn temp_skill(tag: &str) -> std::path::PathBuf {
472 let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
473 std::fs::create_dir_all(&dir).unwrap();
474 std::fs::write(
475 dir.join("SKILL.md"),
476 "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
477 )
478 .unwrap();
479 dir
480 }
481
482 fn boolean_case(skill: std::path::PathBuf) -> TestCase {
483 TestCase {
484 name: "greets".into(),
485 skill,
486 input: "Greet Dr. Smith".into(),
487 user: None,
488 mocks: Vec::new(),
489 spy: false,
490 evals: vec![Eval::Boolean {
491 criterion: "greets Dr. Smith".into(),
492 expected: true,
493 name: None,
494 }],
495 }
496 }
497
498 #[test]
499 fn single_turn_runs_one_assistant_turn_and_scores() {
500 let provider = ScriptedProvider {
501 assistant: vec![AssistantTurn {
502 message: "Hello, Dr. Smith!".into(),
503 done: false,
504 ..Default::default()
505 }],
506 user: vec![],
507 judge: vec![JudgeVerdict {
508 value: JudgeValue::Bool(true),
509 reason: "names her".into(),
510 usage: None,
511 }],
512 calls: RefCell::new(Calls::default()),
513 };
514 let config = Config::default();
515 let runner = Runner::new(&provider, &config);
516 let runs = runner
517 .run_case(&boolean_case(temp_skill("single")))
518 .unwrap();
519 assert_eq!(runs.len(), 1);
520 assert!(runs[0].passed);
521 assert_eq!(runs[0].turns, 1);
522 assert_eq!(provider.calls.borrow().assistant, 1);
523 }
524
525 #[test]
526 fn multi_turn_stops_when_done_when_holds() {
527 let mut case = boolean_case(temp_skill("multi"));
528 case.user = Some(crate::testcase::SimulatedUser {
529 persona: "a terse patient".into(),
530 done_when: Some("the assistant has greeted".into()),
531 max_turns: Some(5),
532 });
533 let provider = ScriptedProvider {
534 assistant: vec![AssistantTurn {
535 message: "Hi there".into(),
536 done: false,
537 ..Default::default()
538 }],
539 user: vec![UserTurn {
540 message: "continue".into(),
541 stop: false,
542 ..Default::default()
543 }],
544 judge: vec![
547 JudgeVerdict {
548 value: JudgeValue::Bool(true),
549 reason: "done".into(),
550 usage: None,
551 },
552 JudgeVerdict {
553 value: JudgeValue::Bool(true),
554 reason: "greeted".into(),
555 usage: None,
556 },
557 ],
558 calls: RefCell::new(Calls::default()),
559 };
560 let config = Config::default();
561 let runner = Runner::new(&provider, &config);
562 let runs = runner.run_case(&case).unwrap();
563 assert!(runs[0].passed);
564 assert_eq!(provider.calls.borrow().assistant, 1);
566 assert_eq!(provider.calls.borrow().user, 0);
567 }
568
569 #[test]
570 fn failing_eval_marks_run_failed() {
571 let provider = ScriptedProvider {
572 assistant: vec![AssistantTurn {
573 message: "Hello".into(),
574 done: false,
575 ..Default::default()
576 }],
577 user: vec![],
578 judge: vec![JudgeVerdict {
579 value: JudgeValue::Bool(false),
580 reason: "no name".into(),
581 usage: None,
582 }],
583 calls: RefCell::new(Calls::default()),
584 };
585 let config = Config::default();
586 let runner = Runner::new(&provider, &config);
587 let report = runner
588 .run_all(&[boolean_case(temp_skill("faileval"))])
589 .unwrap();
590 assert!(!report.passed);
591 assert_eq!(report.summary.failed, 1);
592 }
593
594 #[test]
595 fn matrix_fans_out_over_platforms_and_models() {
596 let provider = ScriptedProvider {
597 assistant: vec![AssistantTurn {
598 message: "Hello".into(),
599 done: false,
600 ..Default::default()
601 }],
602 user: vec![],
603 judge: vec![JudgeVerdict {
604 value: JudgeValue::Bool(true),
605 reason: String::new(),
606 usage: None,
607 }],
608 calls: RefCell::new(Calls::default()),
609 };
610 let config = Config {
611 platforms: vec!["a".into(), "b".into()],
612 models: vec!["m1".into(), "m2".into()],
613 ..Config::default()
614 };
615 let runner = Runner::new(&provider, &config);
616 let runs = runner
617 .run_case(&boolean_case(temp_skill("matrix")))
618 .unwrap();
619 assert_eq!(runs.len(), 4);
620 }
621
622 #[test]
623 fn run_all_streaming_short_circuits_on_break() {
624 use crate::conversation::ToolEvent;
625 let provider = ScriptedProvider {
627 assistant: vec![AssistantTurn {
628 message: "did a bad thing".into(),
629 done: false,
630 usage: None,
631 session_id: None,
632 mock_calls: None,
633 events: vec![ToolEvent {
634 kind: "tool_call".into(),
635 name: Some("bash".into()),
636 input: Some(serde_json::json!({ "command": "rm -rf /" })),
637 output: None,
638 index: 0,
639 }],
640 }],
641 user: vec![],
642 judge: vec![JudgeVerdict {
643 value: JudgeValue::Bool(true),
644 reason: String::new(),
645 usage: None,
646 }],
647 calls: RefCell::new(Calls::default()),
648 };
649 let config = Config {
650 platforms: vec!["a".into(), "b".into()],
651 ..Config::default()
652 };
653 let runner = Runner::new(&provider, &config);
654 let mut seen = 0usize;
655 let report = runner
656 .run_all_streaming(
657 &[boolean_case(temp_skill("stream-abort"))],
658 &mut |ev: &StreamEvent| {
659 seen += 1;
660 assert_eq!(ev.event.name.as_deref(), Some("bash"));
661 assert_eq!(ev.turn, 1);
662 ControlFlow::Break(())
663 },
664 )
665 .unwrap();
666 assert_eq!(seen, 1);
669 assert_eq!(report.runs.len(), 1);
670 assert!(!report.runs[0].passed);
671 assert_eq!(provider.calls.borrow().judge, 0);
673 }
674
675 fn usage(input: u64) -> Option<Usage> {
676 Some(Usage {
677 input_tokens: Some(input),
678 output_tokens: None,
679 cost_usd: None,
680 })
681 }
682
683 #[test]
684 fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
685 let mut case = boolean_case(temp_skill("loop"));
689 case.user = Some(crate::testcase::SimulatedUser {
690 persona: "a chatty patient".into(),
691 done_when: Some("the booking is confirmed".into()),
692 max_turns: Some(8),
693 });
694 let provider = ScriptedProvider {
695 assistant: vec![
696 AssistantTurn {
697 message: "Hello, how can I help?".into(),
698 done: false,
699 usage: usage(3),
700 session_id: Some("sess-1".into()),
702 events: Vec::new(),
703 mock_calls: None,
704 },
705 AssistantTurn {
706 message: "Booked!".into(),
707 done: false,
708 usage: usage(4),
709 session_id: Some("sess-2".into()),
710 events: Vec::new(),
711 mock_calls: None,
712 },
713 ],
714 user: vec![UserTurn {
715 message: "Please book me in.".into(),
716 stop: false,
717 usage: usage(2),
718 }],
719 judge: vec![
720 JudgeVerdict {
722 value: JudgeValue::Bool(false),
723 reason: "not yet".into(),
724 usage: usage(1),
725 },
726 JudgeVerdict {
728 value: JudgeValue::Bool(true),
729 reason: "confirmed".into(),
730 usage: usage(1),
731 },
732 JudgeVerdict {
734 value: JudgeValue::Bool(true),
735 reason: "greeted".into(),
736 usage: usage(5),
737 },
738 ],
739 calls: RefCell::new(Calls::default()),
740 };
741 let config = Config::default();
742 let runner = Runner::new(&provider, &config);
743 let runs = runner.run_case(&case).unwrap();
744 assert!(runs[0].passed);
745 assert_eq!(provider.calls.borrow().assistant, 2);
747 assert_eq!(provider.calls.borrow().user, 1);
748 assert_eq!(provider.calls.borrow().judge, 3);
749 assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
752 }
753
754 struct MockingProvider {
758 records: Vec<crate::mock::MockCall>,
759 seen_rules: RefCell<Vec<Option<serde_json::Value>>>,
760 judge_calls: RefCell<usize>,
761 }
762
763 impl Provider for MockingProvider {
764 fn respond(
765 &self,
766 _platform: &str,
767 _model: &str,
768 _skill: &SkillRef<'_>,
769 _messages: &[Message],
770 _session: Option<&str>,
771 ) -> Result<AssistantTurn> {
772 unreachable!("the runner must route through respond_with_mocks")
773 }
774
775 fn respond_with_mocks(
776 &self,
777 _platform: &str,
778 _model: &str,
779 _skill: &SkillRef<'_>,
780 _messages: &[Message],
781 _session: Option<&str>,
782 mocks: Option<&crate::mock::MockPlan<'_>>,
783 ) -> Result<AssistantTurn> {
784 self.seen_rules
785 .borrow_mut()
786 .push(mocks.and_then(|p| p.rules.cloned()));
787 Ok(AssistantTurn {
788 message: "did things".into(),
789 mock_calls: mocks.map(|_| self.records.clone()),
790 ..Default::default()
791 })
792 }
793
794 fn simulate_user(
795 &self,
796 _model: &str,
797 _persona: &str,
798 _messages: &[Message],
799 ) -> Result<UserTurn> {
800 unreachable!("single-turn case")
801 }
802
803 fn judge(
804 &self,
805 _model: &str,
806 _query: &JudgeQuery<'_>,
807 _messages: &[Message],
808 ) -> Result<JudgeVerdict> {
809 *self.judge_calls.borrow_mut() += 1;
810 Ok(JudgeVerdict {
811 value: JudgeValue::Bool(true),
812 reason: "fine".into(),
813 usage: None,
814 })
815 }
816 }
817
818 fn mock_record(
819 tool: &str,
820 command: &str,
821 action: &str,
822 rule: Option<usize>,
823 ) -> crate::mock::MockCall {
824 crate::mock::MockCall {
825 tool: Some(tool.into()),
826 input: Some(serde_json::json!({ "command": command })),
827 action: action.into(),
828 rule,
829 mock: None,
830 }
831 }
832
833 #[test]
834 fn mocked_case_scores_call_evals_deterministically() {
835 let mut case = boolean_case(temp_skill("mocked"));
836 case.mocks = serde_yaml::from_str(
837 r#"
838- name: push
839 match: { tool: bash, pattern: "git push( --force)?\\b" }
840 stub: Everything up-to-date
841- name: danger
842 match: { contains: "rm -rf" }
843 deny: blocked
844- name: git
845 match: { tool: bash, pattern: "\\bgit\\b" }
846"#,
847 )
848 .unwrap();
849 case.evals = vec![
850 serde_yaml::from_str("type: called\nmock: push\ntimes: 1\n").unwrap(),
851 serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap(),
852 serde_yaml::from_str(
853 "type: called\nmock: git\nwhere: { command: { contains: status } }\n",
854 )
855 .unwrap(),
856 ];
857 let provider = MockingProvider {
858 records: vec![
859 mock_record("bash", "git push origin", "stub", Some(0)),
860 mock_record("bash", "git status", "allow", None),
861 ],
862 seen_rules: RefCell::new(Vec::new()),
863 judge_calls: RefCell::new(0),
864 };
865 let config = Config::default();
866 let runner = Runner::new(&provider, &config);
867 let report = runner.run_all(&[case]).unwrap();
868 assert!(report.passed, "all call evals hold: {report:?}");
869 assert_eq!(*provider.judge_calls.borrow(), 0);
871 let seen = provider.seen_rules.borrow();
874 let rules = seen[0].as_ref().expect("plan carried rules");
875 assert_eq!(rules["rules"].as_array().unwrap().len(), 2);
876 let run = &report.runs[0];
878 let records = run.mock_calls.as_ref().expect("channel was on");
879 assert_eq!(records[0].mock.as_deref(), Some("push"));
880 assert_eq!(records[1].mock, None);
881 }
882
883 #[test]
884 fn failing_not_called_eval_reports_the_observed_calls() {
885 let mut case = boolean_case(temp_skill("mock-violate"));
886 case.mocks = serde_yaml::from_str(
887 "- name: danger\n match: { contains: \"rm -rf\" }\n deny: blocked\n",
888 )
889 .unwrap();
890 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
891 let provider = MockingProvider {
892 records: vec![mock_record("bash", "rm -rf /", "deny", Some(0))],
893 seen_rules: RefCell::new(Vec::new()),
894 judge_calls: RefCell::new(0),
895 };
896 let config = Config::default();
897 let runner = Runner::new(&provider, &config);
898 let report = runner.run_all(&[case]).unwrap();
899 assert!(!report.passed);
900 let outcome = &report.runs[0].evals[0];
901 assert!(!outcome.passed);
902 assert!(
904 outcome.reason.contains("rm -rf /") && outcome.reason.contains("[deny]"),
905 "reason: {}",
906 outcome.reason
907 );
908 }
909
910 #[test]
911 fn call_eval_without_a_channel_is_loud_never_vacuous() {
912 let mut case = boolean_case(temp_skill("mock-nochannel"));
915 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
916 let provider = ScriptedProvider {
917 assistant: vec![AssistantTurn {
918 message: "hi".into(),
919 ..Default::default()
920 }],
921 user: vec![],
922 judge: vec![],
923 calls: RefCell::new(Calls::default()),
924 };
925 let config = Config::default();
926 let runner = Runner::new(&provider, &config);
927 let err = runner.run_all(&[case]).unwrap_err();
928 assert!(
929 err.to_string().contains("needs the mock/spy channel"),
930 "{err}"
931 );
932 }
933
934 #[test]
935 fn spy_flag_activates_channel_without_declarations() {
936 let mut case = boolean_case(temp_skill("spy-flag"));
939 case.spy = true;
940 let provider = MockingProvider {
941 records: vec![mock_record("bash", "ls", "allow", None)],
942 seen_rules: RefCell::new(Vec::new()),
943 judge_calls: RefCell::new(0),
944 };
945 let config = Config::default();
946 let runner = Runner::new(&provider, &config);
947 let report = runner.run_all(&[case]).unwrap();
948 assert_eq!(provider.seen_rules.borrow()[0], None);
950 let records = report.runs[0].mock_calls.as_ref().unwrap();
951 assert_eq!(records.len(), 1);
952 assert_eq!(records[0].action, "allow");
953 }
954
955 #[test]
956 fn multi_turn_threads_session_when_resume_supported() {
957 #[derive(Default)]
960 struct Sessions(RefCell<Vec<Option<String>>>);
961 struct ResumeProvider {
962 sessions: Sessions,
963 }
964 impl Provider for ResumeProvider {
965 fn respond(
966 &self,
967 _platform: &str,
968 _model: &str,
969 _skill: &SkillRef<'_>,
970 _messages: &[Message],
971 session: Option<&str>,
972 ) -> Result<AssistantTurn> {
973 self.sessions
974 .0
975 .borrow_mut()
976 .push(session.map(str::to_string));
977 let n = self.sessions.0.borrow().len();
978 Ok(AssistantTurn {
979 message: format!("turn {n}"),
980 done: false,
981 usage: None,
982 session_id: Some(format!("sess-{n}")),
983 events: Vec::new(),
984 mock_calls: None,
985 })
986 }
987 fn simulate_user(
988 &self,
989 _model: &str,
990 _persona: &str,
991 _messages: &[Message],
992 ) -> Result<UserTurn> {
993 Ok(UserTurn {
994 message: "go on".into(),
995 stop: false,
996 usage: None,
997 })
998 }
999 fn judge(
1000 &self,
1001 _model: &str,
1002 _query: &JudgeQuery<'_>,
1003 _messages: &[Message],
1004 ) -> Result<JudgeVerdict> {
1005 Ok(JudgeVerdict {
1006 value: JudgeValue::Bool(true),
1007 reason: String::new(),
1008 usage: None,
1009 })
1010 }
1011 fn supports_resume(&self, _platform: &str) -> bool {
1012 true
1013 }
1014 }
1015 let mut case = boolean_case(temp_skill("resume"));
1016 case.user = Some(crate::testcase::SimulatedUser {
1017 persona: "a patient".into(),
1018 done_when: None,
1020 max_turns: Some(2),
1021 });
1022 let provider = ResumeProvider {
1023 sessions: Sessions::default(),
1024 };
1025 let config = Config::default();
1026 let runner = Runner::new(&provider, &config);
1027 let runs = runner.run_case(&case).unwrap();
1028 assert_eq!(runs[0].turns, 2);
1029 let seen = provider.sessions.0.borrow();
1031 assert_eq!(seen[0], None);
1032 assert_eq!(seen[1].as_deref(), Some("sess-1"));
1033 }
1034}