Skip to main content

skilltest_core/
runner.rs

1//! The runner: orchestrates a test case into a conversation, drives the
2//! provider across turns, scores the transcript with evals, and fans out over
3//! the configured platform × model matrix.
4
5use 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
17/// One streamed tool event, tagged with the run it belongs to, delivered live to
18/// a [`Runner::run_all_streaming`] sink so a consumer can watch what a skill does
19/// and short-circuit.
20pub struct StreamEvent<'a> {
21    /// The test case's name.
22    pub case: &'a str,
23    /// The platform (harness) under test.
24    pub platform: &'a str,
25    /// The model under test.
26    pub model: &'a str,
27    /// 1-based assistant-turn index within this run.
28    pub turn: usize,
29    /// The normalized tool event.
30    pub event: &'a ToolEvent,
31}
32
33/// The streaming knobs threaded through a run: whether to drive turns live (via
34/// [`Provider::respond_streaming`]) and the sink each tool event is delivered to.
35struct Streaming<'s> {
36    on: bool,
37    sink: &'s mut (dyn FnMut(&StreamEvent) -> ControlFlow<()> + 's),
38}
39
40/// Runs test cases against a provider using a configuration.
41pub struct Runner<'a> {
42    provider: &'a dyn Provider,
43    config: &'a Config,
44}
45
46impl<'a> Runner<'a> {
47    /// Build a runner.
48    #[must_use]
49    pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
50        Self { provider, config }
51    }
52
53    /// Run every supplied case across the full platform × model matrix and
54    /// collect a [`Report`].
55    ///
56    /// # Errors
57    /// Propagates the first [`crate::Error`] from loading a skill or a provider
58    /// failure. Eval *failures* are not errors — they are recorded in the report.
59    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    /// Like [`Runner::run_all`], but drives each turn through
71    /// [`Provider::respond_streaming`] and delivers each skill tool event to
72    /// `on_event` the instant it is observed. `on_event` returns
73    /// [`ControlFlow::Break`] to short-circuit: the current run is torn down (the
74    /// provider kills the harness), no further runs start, and the partial
75    /// [`Report`] built so far is returned.
76    ///
77    /// # Errors
78    /// As [`Runner::run_all`].
79    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    /// The matrix loop shared by the buffered and streaming entry points.
94    /// `streaming.on` selects the buffered [`Provider::respond`] (`false`) or the
95    /// live [`Provider::respond_streaming`] (`true`) per turn.
96    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    /// Run a single case across the matrix.
114    ///
115    /// # Errors
116    /// As [`Runner::run_all`].
117    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    /// Run a single case on one platform/model pair. Returns the run plus whether
136    /// the streaming sink asked to short-circuit ([`ControlFlow::Break`]).
137    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        // The effective mock/spy set: CLI/SDK declarations first (first match
147        // wins, so the most local rule shadows), then the case's own.
148        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        // On an abort we don't spend judge calls scoring a torn-off transcript.
161        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    /// Drive the conversation: a single assistant turn for single-turn cases, or
191    /// a simulated-user loop for multi-turn cases. Streams each turn's tool
192    /// events to `on_event`; returns the transcript plus whether the sink asked
193    /// to short-circuit.
194    #[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        // On harnesses that support it, thread the session_id from each
221        // respond into the next one so the harness keeps real state instead of
222        // being re-prompted with a stringified transcript.
223        let mut session: Option<String> = None;
224        // The mock/spy plan, handed to every skill turn (never to the judge or
225        // the simulated user), and the records accumulated across turns.
226        // `None` until some turn reports a channel, so "channel off" and
227        // "channel on, zero calls" stay distinguishable.
228        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            // In streaming mode, drive the turn through `respond_streaming` and
240            // tag each event with the run it belongs to; if the sink breaks, the
241            // provider tears the harness down and returns the partial turn, and we
242            // stop the run below. The buffered path uses the plain `respond` so a
243            // non-streaming run keeps oneharness's buffered (`--compact`) contract.
244            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            // Capture or refresh the session handle for the next turn.
287            if let Some(id) = turn.session_id {
288                session = Some(id);
289            }
290            let skill_done = turn.done;
291            // Carry the turn's normalized tool events onto its assistant message
292            // so consumers can analyze what the skill did.
293            transcript.push(Message::assistant(turn.message).with_events(turn.events));
294
295            // The streaming sink asked to short-circuit: stop the run now.
296            if turn_flow.is_break() {
297                return Ok((transcript, mock_calls, ControlFlow::Break(())));
298            }
299
300            // Single-turn cases stop after the first assistant turn.
301            let Some(user) = &case.user else {
302                break;
303            };
304
305            if skill_done || transcript.assistant_turns() >= max_turns {
306                break;
307            }
308
309            // Stop early if the configured done-condition holds.
310            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            // Otherwise the simulated user replies and the loop continues.
328            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    /// Run every eval against the finished transcript: judge-backed kinds go
345    /// to the provider's judge; `called`/`not_called` are scored
346    /// deterministically against the mock/spy channel's records.
347    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                    // Deterministic: no judge call. A missing channel is loud —
376                    // a `not_called` scored against nothing must never pass.
377                    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    /// An in-memory provider scripted with canned turns and verdicts, so the
412    /// runner's orchestration can be tested without any subprocess.
413    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    /// Create a throwaway skill directory with a minimal SKILL.md so the runner
470    /// (which loads the skill from disk) has something real to read.
471    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            // First judge call is the done_when check (true -> stop), second is
545            // the eval.
546            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        // One assistant turn, the simulated user never had to speak.
565        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        // A turn that took a disallowed action; the streaming sink breaks on it.
626        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        // The sink saw the first event and aborted; only the first matrix cell
667        // ran, and it is not passing.
668        assert_eq!(seen, 1);
669        assert_eq!(report.runs.len(), 1);
670        assert!(!report.runs[0].passed);
671        // No judge call was spent scoring the torn-off run.
672        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        // No early stop: the done_when check returns false, so the simulated
686        // user speaks after turn 1, then turn 2 satisfies done_when and the loop
687        // ends. Every provider call reports usage, so totals must accumulate.
688        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                    // A session id the runner should capture for the next turn.
701                    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                // done_when after assistant turn 1 -> not done yet.
721                JudgeVerdict {
722                    value: JudgeValue::Bool(false),
723                    reason: "not yet".into(),
724                    usage: usage(1),
725                },
726                // done_when after assistant turn 2 -> done, the loop ends.
727                JudgeVerdict {
728                    value: JudgeValue::Bool(true),
729                    reason: "confirmed".into(),
730                    usage: usage(1),
731                },
732                // The final eval.
733                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        // Two assistant turns; the user spoke exactly once between them.
746        assert_eq!(provider.calls.borrow().assistant, 2);
747        assert_eq!(provider.calls.borrow().user, 1);
748        assert_eq!(provider.calls.borrow().judge, 3);
749        // Usage across every call:
750        // 3(resp) + 1(done_when) + 2(user) + 4(resp) + 1(done_when) + 5(eval) = 16.
751        assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
752    }
753
754    /// A provider with mock support: records the plan it was handed and
755    /// returns scripted mock records, so the runner's threading, resolution,
756    /// and deterministic scoring can be tested without a subprocess.
757    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        // No judge was ever consulted — the call evals are deterministic.
870        assert_eq!(*provider.judge_calls.borrow(), 0);
871        // The provider received the compiled ruleset (two action rules; the
872        // spy is matched locally, never compiled).
873        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        // The report's records got their mock names resolved.
877        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        // The failure reason lists what actually ran, verdict included.
903        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        // A `not_called` eval on a case with no mocks/spy: there is no
913        // observation channel, so the run must error, not pass on zero records.
914        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        // `spy: true` turns the channel on with no mocks: the plan carries no
937        // rules, and the records land on the run for SDK spies to bind.
938        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        // The plan was present but rule-less.
949        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        // A provider that supports resume should be handed the session id the
958        // previous respond returned. Capture the session arg each respond sees.
959        #[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            // No done_when, so the loop runs to max_turns.
1019            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        // First respond saw no session; the second saw the id from the first.
1030        let seen = provider.sessions.0.borrow();
1031        assert_eq!(seen[0], None);
1032        assert_eq!(seen[1].as_deref(), Some("sess-1"));
1033    }
1034}