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
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(BooleanEval { criterion, .. }) => JudgeQuery {
360 kind: JudgeKind::Boolean,
361 criterion,
362 scale: None,
363 },
364 Eval::Numeric(NumericEval {
365 criterion,
366 min,
367 max,
368 ..
369 }) => JudgeQuery {
370 kind: JudgeKind::Numeric,
371 criterion,
372 scale: Some((*min, *max)),
373 },
374 Eval::Called(CalledEval { mock, r#where, .. })
375 | Eval::NotCalled(NotCalledEval { mock, r#where, .. }) => {
376 let records = mock_calls.ok_or_else(|| {
379 Error::Invalid(format!(
380 "eval `{}` needs the mock/spy channel, but the provider reported no observations for this run",
381 eval.label()
382 ))
383 })?;
384 let matching = mock_set
385 .records_for(mock, records)?
386 .into_iter()
387 .filter(|r| where_matches(r#where, r.input.as_ref()))
388 .count();
389 outcomes.push(eval.outcome_for_calls(matching, &describe_records(records))?);
390 continue;
391 }
392 };
393 let verdict = self
394 .provider
395 .judge(judge_model, &query, &transcript.messages)?;
396 if let Some(u) = &verdict.usage {
397 totals.add(u);
398 }
399 outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
400 }
401 Ok(outcomes)
402 }
403}
404
405#[cfg(test)]
406mod tests {
407 use super::*;
408 use crate::conversation::Message;
409 use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
410 use std::cell::RefCell;
411
412 struct ScriptedProvider {
415 assistant: Vec<AssistantTurn>,
416 user: Vec<UserTurn>,
417 judge: Vec<JudgeVerdict>,
418 calls: RefCell<Calls>,
419 }
420
421 #[derive(Default)]
422 struct Calls {
423 assistant: usize,
424 user: usize,
425 judge: usize,
426 }
427
428 impl Provider for ScriptedProvider {
429 fn respond(
430 &self,
431 _platform: &str,
432 _model: &str,
433 _skill: &SkillRef<'_>,
434 _messages: &[Message],
435 _session: Option<&str>,
436 ) -> Result<AssistantTurn> {
437 let i = self.calls.borrow().assistant;
438 self.calls.borrow_mut().assistant += 1;
439 Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
440 }
441
442 fn simulate_user(
443 &self,
444 _model: &str,
445 _persona: &str,
446 _messages: &[Message],
447 ) -> Result<UserTurn> {
448 let i = self.calls.borrow().user;
449 self.calls.borrow_mut().user += 1;
450 Ok(self.user[i.min(self.user.len() - 1)].clone())
451 }
452
453 fn judge(
454 &self,
455 _model: &str,
456 _query: &JudgeQuery<'_>,
457 _messages: &[Message],
458 ) -> Result<JudgeVerdict> {
459 let i = self.calls.borrow().judge;
460 self.calls.borrow_mut().judge += 1;
461 let v = &self.judge[i.min(self.judge.len() - 1)];
462 Ok(JudgeVerdict {
463 value: v.value,
464 reason: v.reason.clone(),
465 usage: v.usage.clone(),
466 })
467 }
468 }
469
470 fn temp_skill(tag: &str) -> std::path::PathBuf {
473 let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
474 std::fs::create_dir_all(&dir).unwrap();
475 std::fs::write(
476 dir.join("SKILL.md"),
477 "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
478 )
479 .unwrap();
480 dir
481 }
482
483 fn boolean_case(skill: std::path::PathBuf) -> TestCase {
484 TestCase {
485 name: "greets".into(),
486 skill,
487 input: "Greet Dr. Smith".into(),
488 user: None,
489 mocks: Vec::new(),
490 spy: false,
491 evals: vec![Eval::Boolean(BooleanEval {
492 criterion: "greets Dr. Smith".into(),
493 expected: true,
494 name: None,
495 })],
496 }
497 }
498
499 #[test]
500 fn single_turn_runs_one_assistant_turn_and_scores() {
501 let provider = ScriptedProvider {
502 assistant: vec![AssistantTurn {
503 message: "Hello, Dr. Smith!".into(),
504 done: false,
505 ..Default::default()
506 }],
507 user: vec![],
508 judge: vec![JudgeVerdict {
509 value: JudgeValue::Bool(true),
510 reason: "names her".into(),
511 usage: None,
512 }],
513 calls: RefCell::new(Calls::default()),
514 };
515 let config = Config::default();
516 let runner = Runner::new(&provider, &config);
517 let runs = runner
518 .run_case(&boolean_case(temp_skill("single")))
519 .unwrap();
520 assert_eq!(runs.len(), 1);
521 assert!(runs[0].passed);
522 assert_eq!(runs[0].turns, 1);
523 assert_eq!(provider.calls.borrow().assistant, 1);
524 }
525
526 #[test]
527 fn multi_turn_stops_when_done_when_holds() {
528 let mut case = boolean_case(temp_skill("multi"));
529 case.user = Some(crate::testcase::SimulatedUser {
530 persona: "a terse patient".into(),
531 done_when: Some("the assistant has greeted".into()),
532 max_turns: Some(5),
533 });
534 let provider = ScriptedProvider {
535 assistant: vec![AssistantTurn {
536 message: "Hi there".into(),
537 done: false,
538 ..Default::default()
539 }],
540 user: vec![UserTurn {
541 message: "continue".into(),
542 stop: false,
543 ..Default::default()
544 }],
545 judge: vec![
548 JudgeVerdict {
549 value: JudgeValue::Bool(true),
550 reason: "done".into(),
551 usage: None,
552 },
553 JudgeVerdict {
554 value: JudgeValue::Bool(true),
555 reason: "greeted".into(),
556 usage: None,
557 },
558 ],
559 calls: RefCell::new(Calls::default()),
560 };
561 let config = Config::default();
562 let runner = Runner::new(&provider, &config);
563 let runs = runner.run_case(&case).unwrap();
564 assert!(runs[0].passed);
565 assert_eq!(provider.calls.borrow().assistant, 1);
567 assert_eq!(provider.calls.borrow().user, 0);
568 }
569
570 #[test]
571 fn failing_eval_marks_run_failed() {
572 let provider = ScriptedProvider {
573 assistant: vec![AssistantTurn {
574 message: "Hello".into(),
575 done: false,
576 ..Default::default()
577 }],
578 user: vec![],
579 judge: vec![JudgeVerdict {
580 value: JudgeValue::Bool(false),
581 reason: "no name".into(),
582 usage: None,
583 }],
584 calls: RefCell::new(Calls::default()),
585 };
586 let config = Config::default();
587 let runner = Runner::new(&provider, &config);
588 let report = runner
589 .run_all(&[boolean_case(temp_skill("faileval"))])
590 .unwrap();
591 assert!(!report.passed);
592 assert_eq!(report.summary.failed, 1);
593 }
594
595 #[test]
596 fn matrix_fans_out_over_platforms_and_models() {
597 let provider = ScriptedProvider {
598 assistant: vec![AssistantTurn {
599 message: "Hello".into(),
600 done: false,
601 ..Default::default()
602 }],
603 user: vec![],
604 judge: vec![JudgeVerdict {
605 value: JudgeValue::Bool(true),
606 reason: String::new(),
607 usage: None,
608 }],
609 calls: RefCell::new(Calls::default()),
610 };
611 let config = Config {
612 platforms: vec!["a".into(), "b".into()],
613 models: vec!["m1".into(), "m2".into()],
614 ..Config::default()
615 };
616 let runner = Runner::new(&provider, &config);
617 let runs = runner
618 .run_case(&boolean_case(temp_skill("matrix")))
619 .unwrap();
620 assert_eq!(runs.len(), 4);
621 }
622
623 #[test]
624 fn run_all_streaming_short_circuits_on_break() {
625 use crate::conversation::ToolEvent;
626 let provider = ScriptedProvider {
628 assistant: vec![AssistantTurn {
629 message: "did a bad thing".into(),
630 done: false,
631 usage: None,
632 session_id: None,
633 mock_calls: None,
634 events: vec![ToolEvent {
635 kind: "tool_call".into(),
636 name: Some("bash".into()),
637 input: Some(serde_json::json!({ "command": "rm -rf /" })),
638 output: None,
639 index: 0,
640 }],
641 }],
642 user: vec![],
643 judge: vec![JudgeVerdict {
644 value: JudgeValue::Bool(true),
645 reason: String::new(),
646 usage: None,
647 }],
648 calls: RefCell::new(Calls::default()),
649 };
650 let config = Config {
651 platforms: vec!["a".into(), "b".into()],
652 ..Config::default()
653 };
654 let runner = Runner::new(&provider, &config);
655 let mut seen = 0usize;
656 let report = runner
657 .run_all_streaming(
658 &[boolean_case(temp_skill("stream-abort"))],
659 &mut |ev: &StreamEvent| {
660 seen += 1;
661 assert_eq!(ev.event.name.as_deref(), Some("bash"));
662 assert_eq!(ev.turn, 1);
663 ControlFlow::Break(())
664 },
665 )
666 .unwrap();
667 assert_eq!(seen, 1);
670 assert_eq!(report.runs.len(), 1);
671 assert!(!report.runs[0].passed);
672 assert_eq!(provider.calls.borrow().judge, 0);
674 }
675
676 fn usage(input: u64) -> Option<Usage> {
677 Some(Usage {
678 input_tokens: Some(input),
679 output_tokens: None,
680 cost_usd: None,
681 })
682 }
683
684 #[test]
685 fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
686 let mut case = boolean_case(temp_skill("loop"));
690 case.user = Some(crate::testcase::SimulatedUser {
691 persona: "a chatty patient".into(),
692 done_when: Some("the booking is confirmed".into()),
693 max_turns: Some(8),
694 });
695 let provider = ScriptedProvider {
696 assistant: vec![
697 AssistantTurn {
698 message: "Hello, how can I help?".into(),
699 done: false,
700 usage: usage(3),
701 session_id: Some("sess-1".into()),
703 events: Vec::new(),
704 mock_calls: None,
705 },
706 AssistantTurn {
707 message: "Booked!".into(),
708 done: false,
709 usage: usage(4),
710 session_id: Some("sess-2".into()),
711 events: Vec::new(),
712 mock_calls: None,
713 },
714 ],
715 user: vec![UserTurn {
716 message: "Please book me in.".into(),
717 stop: false,
718 usage: usage(2),
719 }],
720 judge: vec![
721 JudgeVerdict {
723 value: JudgeValue::Bool(false),
724 reason: "not yet".into(),
725 usage: usage(1),
726 },
727 JudgeVerdict {
729 value: JudgeValue::Bool(true),
730 reason: "confirmed".into(),
731 usage: usage(1),
732 },
733 JudgeVerdict {
735 value: JudgeValue::Bool(true),
736 reason: "greeted".into(),
737 usage: usage(5),
738 },
739 ],
740 calls: RefCell::new(Calls::default()),
741 };
742 let config = Config::default();
743 let runner = Runner::new(&provider, &config);
744 let runs = runner.run_case(&case).unwrap();
745 assert!(runs[0].passed);
746 assert_eq!(provider.calls.borrow().assistant, 2);
748 assert_eq!(provider.calls.borrow().user, 1);
749 assert_eq!(provider.calls.borrow().judge, 3);
750 assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
753 }
754
755 struct MockingProvider {
759 records: Vec<crate::mock::MockCall>,
760 seen_rules: RefCell<Vec<Option<serde_json::Value>>>,
761 judge_calls: RefCell<usize>,
762 }
763
764 impl Provider for MockingProvider {
765 fn respond(
766 &self,
767 _platform: &str,
768 _model: &str,
769 _skill: &SkillRef<'_>,
770 _messages: &[Message],
771 _session: Option<&str>,
772 ) -> Result<AssistantTurn> {
773 unreachable!("the runner must route through respond_with_mocks")
774 }
775
776 fn respond_with_mocks(
777 &self,
778 _platform: &str,
779 _model: &str,
780 _skill: &SkillRef<'_>,
781 _messages: &[Message],
782 _session: Option<&str>,
783 mocks: Option<&crate::mock::MockPlan<'_>>,
784 ) -> Result<AssistantTurn> {
785 self.seen_rules
786 .borrow_mut()
787 .push(mocks.and_then(|p| p.rules.cloned()));
788 Ok(AssistantTurn {
789 message: "did things".into(),
790 mock_calls: mocks.map(|_| self.records.clone()),
791 ..Default::default()
792 })
793 }
794
795 fn simulate_user(
796 &self,
797 _model: &str,
798 _persona: &str,
799 _messages: &[Message],
800 ) -> Result<UserTurn> {
801 unreachable!("single-turn case")
802 }
803
804 fn judge(
805 &self,
806 _model: &str,
807 _query: &JudgeQuery<'_>,
808 _messages: &[Message],
809 ) -> Result<JudgeVerdict> {
810 *self.judge_calls.borrow_mut() += 1;
811 Ok(JudgeVerdict {
812 value: JudgeValue::Bool(true),
813 reason: "fine".into(),
814 usage: None,
815 })
816 }
817 }
818
819 fn mock_record(
820 tool: &str,
821 command: &str,
822 action: &str,
823 rule: Option<usize>,
824 ) -> crate::mock::MockCall {
825 crate::mock::MockCall {
826 tool: Some(tool.into()),
827 input: Some(serde_json::json!({ "command": command })),
828 action: action.into(),
829 rule,
830 mock: None,
831 }
832 }
833
834 #[test]
835 fn mocked_case_scores_call_evals_deterministically() {
836 let mut case = boolean_case(temp_skill("mocked"));
837 case.mocks = serde_yaml::from_str(
838 r#"
839- name: push
840 match: { tool: bash, pattern: "git push( --force)?\\b" }
841 stub: Everything up-to-date
842- name: danger
843 match: { contains: "rm -rf" }
844 deny: blocked
845- name: git
846 match: { tool: bash, pattern: "\\bgit\\b" }
847"#,
848 )
849 .unwrap();
850 case.evals = vec![
851 serde_yaml::from_str("type: called\nmock: push\ntimes: 1\n").unwrap(),
852 serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap(),
853 serde_yaml::from_str(
854 "type: called\nmock: git\nwhere: { command: { contains: status } }\n",
855 )
856 .unwrap(),
857 ];
858 let provider = MockingProvider {
859 records: vec![
860 mock_record("bash", "git push origin", "stub", Some(0)),
861 mock_record("bash", "git status", "allow", None),
862 ],
863 seen_rules: RefCell::new(Vec::new()),
864 judge_calls: RefCell::new(0),
865 };
866 let config = Config::default();
867 let runner = Runner::new(&provider, &config);
868 let report = runner.run_all(&[case]).unwrap();
869 assert!(report.passed, "all call evals hold: {report:?}");
870 assert_eq!(*provider.judge_calls.borrow(), 0);
872 let seen = provider.seen_rules.borrow();
875 let rules = seen[0].as_ref().expect("plan carried rules");
876 assert_eq!(rules["rules"].as_array().unwrap().len(), 2);
877 let run = &report.runs[0];
879 let records = run.mock_calls.as_ref().expect("channel was on");
880 assert_eq!(records[0].mock.as_deref(), Some("push"));
881 assert_eq!(records[1].mock, None);
882 }
883
884 #[test]
885 fn failing_not_called_eval_reports_the_observed_calls() {
886 let mut case = boolean_case(temp_skill("mock-violate"));
887 case.mocks = serde_yaml::from_str(
888 "- name: danger\n match: { contains: \"rm -rf\" }\n deny: blocked\n",
889 )
890 .unwrap();
891 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
892 let provider = MockingProvider {
893 records: vec![mock_record("bash", "rm -rf /", "deny", Some(0))],
894 seen_rules: RefCell::new(Vec::new()),
895 judge_calls: RefCell::new(0),
896 };
897 let config = Config::default();
898 let runner = Runner::new(&provider, &config);
899 let report = runner.run_all(&[case]).unwrap();
900 assert!(!report.passed);
901 let outcome = &report.runs[0].evals[0];
902 assert!(!outcome.passed);
903 assert!(
905 outcome.reason.contains("rm -rf /") && outcome.reason.contains("[deny]"),
906 "reason: {}",
907 outcome.reason
908 );
909 }
910
911 #[test]
912 fn call_eval_without_a_channel_is_loud_never_vacuous() {
913 let mut case = boolean_case(temp_skill("mock-nochannel"));
916 case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
917 let provider = ScriptedProvider {
918 assistant: vec![AssistantTurn {
919 message: "hi".into(),
920 ..Default::default()
921 }],
922 user: vec![],
923 judge: vec![],
924 calls: RefCell::new(Calls::default()),
925 };
926 let config = Config::default();
927 let runner = Runner::new(&provider, &config);
928 let err = runner.run_all(&[case]).unwrap_err();
929 assert!(
930 err.to_string().contains("needs the mock/spy channel"),
931 "{err}"
932 );
933 }
934
935 #[test]
936 fn spy_flag_activates_channel_without_declarations() {
937 let mut case = boolean_case(temp_skill("spy-flag"));
940 case.spy = true;
941 let provider = MockingProvider {
942 records: vec![mock_record("bash", "ls", "allow", None)],
943 seen_rules: RefCell::new(Vec::new()),
944 judge_calls: RefCell::new(0),
945 };
946 let config = Config::default();
947 let runner = Runner::new(&provider, &config);
948 let report = runner.run_all(&[case]).unwrap();
949 assert_eq!(provider.seen_rules.borrow()[0], None);
951 let records = report.runs[0].mock_calls.as_ref().unwrap();
952 assert_eq!(records.len(), 1);
953 assert_eq!(records[0].action, "allow");
954 }
955
956 #[test]
957 fn multi_turn_threads_session_when_resume_supported() {
958 #[derive(Default)]
961 struct Sessions(RefCell<Vec<Option<String>>>);
962 struct ResumeProvider {
963 sessions: Sessions,
964 }
965 impl Provider for ResumeProvider {
966 fn respond(
967 &self,
968 _platform: &str,
969 _model: &str,
970 _skill: &SkillRef<'_>,
971 _messages: &[Message],
972 session: Option<&str>,
973 ) -> Result<AssistantTurn> {
974 self.sessions
975 .0
976 .borrow_mut()
977 .push(session.map(str::to_string));
978 let n = self.sessions.0.borrow().len();
979 Ok(AssistantTurn {
980 message: format!("turn {n}"),
981 done: false,
982 usage: None,
983 session_id: Some(format!("sess-{n}")),
984 events: Vec::new(),
985 mock_calls: None,
986 })
987 }
988 fn simulate_user(
989 &self,
990 _model: &str,
991 _persona: &str,
992 _messages: &[Message],
993 ) -> Result<UserTurn> {
994 Ok(UserTurn {
995 message: "go on".into(),
996 stop: false,
997 usage: None,
998 })
999 }
1000 fn judge(
1001 &self,
1002 _model: &str,
1003 _query: &JudgeQuery<'_>,
1004 _messages: &[Message],
1005 ) -> Result<JudgeVerdict> {
1006 Ok(JudgeVerdict {
1007 value: JudgeValue::Bool(true),
1008 reason: String::new(),
1009 usage: None,
1010 })
1011 }
1012 fn supports_resume(&self, _platform: &str) -> bool {
1013 true
1014 }
1015 }
1016 let mut case = boolean_case(temp_skill("resume"));
1017 case.user = Some(crate::testcase::SimulatedUser {
1018 persona: "a patient".into(),
1019 done_when: None,
1021 max_turns: Some(2),
1022 });
1023 let provider = ResumeProvider {
1024 sessions: Sessions::default(),
1025 };
1026 let config = Config::default();
1027 let runner = Runner::new(&provider, &config);
1028 let runs = runner.run_case(&case).unwrap();
1029 assert_eq!(runs[0].turns, 2);
1030 let seen = provider.sessions.0.borrow();
1032 assert_eq!(seen[0], None);
1033 assert_eq!(seen[1].as_deref(), Some("sess-1"));
1034 }
1035}