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/// What driving one case's conversation produces, before scoring.
41struct ConverseOutcome {
42    transcript: Transcript,
43    /// Accumulated mock/spy records across turns (`None` = channel off).
44    mock_calls: Option<Vec<MockCall>>,
45    /// The provider's command to review this run's recorded history, if any.
46    history_command: Option<String>,
47    /// Whether the streaming sink asked to short-circuit the run.
48    flow: ControlFlow<()>,
49}
50
51/// Runs test cases against a provider using a configuration.
52pub struct Runner<'a> {
53    provider: &'a dyn Provider,
54    config: &'a Config,
55}
56
57impl<'a> Runner<'a> {
58    /// Build a runner.
59    #[must_use]
60    pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
61        Self { provider, config }
62    }
63
64    /// Run every supplied case across the full platform × model matrix and
65    /// collect a [`Report`].
66    ///
67    /// # Errors
68    /// Propagates the first [`crate::Error`] from loading a skill or a provider
69    /// failure. Eval *failures* are not errors — they are recorded in the report.
70    pub fn run_all(&self, cases: &[TestCase]) -> Result<Report> {
71        let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
72        self.run_all_inner(
73            cases,
74            &mut Streaming {
75                on: false,
76                sink: &mut sink,
77            },
78        )
79    }
80
81    /// Like [`Runner::run_all`], but drives each turn through
82    /// [`Provider::respond_streaming`] and delivers each skill tool event to
83    /// `on_event` the instant it is observed. `on_event` returns
84    /// [`ControlFlow::Break`] to short-circuit: the current run is torn down (the
85    /// provider kills the harness), no further runs start, and the partial
86    /// [`Report`] built so far is returned.
87    ///
88    /// # Errors
89    /// As [`Runner::run_all`].
90    pub fn run_all_streaming(
91        &self,
92        cases: &[TestCase],
93        on_event: &mut dyn FnMut(&StreamEvent) -> ControlFlow<()>,
94    ) -> Result<Report> {
95        self.run_all_inner(
96            cases,
97            &mut Streaming {
98                on: true,
99                sink: on_event,
100            },
101        )
102    }
103
104    /// The matrix loop shared by the buffered and streaming entry points.
105    /// `streaming.on` selects the buffered [`Provider::respond`] (`false`) or the
106    /// live [`Provider::respond_streaming`] (`true`) per turn.
107    fn run_all_inner(&self, cases: &[TestCase], streaming: &mut Streaming) -> Result<Report> {
108        let mut runs = Vec::new();
109        for case in cases {
110            let skill = load_skill(&case.skill)?;
111            for platform in &self.config.platforms {
112                for model in &self.config.models {
113                    let (run, flow) = self.run_case_on(case, &skill, platform, model, streaming)?;
114                    runs.push(run);
115                    if flow.is_break() {
116                        return Ok(Report::new(runs));
117                    }
118                }
119            }
120        }
121        Ok(Report::new(runs))
122    }
123
124    /// Run a single case across the matrix.
125    ///
126    /// # Errors
127    /// As [`Runner::run_all`].
128    pub fn run_case(&self, case: &TestCase) -> Result<Vec<CaseRun>> {
129        let skill = load_skill(&case.skill)?;
130        let mut runs = Vec::new();
131        let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
132        let mut streaming = Streaming {
133            on: false,
134            sink: &mut sink,
135        };
136        for platform in &self.config.platforms {
137            for model in &self.config.models {
138                let (run, _flow) =
139                    self.run_case_on(case, &skill, platform, model, &mut streaming)?;
140                runs.push(run);
141            }
142        }
143        Ok(runs)
144    }
145
146    /// Run a single case on one platform/model pair. Returns the run plus whether
147    /// the streaming sink asked to short-circuit ([`ControlFlow::Break`]).
148    fn run_case_on(
149        &self,
150        case: &TestCase,
151        skill: &SkillDefinition,
152        platform: &str,
153        model: &str,
154        streaming: &mut Streaming,
155    ) -> Result<(CaseRun, ControlFlow<()>)> {
156        let mut totals = Usage::default();
157        // The effective mock/spy set: CLI/SDK declarations first (first match
158        // wins, so the most local rule shadows), then the case's own.
159        let mock_set =
160            MockSet::build(&self.config.mocks, &case.mocks, self.config.spy || case.spy)?;
161        let ConverseOutcome {
162            transcript,
163            mock_calls,
164            history_command,
165            flow,
166        } = self.converse(
167            case,
168            skill,
169            platform,
170            model,
171            &mock_set,
172            &mut totals,
173            streaming,
174        )?;
175        let mock_calls = mock_calls.map(|records| mock_set.resolve(records));
176        // On an abort we don't spend judge calls scoring a torn-off transcript.
177        let evals = if flow.is_break() {
178            Vec::new()
179        } else {
180            self.score(
181                case,
182                &transcript,
183                &mock_set,
184                mock_calls.as_deref(),
185                &mut totals,
186            )?
187        };
188        let passed = flow.is_continue() && evals.iter().all(|e| e.passed);
189        Ok((
190            CaseRun {
191                case: case.name.clone(),
192                skill: skill.dir.to_string_lossy().into_owned(),
193                platform: platform.to_string(),
194                model: model.to_string(),
195                passed,
196                turns: transcript.assistant_turns(),
197                evals,
198                transcript,
199                usage: (!totals.is_empty()).then_some(totals),
200                mock_calls,
201                history_command,
202            },
203            flow,
204        ))
205    }
206
207    /// Drive the conversation: a single assistant turn for single-turn cases, or
208    /// a simulated-user loop for multi-turn cases. Streams each turn's tool
209    /// events to `on_event`; returns the transcript plus whether the sink asked
210    /// to short-circuit.
211    #[allow(clippy::too_many_arguments)]
212    fn converse(
213        &self,
214        case: &TestCase,
215        skill: &SkillDefinition,
216        platform: &str,
217        model: &str,
218        mock_set: &MockSet,
219        totals: &mut Usage,
220        streaming: &mut Streaming,
221    ) -> Result<ConverseOutcome> {
222        let dir = skill.dir.to_string_lossy().into_owned();
223        let skill_ref = SkillRef {
224            name: &skill.name,
225            dir: &dir,
226            instructions: &skill.instructions,
227        };
228        let judge_model = self.config.effective_judge_model();
229        let max_turns = case
230            .user
231            .as_ref()
232            .and_then(|u| u.max_turns)
233            .unwrap_or(self.config.max_turns) as usize;
234        let resume_supported = self.provider.supports_resume(platform);
235
236        let mut transcript = Transcript::from_input(&case.input);
237        // On harnesses that support it, thread the session_id from each
238        // respond into the next one so the harness keeps real state instead of
239        // being re-prompted with a stringified transcript.
240        let mut session: Option<String> = None;
241        // The mock/spy plan, handed to every skill turn (never to the judge or
242        // the simulated user), and the records accumulated across turns.
243        // `None` until some turn reports a channel, so "channel off" and
244        // "channel on, zero calls" stay distinguishable.
245        let plan = mock_set.active().then(|| MockPlan {
246            rules: mock_set.rules(),
247        });
248        let mut mock_calls: Option<Vec<MockCall>> = None;
249        // The command to review this run's recorded history (oneharness only,
250        // with history enabled). The name is stable across turns, so keeping the
251        // latest non-empty one yields a single command that covers the run.
252        let mut history_command: Option<String> = None;
253
254        loop {
255            let session_arg = if resume_supported {
256                session.as_deref()
257            } else {
258                None
259            };
260            // In streaming mode, drive the turn through `respond_streaming` and
261            // tag each event with the run it belongs to; if the sink breaks, the
262            // provider tears the harness down and returns the partial turn, and we
263            // stop the run below. The buffered path uses the plain `respond` so a
264            // non-streaming run keeps oneharness's buffered (`--compact`) contract.
265            let case_name = case.name.as_str();
266            let turn_index = transcript.assistant_turns() + 1;
267            let mut turn_flow = ControlFlow::Continue(());
268            let turn = if streaming.on {
269                let sink = &mut streaming.sink;
270                self.provider.respond_streaming_with_mocks(
271                    platform,
272                    model,
273                    &skill_ref,
274                    &transcript.messages,
275                    session_arg,
276                    plan.as_ref(),
277                    &mut |event| {
278                        let flow = sink(&StreamEvent {
279                            case: case_name,
280                            platform,
281                            model,
282                            turn: turn_index,
283                            event,
284                        });
285                        if flow.is_break() {
286                            turn_flow = ControlFlow::Break(());
287                        }
288                        flow
289                    },
290                )?
291            } else {
292                self.provider.respond_with_mocks(
293                    platform,
294                    model,
295                    &skill_ref,
296                    &transcript.messages,
297                    session_arg,
298                    plan.as_ref(),
299                )?
300            };
301            if let Some(records) = turn.mock_calls {
302                mock_calls.get_or_insert_with(Vec::new).extend(records);
303            }
304            if let Some(u) = &turn.usage {
305                totals.add(u);
306            }
307            // Capture or refresh the session handle for the next turn.
308            if let Some(id) = turn.session_id {
309                session = Some(id);
310            }
311            // Capture the review command; stable across turns of the same run.
312            if let Some(cmd) = turn.history_command {
313                history_command = Some(cmd);
314            }
315            let skill_done = turn.done;
316            // Carry the turn's normalized tool events onto its assistant message
317            // so consumers can analyze what the skill did.
318            transcript.push(Message::assistant(turn.message).with_events(turn.events));
319
320            // The streaming sink asked to short-circuit: stop the run now.
321            if turn_flow.is_break() {
322                return Ok(ConverseOutcome {
323                    transcript,
324                    mock_calls,
325                    history_command,
326                    flow: ControlFlow::Break(()),
327                });
328            }
329
330            // Single-turn cases stop after the first assistant turn.
331            let Some(user) = &case.user else {
332                break;
333            };
334
335            if skill_done || transcript.assistant_turns() >= max_turns {
336                break;
337            }
338
339            // Stop early if the configured done-condition holds.
340            if let Some(done_when) = &user.done_when {
341                let query = JudgeQuery {
342                    kind: JudgeKind::Boolean,
343                    criterion: done_when,
344                    scale: None,
345                };
346                let verdict = self
347                    .provider
348                    .judge(judge_model, &query, &transcript.messages)?;
349                if let Some(u) = &verdict.usage {
350                    totals.add(u);
351                }
352                if matches!(verdict.value, JudgeValue::Bool(true)) {
353                    break;
354                }
355            }
356
357            // Otherwise the simulated user replies and the loop continues.
358            let user_turn =
359                self.provider
360                    .simulate_user(judge_model, &user.persona, &transcript.messages)?;
361            if let Some(u) = &user_turn.usage {
362                totals.add(u);
363            }
364            let stop = user_turn.stop;
365            transcript.push(Message::user(user_turn.message));
366            if stop {
367                break;
368            }
369        }
370
371        Ok(ConverseOutcome {
372            transcript,
373            mock_calls,
374            history_command,
375            flow: ControlFlow::Continue(()),
376        })
377    }
378
379    /// Run every eval against the finished transcript: judge-backed kinds go
380    /// to the provider's judge; `called`/`not_called` are scored
381    /// deterministically against the mock/spy channel's records.
382    fn score(
383        &self,
384        case: &TestCase,
385        transcript: &Transcript,
386        mock_set: &MockSet,
387        mock_calls: Option<&[MockCall]>,
388        totals: &mut Usage,
389    ) -> Result<Vec<crate::eval::EvalOutcome>> {
390        let judge_model = self.config.effective_judge_model();
391        let mut outcomes = Vec::with_capacity(case.evals.len());
392        for eval in &case.evals {
393            let query = match eval {
394                Eval::Boolean(BooleanEval { criterion, .. }) => JudgeQuery {
395                    kind: JudgeKind::Boolean,
396                    criterion,
397                    scale: None,
398                },
399                Eval::Numeric(NumericEval {
400                    criterion,
401                    min,
402                    max,
403                    ..
404                }) => JudgeQuery {
405                    kind: JudgeKind::Numeric,
406                    criterion,
407                    scale: Some((*min, *max)),
408                },
409                Eval::Called(CalledEval { mock, r#where, .. })
410                | Eval::NotCalled(NotCalledEval { mock, r#where, .. }) => {
411                    // Deterministic: no judge call. A missing channel is loud —
412                    // a `not_called` scored against nothing must never pass.
413                    let records = mock_calls.ok_or_else(|| {
414                        Error::Invalid(format!(
415                            "eval `{}` needs the mock/spy channel, but the provider reported no                              observations for this run",
416                            eval.label()
417                        ))
418                    })?;
419                    let matching = mock_set
420                        .records_for(mock, records)?
421                        .into_iter()
422                        .filter(|r| where_matches(r#where, r.input.as_ref()))
423                        .count();
424                    outcomes.push(eval.outcome_for_calls(matching, &describe_records(records))?);
425                    continue;
426                }
427            };
428            let verdict = self
429                .provider
430                .judge(judge_model, &query, &transcript.messages)?;
431            if let Some(u) = &verdict.usage {
432                totals.add(u);
433            }
434            outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
435        }
436        Ok(outcomes)
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443    use crate::conversation::Message;
444    use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
445    use std::cell::RefCell;
446
447    /// An in-memory provider scripted with canned turns and verdicts, so the
448    /// runner's orchestration can be tested without any subprocess.
449    struct ScriptedProvider {
450        assistant: Vec<AssistantTurn>,
451        user: Vec<UserTurn>,
452        judge: Vec<JudgeVerdict>,
453        calls: RefCell<Calls>,
454    }
455
456    #[derive(Default)]
457    struct Calls {
458        assistant: usize,
459        user: usize,
460        judge: usize,
461    }
462
463    impl Provider for ScriptedProvider {
464        fn respond(
465            &self,
466            _platform: &str,
467            _model: &str,
468            _skill: &SkillRef<'_>,
469            _messages: &[Message],
470            _session: Option<&str>,
471        ) -> Result<AssistantTurn> {
472            let i = self.calls.borrow().assistant;
473            self.calls.borrow_mut().assistant += 1;
474            Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
475        }
476
477        fn simulate_user(
478            &self,
479            _model: &str,
480            _persona: &str,
481            _messages: &[Message],
482        ) -> Result<UserTurn> {
483            let i = self.calls.borrow().user;
484            self.calls.borrow_mut().user += 1;
485            Ok(self.user[i.min(self.user.len() - 1)].clone())
486        }
487
488        fn judge(
489            &self,
490            _model: &str,
491            _query: &JudgeQuery<'_>,
492            _messages: &[Message],
493        ) -> Result<JudgeVerdict> {
494            let i = self.calls.borrow().judge;
495            self.calls.borrow_mut().judge += 1;
496            let v = &self.judge[i.min(self.judge.len() - 1)];
497            Ok(JudgeVerdict {
498                value: v.value,
499                reason: v.reason.clone(),
500                usage: v.usage.clone(),
501            })
502        }
503    }
504
505    /// Create a throwaway skill directory with a minimal SKILL.md so the runner
506    /// (which loads the skill from disk) has something real to read.
507    fn temp_skill(tag: &str) -> std::path::PathBuf {
508        let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
509        std::fs::create_dir_all(&dir).unwrap();
510        std::fs::write(
511            dir.join("SKILL.md"),
512            "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
513        )
514        .unwrap();
515        dir
516    }
517
518    fn boolean_case(skill: std::path::PathBuf) -> TestCase {
519        TestCase {
520            name: "greets".into(),
521            skill,
522            input: "Greet Dr. Smith".into(),
523            user: None,
524            mocks: Vec::new(),
525            spy: false,
526            evals: vec![Eval::Boolean(BooleanEval {
527                criterion: "greets Dr. Smith".into(),
528                expected: true,
529                name: None,
530            })],
531        }
532    }
533
534    #[test]
535    fn single_turn_runs_one_assistant_turn_and_scores() {
536        let provider = ScriptedProvider {
537            assistant: vec![AssistantTurn {
538                message: "Hello, Dr. Smith!".into(),
539                done: false,
540                ..Default::default()
541            }],
542            user: vec![],
543            judge: vec![JudgeVerdict {
544                value: JudgeValue::Bool(true),
545                reason: "names her".into(),
546                usage: None,
547            }],
548            calls: RefCell::new(Calls::default()),
549        };
550        let config = Config::default();
551        let runner = Runner::new(&provider, &config);
552        let runs = runner
553            .run_case(&boolean_case(temp_skill("single")))
554            .unwrap();
555        assert_eq!(runs.len(), 1);
556        assert!(runs[0].passed);
557        assert_eq!(runs[0].turns, 1);
558        assert_eq!(provider.calls.borrow().assistant, 1);
559        // No provider history → the run carries no review command.
560        assert!(runs[0].history_command.is_none());
561    }
562
563    #[test]
564    fn history_command_from_a_turn_lands_on_the_run() {
565        // A provider that records history surfaces a review command on the turn;
566        // the runner lifts it onto the run so a past run can be reviewed.
567        let provider = ScriptedProvider {
568            assistant: vec![AssistantTurn {
569                message: "Hello, Dr. Smith!".into(),
570                history_command: Some("oneharness history show sess --history-dir /h".into()),
571                ..Default::default()
572            }],
573            user: vec![],
574            judge: vec![JudgeVerdict {
575                value: JudgeValue::Bool(true),
576                reason: "names her".into(),
577                usage: None,
578            }],
579            calls: RefCell::new(Calls::default()),
580        };
581        let config = Config::default();
582        let runner = Runner::new(&provider, &config);
583        let runs = runner
584            .run_case(&boolean_case(temp_skill("history")))
585            .unwrap();
586        assert_eq!(
587            runs[0].history_command.as_deref(),
588            Some("oneharness history show sess --history-dir /h"),
589        );
590    }
591
592    #[test]
593    fn multi_turn_run_surfaces_one_history_command() {
594        // Across turns the provider reports the same (stable-name) command; the
595        // run surfaces exactly one, covering the whole multi-turn conversation.
596        let mut case = boolean_case(temp_skill("history-multi"));
597        case.user = Some(crate::testcase::SimulatedUser {
598            persona: "a patient".into(),
599            done_when: None,
600            max_turns: Some(2),
601        });
602        let cmd = "oneharness history show skilltest-p-m-x-1a2b --history-dir /h";
603        let provider = ScriptedProvider {
604            assistant: vec![
605                AssistantTurn {
606                    message: "turn 1".into(),
607                    history_command: Some(cmd.into()),
608                    ..Default::default()
609                },
610                AssistantTurn {
611                    message: "turn 2".into(),
612                    history_command: Some(cmd.into()),
613                    ..Default::default()
614                },
615            ],
616            user: vec![UserTurn {
617                message: "go on".into(),
618                stop: false,
619                ..Default::default()
620            }],
621            judge: vec![JudgeVerdict {
622                value: JudgeValue::Bool(true),
623                reason: String::new(),
624                usage: None,
625            }],
626            calls: RefCell::new(Calls::default()),
627        };
628        let config = Config::default();
629        let runner = Runner::new(&provider, &config);
630        let runs = runner.run_case(&case).unwrap();
631        assert_eq!(runs[0].turns, 2);
632        assert_eq!(runs[0].history_command.as_deref(), Some(cmd));
633    }
634
635    #[test]
636    fn multi_turn_stops_when_done_when_holds() {
637        let mut case = boolean_case(temp_skill("multi"));
638        case.user = Some(crate::testcase::SimulatedUser {
639            persona: "a terse patient".into(),
640            done_when: Some("the assistant has greeted".into()),
641            max_turns: Some(5),
642        });
643        let provider = ScriptedProvider {
644            assistant: vec![AssistantTurn {
645                message: "Hi there".into(),
646                done: false,
647                ..Default::default()
648            }],
649            user: vec![UserTurn {
650                message: "continue".into(),
651                stop: false,
652                ..Default::default()
653            }],
654            // First judge call is the done_when check (true -> stop), second is
655            // the eval.
656            judge: vec![
657                JudgeVerdict {
658                    value: JudgeValue::Bool(true),
659                    reason: "done".into(),
660                    usage: None,
661                },
662                JudgeVerdict {
663                    value: JudgeValue::Bool(true),
664                    reason: "greeted".into(),
665                    usage: None,
666                },
667            ],
668            calls: RefCell::new(Calls::default()),
669        };
670        let config = Config::default();
671        let runner = Runner::new(&provider, &config);
672        let runs = runner.run_case(&case).unwrap();
673        assert!(runs[0].passed);
674        // One assistant turn, the simulated user never had to speak.
675        assert_eq!(provider.calls.borrow().assistant, 1);
676        assert_eq!(provider.calls.borrow().user, 0);
677    }
678
679    #[test]
680    fn failing_eval_marks_run_failed() {
681        let provider = ScriptedProvider {
682            assistant: vec![AssistantTurn {
683                message: "Hello".into(),
684                done: false,
685                ..Default::default()
686            }],
687            user: vec![],
688            judge: vec![JudgeVerdict {
689                value: JudgeValue::Bool(false),
690                reason: "no name".into(),
691                usage: None,
692            }],
693            calls: RefCell::new(Calls::default()),
694        };
695        let config = Config::default();
696        let runner = Runner::new(&provider, &config);
697        let report = runner
698            .run_all(&[boolean_case(temp_skill("faileval"))])
699            .unwrap();
700        assert!(!report.passed);
701        assert_eq!(report.summary.failed, 1);
702    }
703
704    #[test]
705    fn matrix_fans_out_over_platforms_and_models() {
706        let provider = ScriptedProvider {
707            assistant: vec![AssistantTurn {
708                message: "Hello".into(),
709                done: false,
710                ..Default::default()
711            }],
712            user: vec![],
713            judge: vec![JudgeVerdict {
714                value: JudgeValue::Bool(true),
715                reason: String::new(),
716                usage: None,
717            }],
718            calls: RefCell::new(Calls::default()),
719        };
720        let config = Config {
721            platforms: vec!["a".into(), "b".into()],
722            models: vec!["m1".into(), "m2".into()],
723            ..Config::default()
724        };
725        let runner = Runner::new(&provider, &config);
726        let runs = runner
727            .run_case(&boolean_case(temp_skill("matrix")))
728            .unwrap();
729        assert_eq!(runs.len(), 4);
730    }
731
732    #[test]
733    fn run_all_streaming_short_circuits_on_break() {
734        use crate::conversation::ToolEvent;
735        // A turn that took a disallowed action; the streaming sink breaks on it.
736        let provider = ScriptedProvider {
737            assistant: vec![AssistantTurn {
738                message: "did a bad thing".into(),
739                done: false,
740                usage: None,
741                session_id: None,
742                mock_calls: None,
743                events: vec![ToolEvent {
744                    kind: "tool_call".into(),
745                    name: Some("bash".into()),
746                    input: Some(serde_json::json!({ "command": "rm -rf /" })),
747                    output: None,
748                    index: 0,
749                }],
750                history_command: None,
751            }],
752            user: vec![],
753            judge: vec![JudgeVerdict {
754                value: JudgeValue::Bool(true),
755                reason: String::new(),
756                usage: None,
757            }],
758            calls: RefCell::new(Calls::default()),
759        };
760        let config = Config {
761            platforms: vec!["a".into(), "b".into()],
762            ..Config::default()
763        };
764        let runner = Runner::new(&provider, &config);
765        let mut seen = 0usize;
766        let report = runner
767            .run_all_streaming(
768                &[boolean_case(temp_skill("stream-abort"))],
769                &mut |ev: &StreamEvent| {
770                    seen += 1;
771                    assert_eq!(ev.event.name.as_deref(), Some("bash"));
772                    assert_eq!(ev.turn, 1);
773                    ControlFlow::Break(())
774                },
775            )
776            .unwrap();
777        // The sink saw the first event and aborted; only the first matrix cell
778        // ran, and it is not passing.
779        assert_eq!(seen, 1);
780        assert_eq!(report.runs.len(), 1);
781        assert!(!report.runs[0].passed);
782        // No judge call was spent scoring the torn-off run.
783        assert_eq!(provider.calls.borrow().judge, 0);
784    }
785
786    fn usage(input: u64) -> Option<Usage> {
787        Some(Usage {
788            input_tokens: Some(input),
789            output_tokens: None,
790            cost_usd: None,
791        })
792    }
793
794    #[test]
795    fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
796        // No early stop: the done_when check returns false, so the simulated
797        // user speaks after turn 1, then turn 2 satisfies done_when and the loop
798        // ends. Every provider call reports usage, so totals must accumulate.
799        let mut case = boolean_case(temp_skill("loop"));
800        case.user = Some(crate::testcase::SimulatedUser {
801            persona: "a chatty patient".into(),
802            done_when: Some("the booking is confirmed".into()),
803            max_turns: Some(8),
804        });
805        let provider = ScriptedProvider {
806            assistant: vec![
807                AssistantTurn {
808                    message: "Hello, how can I help?".into(),
809                    done: false,
810                    usage: usage(3),
811                    // A session id the runner should capture for the next turn.
812                    session_id: Some("sess-1".into()),
813                    events: Vec::new(),
814                    mock_calls: None,
815                    history_command: None,
816                },
817                AssistantTurn {
818                    message: "Booked!".into(),
819                    done: false,
820                    usage: usage(4),
821                    session_id: Some("sess-2".into()),
822                    events: Vec::new(),
823                    mock_calls: None,
824                    history_command: None,
825                },
826            ],
827            user: vec![UserTurn {
828                message: "Please book me in.".into(),
829                stop: false,
830                usage: usage(2),
831            }],
832            judge: vec![
833                // done_when after assistant turn 1 -> not done yet.
834                JudgeVerdict {
835                    value: JudgeValue::Bool(false),
836                    reason: "not yet".into(),
837                    usage: usage(1),
838                },
839                // done_when after assistant turn 2 -> done, the loop ends.
840                JudgeVerdict {
841                    value: JudgeValue::Bool(true),
842                    reason: "confirmed".into(),
843                    usage: usage(1),
844                },
845                // The final eval.
846                JudgeVerdict {
847                    value: JudgeValue::Bool(true),
848                    reason: "greeted".into(),
849                    usage: usage(5),
850                },
851            ],
852            calls: RefCell::new(Calls::default()),
853        };
854        let config = Config::default();
855        let runner = Runner::new(&provider, &config);
856        let runs = runner.run_case(&case).unwrap();
857        assert!(runs[0].passed);
858        // Two assistant turns; the user spoke exactly once between them.
859        assert_eq!(provider.calls.borrow().assistant, 2);
860        assert_eq!(provider.calls.borrow().user, 1);
861        assert_eq!(provider.calls.borrow().judge, 3);
862        // Usage across every call:
863        // 3(resp) + 1(done_when) + 2(user) + 4(resp) + 1(done_when) + 5(eval) = 16.
864        assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
865    }
866
867    /// A provider with mock support: records the plan it was handed and
868    /// returns scripted mock records, so the runner's threading, resolution,
869    /// and deterministic scoring can be tested without a subprocess.
870    struct MockingProvider {
871        records: Vec<crate::mock::MockCall>,
872        seen_rules: RefCell<Vec<Option<serde_json::Value>>>,
873        judge_calls: RefCell<usize>,
874    }
875
876    impl Provider for MockingProvider {
877        fn respond(
878            &self,
879            _platform: &str,
880            _model: &str,
881            _skill: &SkillRef<'_>,
882            _messages: &[Message],
883            _session: Option<&str>,
884        ) -> Result<AssistantTurn> {
885            unreachable!("the runner must route through respond_with_mocks")
886        }
887
888        fn respond_with_mocks(
889            &self,
890            _platform: &str,
891            _model: &str,
892            _skill: &SkillRef<'_>,
893            _messages: &[Message],
894            _session: Option<&str>,
895            mocks: Option<&crate::mock::MockPlan<'_>>,
896        ) -> Result<AssistantTurn> {
897            self.seen_rules
898                .borrow_mut()
899                .push(mocks.and_then(|p| p.rules.cloned()));
900            Ok(AssistantTurn {
901                message: "did things".into(),
902                mock_calls: mocks.map(|_| self.records.clone()),
903                ..Default::default()
904            })
905        }
906
907        fn simulate_user(
908            &self,
909            _model: &str,
910            _persona: &str,
911            _messages: &[Message],
912        ) -> Result<UserTurn> {
913            unreachable!("single-turn case")
914        }
915
916        fn judge(
917            &self,
918            _model: &str,
919            _query: &JudgeQuery<'_>,
920            _messages: &[Message],
921        ) -> Result<JudgeVerdict> {
922            *self.judge_calls.borrow_mut() += 1;
923            Ok(JudgeVerdict {
924                value: JudgeValue::Bool(true),
925                reason: "fine".into(),
926                usage: None,
927            })
928        }
929    }
930
931    fn mock_record(
932        tool: &str,
933        command: &str,
934        action: &str,
935        rule: Option<usize>,
936    ) -> crate::mock::MockCall {
937        crate::mock::MockCall {
938            tool: Some(tool.into()),
939            input: Some(serde_json::json!({ "command": command })),
940            action: action.into(),
941            rule,
942            mock: None,
943        }
944    }
945
946    #[test]
947    fn mocked_case_scores_call_evals_deterministically() {
948        let mut case = boolean_case(temp_skill("mocked"));
949        case.mocks = serde_yaml::from_str(
950            r#"
951- name: push
952  match: { tool: bash, pattern: "git push( --force)?\\b" }
953  stub: Everything up-to-date
954- name: danger
955  match: { contains: "rm -rf" }
956  deny: blocked
957- name: git
958  match: { tool: bash, pattern: "\\bgit\\b" }
959"#,
960        )
961        .unwrap();
962        case.evals = vec![
963            serde_yaml::from_str("type: called\nmock: push\ntimes: 1\n").unwrap(),
964            serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap(),
965            serde_yaml::from_str(
966                "type: called\nmock: git\nwhere: { command: { contains: status } }\n",
967            )
968            .unwrap(),
969        ];
970        let provider = MockingProvider {
971            records: vec![
972                mock_record("bash", "git push origin", "stub", Some(0)),
973                mock_record("bash", "git status", "allow", None),
974            ],
975            seen_rules: RefCell::new(Vec::new()),
976            judge_calls: RefCell::new(0),
977        };
978        let config = Config::default();
979        let runner = Runner::new(&provider, &config);
980        let report = runner.run_all(&[case]).unwrap();
981        assert!(report.passed, "all call evals hold: {report:?}");
982        // No judge was ever consulted — the call evals are deterministic.
983        assert_eq!(*provider.judge_calls.borrow(), 0);
984        // The provider received the compiled ruleset (two action rules; the
985        // spy is matched locally, never compiled).
986        let seen = provider.seen_rules.borrow();
987        let rules = seen[0].as_ref().expect("plan carried rules");
988        assert_eq!(rules["rules"].as_array().unwrap().len(), 2);
989        // The report's records got their mock names resolved.
990        let run = &report.runs[0];
991        let records = run.mock_calls.as_ref().expect("channel was on");
992        assert_eq!(records[0].mock.as_deref(), Some("push"));
993        assert_eq!(records[1].mock, None);
994    }
995
996    #[test]
997    fn failing_not_called_eval_reports_the_observed_calls() {
998        let mut case = boolean_case(temp_skill("mock-violate"));
999        case.mocks = serde_yaml::from_str(
1000            "- name: danger\n  match: { contains: \"rm -rf\" }\n  deny: blocked\n",
1001        )
1002        .unwrap();
1003        case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
1004        let provider = MockingProvider {
1005            records: vec![mock_record("bash", "rm -rf /", "deny", Some(0))],
1006            seen_rules: RefCell::new(Vec::new()),
1007            judge_calls: RefCell::new(0),
1008        };
1009        let config = Config::default();
1010        let runner = Runner::new(&provider, &config);
1011        let report = runner.run_all(&[case]).unwrap();
1012        assert!(!report.passed);
1013        let outcome = &report.runs[0].evals[0];
1014        assert!(!outcome.passed);
1015        // The failure reason lists what actually ran, verdict included.
1016        assert!(
1017            outcome.reason.contains("rm -rf /") && outcome.reason.contains("[deny]"),
1018            "reason: {}",
1019            outcome.reason
1020        );
1021    }
1022
1023    #[test]
1024    fn call_eval_without_a_channel_is_loud_never_vacuous() {
1025        // A `not_called` eval on a case with no mocks/spy: there is no
1026        // observation channel, so the run must error, not pass on zero records.
1027        let mut case = boolean_case(temp_skill("mock-nochannel"));
1028        case.evals = vec![serde_yaml::from_str("type: not_called\nmock: danger\n").unwrap()];
1029        let provider = ScriptedProvider {
1030            assistant: vec![AssistantTurn {
1031                message: "hi".into(),
1032                ..Default::default()
1033            }],
1034            user: vec![],
1035            judge: vec![],
1036            calls: RefCell::new(Calls::default()),
1037        };
1038        let config = Config::default();
1039        let runner = Runner::new(&provider, &config);
1040        let err = runner.run_all(&[case]).unwrap_err();
1041        assert!(
1042            err.to_string().contains("needs the mock/spy channel"),
1043            "{err}"
1044        );
1045    }
1046
1047    #[test]
1048    fn spy_flag_activates_channel_without_declarations() {
1049        // `spy: true` turns the channel on with no mocks: the plan carries no
1050        // rules, and the records land on the run for SDK spies to bind.
1051        let mut case = boolean_case(temp_skill("spy-flag"));
1052        case.spy = true;
1053        let provider = MockingProvider {
1054            records: vec![mock_record("bash", "ls", "allow", None)],
1055            seen_rules: RefCell::new(Vec::new()),
1056            judge_calls: RefCell::new(0),
1057        };
1058        let config = Config::default();
1059        let runner = Runner::new(&provider, &config);
1060        let report = runner.run_all(&[case]).unwrap();
1061        // The plan was present but rule-less.
1062        assert_eq!(provider.seen_rules.borrow()[0], None);
1063        let records = report.runs[0].mock_calls.as_ref().unwrap();
1064        assert_eq!(records.len(), 1);
1065        assert_eq!(records[0].action, "allow");
1066    }
1067
1068    #[test]
1069    fn multi_turn_threads_session_when_resume_supported() {
1070        // A provider that supports resume should be handed the session id the
1071        // previous respond returned. Capture the session arg each respond sees.
1072        #[derive(Default)]
1073        struct Sessions(RefCell<Vec<Option<String>>>);
1074        struct ResumeProvider {
1075            sessions: Sessions,
1076        }
1077        impl Provider for ResumeProvider {
1078            fn respond(
1079                &self,
1080                _platform: &str,
1081                _model: &str,
1082                _skill: &SkillRef<'_>,
1083                _messages: &[Message],
1084                session: Option<&str>,
1085            ) -> Result<AssistantTurn> {
1086                self.sessions
1087                    .0
1088                    .borrow_mut()
1089                    .push(session.map(str::to_string));
1090                let n = self.sessions.0.borrow().len();
1091                Ok(AssistantTurn {
1092                    message: format!("turn {n}"),
1093                    done: false,
1094                    usage: None,
1095                    session_id: Some(format!("sess-{n}")),
1096                    events: Vec::new(),
1097                    mock_calls: None,
1098                    history_command: None,
1099                })
1100            }
1101            fn simulate_user(
1102                &self,
1103                _model: &str,
1104                _persona: &str,
1105                _messages: &[Message],
1106            ) -> Result<UserTurn> {
1107                Ok(UserTurn {
1108                    message: "go on".into(),
1109                    stop: false,
1110                    usage: None,
1111                })
1112            }
1113            fn judge(
1114                &self,
1115                _model: &str,
1116                _query: &JudgeQuery<'_>,
1117                _messages: &[Message],
1118            ) -> Result<JudgeVerdict> {
1119                Ok(JudgeVerdict {
1120                    value: JudgeValue::Bool(true),
1121                    reason: String::new(),
1122                    usage: None,
1123                })
1124            }
1125            fn supports_resume(&self, _platform: &str) -> bool {
1126                true
1127            }
1128        }
1129        let mut case = boolean_case(temp_skill("resume"));
1130        case.user = Some(crate::testcase::SimulatedUser {
1131            persona: "a patient".into(),
1132            // No done_when, so the loop runs to max_turns.
1133            done_when: None,
1134            max_turns: Some(2),
1135        });
1136        let provider = ResumeProvider {
1137            sessions: Sessions::default(),
1138        };
1139        let config = Config::default();
1140        let runner = Runner::new(&provider, &config);
1141        let runs = runner.run_case(&case).unwrap();
1142        assert_eq!(runs[0].turns, 2);
1143        // First respond saw no session; the second saw the id from the first.
1144        let seen = provider.sessions.0.borrow();
1145        assert_eq!(seen[0], None);
1146        assert_eq!(seen[1].as_deref(), Some("sess-1"));
1147    }
1148}