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::{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
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(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                    // Deterministic: no judge call. A missing channel is loud —
377                    // a `not_called` scored against nothing must never pass.
378                    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    /// An in-memory provider scripted with canned turns and verdicts, so the
413    /// runner's orchestration can be tested without any subprocess.
414    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    /// Create a throwaway skill directory with a minimal SKILL.md so the runner
471    /// (which loads the skill from disk) has something real to read.
472    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            // First judge call is the done_when check (true -> stop), second is
546            // the eval.
547            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        // One assistant turn, the simulated user never had to speak.
566        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        // A turn that took a disallowed action; the streaming sink breaks on it.
627        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        // The sink saw the first event and aborted; only the first matrix cell
668        // ran, and it is not passing.
669        assert_eq!(seen, 1);
670        assert_eq!(report.runs.len(), 1);
671        assert!(!report.runs[0].passed);
672        // No judge call was spent scoring the torn-off run.
673        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        // No early stop: the done_when check returns false, so the simulated
687        // user speaks after turn 1, then turn 2 satisfies done_when and the loop
688        // ends. Every provider call reports usage, so totals must accumulate.
689        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                    // A session id the runner should capture for the next turn.
702                    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                // done_when after assistant turn 1 -> not done yet.
722                JudgeVerdict {
723                    value: JudgeValue::Bool(false),
724                    reason: "not yet".into(),
725                    usage: usage(1),
726                },
727                // done_when after assistant turn 2 -> done, the loop ends.
728                JudgeVerdict {
729                    value: JudgeValue::Bool(true),
730                    reason: "confirmed".into(),
731                    usage: usage(1),
732                },
733                // The final eval.
734                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        // Two assistant turns; the user spoke exactly once between them.
747        assert_eq!(provider.calls.borrow().assistant, 2);
748        assert_eq!(provider.calls.borrow().user, 1);
749        assert_eq!(provider.calls.borrow().judge, 3);
750        // Usage across every call:
751        // 3(resp) + 1(done_when) + 2(user) + 4(resp) + 1(done_when) + 5(eval) = 16.
752        assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
753    }
754
755    /// A provider with mock support: records the plan it was handed and
756    /// returns scripted mock records, so the runner's threading, resolution,
757    /// and deterministic scoring can be tested without a subprocess.
758    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        // No judge was ever consulted — the call evals are deterministic.
871        assert_eq!(*provider.judge_calls.borrow(), 0);
872        // The provider received the compiled ruleset (two action rules; the
873        // spy is matched locally, never compiled).
874        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        // The report's records got their mock names resolved.
878        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        // The failure reason lists what actually ran, verdict included.
904        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        // A `not_called` eval on a case with no mocks/spy: there is no
914        // observation channel, so the run must error, not pass on zero records.
915        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        // `spy: true` turns the channel on with no mocks: the plan carries no
938        // rules, and the records land on the run for SDK spies to bind.
939        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        // The plan was present but rule-less.
950        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        // A provider that supports resume should be handed the session id the
959        // previous respond returned. Capture the session arg each respond sees.
960        #[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            // No done_when, so the loop runs to max_turns.
1020            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        // First respond saw no session; the second saw the id from the first.
1031        let seen = provider.sessions.0.borrow();
1032        assert_eq!(seen[0], None);
1033        assert_eq!(seen[1].as_deref(), Some("sess-1"));
1034    }
1035}