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::Result;
10use crate::eval::{Eval, JudgeValue};
11use crate::provider::{JudgeKind, JudgeQuery, Provider, SkillRef, Usage};
12use crate::report::{CaseRun, Report};
13use crate::skill::{load_skill, SkillDefinition};
14use crate::testcase::TestCase;
15
16/// One streamed tool event, tagged with the run it belongs to, delivered live to
17/// a [`Runner::run_all_streaming`] sink so a consumer can watch what a skill does
18/// and short-circuit.
19pub struct StreamEvent<'a> {
20    /// The test case's name.
21    pub case: &'a str,
22    /// The platform (harness) under test.
23    pub platform: &'a str,
24    /// The model under test.
25    pub model: &'a str,
26    /// 1-based assistant-turn index within this run.
27    pub turn: usize,
28    /// The normalized tool event.
29    pub event: &'a ToolEvent,
30}
31
32/// The streaming knobs threaded through a run: whether to drive turns live (via
33/// [`Provider::respond_streaming`]) and the sink each tool event is delivered to.
34struct Streaming<'s> {
35    on: bool,
36    sink: &'s mut (dyn FnMut(&StreamEvent) -> ControlFlow<()> + 's),
37}
38
39/// Runs test cases against a provider using a configuration.
40pub struct Runner<'a> {
41    provider: &'a dyn Provider,
42    config: &'a Config,
43}
44
45impl<'a> Runner<'a> {
46    /// Build a runner.
47    #[must_use]
48    pub fn new(provider: &'a dyn Provider, config: &'a Config) -> Self {
49        Self { provider, config }
50    }
51
52    /// Run every supplied case across the full platform × model matrix and
53    /// collect a [`Report`].
54    ///
55    /// # Errors
56    /// Propagates the first [`crate::Error`] from loading a skill or a provider
57    /// failure. Eval *failures* are not errors — they are recorded in the report.
58    pub fn run_all(&self, cases: &[TestCase]) -> Result<Report> {
59        let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
60        self.run_all_inner(
61            cases,
62            &mut Streaming {
63                on: false,
64                sink: &mut sink,
65            },
66        )
67    }
68
69    /// Like [`Runner::run_all`], but drives each turn through
70    /// [`Provider::respond_streaming`] and delivers each skill tool event to
71    /// `on_event` the instant it is observed. `on_event` returns
72    /// [`ControlFlow::Break`] to short-circuit: the current run is torn down (the
73    /// provider kills the harness), no further runs start, and the partial
74    /// [`Report`] built so far is returned.
75    ///
76    /// # Errors
77    /// As [`Runner::run_all`].
78    pub fn run_all_streaming(
79        &self,
80        cases: &[TestCase],
81        on_event: &mut dyn FnMut(&StreamEvent) -> ControlFlow<()>,
82    ) -> Result<Report> {
83        self.run_all_inner(
84            cases,
85            &mut Streaming {
86                on: true,
87                sink: on_event,
88            },
89        )
90    }
91
92    /// The matrix loop shared by the buffered and streaming entry points.
93    /// `streaming.on` selects the buffered [`Provider::respond`] (`false`) or the
94    /// live [`Provider::respond_streaming`] (`true`) per turn.
95    fn run_all_inner(&self, cases: &[TestCase], streaming: &mut Streaming) -> Result<Report> {
96        let mut runs = Vec::new();
97        for case in cases {
98            let skill = load_skill(&case.skill)?;
99            for platform in &self.config.platforms {
100                for model in &self.config.models {
101                    let (run, flow) = self.run_case_on(case, &skill, platform, model, streaming)?;
102                    runs.push(run);
103                    if flow.is_break() {
104                        return Ok(Report::new(runs));
105                    }
106                }
107            }
108        }
109        Ok(Report::new(runs))
110    }
111
112    /// Run a single case across the matrix.
113    ///
114    /// # Errors
115    /// As [`Runner::run_all`].
116    pub fn run_case(&self, case: &TestCase) -> Result<Vec<CaseRun>> {
117        let skill = load_skill(&case.skill)?;
118        let mut runs = Vec::new();
119        let mut sink = |_: &StreamEvent| ControlFlow::Continue(());
120        let mut streaming = Streaming {
121            on: false,
122            sink: &mut sink,
123        };
124        for platform in &self.config.platforms {
125            for model in &self.config.models {
126                let (run, _flow) =
127                    self.run_case_on(case, &skill, platform, model, &mut streaming)?;
128                runs.push(run);
129            }
130        }
131        Ok(runs)
132    }
133
134    /// Run a single case on one platform/model pair. Returns the run plus whether
135    /// the streaming sink asked to short-circuit ([`ControlFlow::Break`]).
136    fn run_case_on(
137        &self,
138        case: &TestCase,
139        skill: &SkillDefinition,
140        platform: &str,
141        model: &str,
142        streaming: &mut Streaming,
143    ) -> Result<(CaseRun, ControlFlow<()>)> {
144        let mut totals = Usage::default();
145        let (transcript, flow) =
146            self.converse(case, skill, platform, model, &mut totals, streaming)?;
147        // On an abort we don't spend judge calls scoring a torn-off transcript.
148        let evals = if flow.is_break() {
149            Vec::new()
150        } else {
151            self.score(case, &transcript, &mut totals)?
152        };
153        let passed = flow.is_continue() && evals.iter().all(|e| e.passed);
154        Ok((
155            CaseRun {
156                case: case.name.clone(),
157                skill: skill.dir.to_string_lossy().into_owned(),
158                platform: platform.to_string(),
159                model: model.to_string(),
160                passed,
161                turns: transcript.assistant_turns(),
162                evals,
163                transcript,
164                usage: (!totals.is_empty()).then_some(totals),
165            },
166            flow,
167        ))
168    }
169
170    /// Drive the conversation: a single assistant turn for single-turn cases, or
171    /// a simulated-user loop for multi-turn cases. Streams each turn's tool
172    /// events to `on_event`; returns the transcript plus whether the sink asked
173    /// to short-circuit.
174    fn converse(
175        &self,
176        case: &TestCase,
177        skill: &SkillDefinition,
178        platform: &str,
179        model: &str,
180        totals: &mut Usage,
181        streaming: &mut Streaming,
182    ) -> Result<(Transcript, ControlFlow<()>)> {
183        let dir = skill.dir.to_string_lossy().into_owned();
184        let skill_ref = SkillRef {
185            name: &skill.name,
186            dir: &dir,
187            instructions: &skill.instructions,
188        };
189        let judge_model = self.config.effective_judge_model();
190        let max_turns = case
191            .user
192            .as_ref()
193            .and_then(|u| u.max_turns)
194            .unwrap_or(self.config.max_turns) as usize;
195        let resume_supported = self.provider.supports_resume(platform);
196
197        let mut transcript = Transcript::from_input(&case.input);
198        // On harnesses that support it, thread the session_id from each
199        // respond into the next one so the harness keeps real state instead of
200        // being re-prompted with a stringified transcript.
201        let mut session: Option<String> = None;
202
203        loop {
204            let session_arg = if resume_supported {
205                session.as_deref()
206            } else {
207                None
208            };
209            // In streaming mode, drive the turn through `respond_streaming` and
210            // tag each event with the run it belongs to; if the sink breaks, the
211            // provider tears the harness down and returns the partial turn, and we
212            // stop the run below. The buffered path uses the plain `respond` so a
213            // non-streaming run keeps oneharness's buffered (`--compact`) contract.
214            let case_name = case.name.as_str();
215            let turn_index = transcript.assistant_turns() + 1;
216            let mut turn_flow = ControlFlow::Continue(());
217            let turn = if streaming.on {
218                let sink = &mut streaming.sink;
219                self.provider.respond_streaming(
220                    platform,
221                    model,
222                    &skill_ref,
223                    &transcript.messages,
224                    session_arg,
225                    &mut |event| {
226                        let flow = sink(&StreamEvent {
227                            case: case_name,
228                            platform,
229                            model,
230                            turn: turn_index,
231                            event,
232                        });
233                        if flow.is_break() {
234                            turn_flow = ControlFlow::Break(());
235                        }
236                        flow
237                    },
238                )?
239            } else {
240                self.provider.respond(
241                    platform,
242                    model,
243                    &skill_ref,
244                    &transcript.messages,
245                    session_arg,
246                )?
247            };
248            if let Some(u) = &turn.usage {
249                totals.add(u);
250            }
251            // Capture or refresh the session handle for the next turn.
252            if let Some(id) = turn.session_id {
253                session = Some(id);
254            }
255            let skill_done = turn.done;
256            // Carry the turn's normalized tool events onto its assistant message
257            // so consumers can analyze what the skill did.
258            transcript.push(Message::assistant(turn.message).with_events(turn.events));
259
260            // The streaming sink asked to short-circuit: stop the run now.
261            if turn_flow.is_break() {
262                return Ok((transcript, ControlFlow::Break(())));
263            }
264
265            // Single-turn cases stop after the first assistant turn.
266            let Some(user) = &case.user else {
267                break;
268            };
269
270            if skill_done || transcript.assistant_turns() >= max_turns {
271                break;
272            }
273
274            // Stop early if the configured done-condition holds.
275            if let Some(done_when) = &user.done_when {
276                let query = JudgeQuery {
277                    kind: JudgeKind::Boolean,
278                    criterion: done_when,
279                    scale: None,
280                };
281                let verdict = self
282                    .provider
283                    .judge(judge_model, &query, &transcript.messages)?;
284                if let Some(u) = &verdict.usage {
285                    totals.add(u);
286                }
287                if matches!(verdict.value, JudgeValue::Bool(true)) {
288                    break;
289                }
290            }
291
292            // Otherwise the simulated user replies and the loop continues.
293            let user_turn =
294                self.provider
295                    .simulate_user(judge_model, &user.persona, &transcript.messages)?;
296            if let Some(u) = &user_turn.usage {
297                totals.add(u);
298            }
299            let stop = user_turn.stop;
300            transcript.push(Message::user(user_turn.message));
301            if stop {
302                break;
303            }
304        }
305
306        Ok((transcript, ControlFlow::Continue(())))
307    }
308
309    /// Run every eval against the finished transcript.
310    fn score(
311        &self,
312        case: &TestCase,
313        transcript: &Transcript,
314        totals: &mut Usage,
315    ) -> Result<Vec<crate::eval::EvalOutcome>> {
316        let judge_model = self.config.effective_judge_model();
317        let mut outcomes = Vec::with_capacity(case.evals.len());
318        for eval in &case.evals {
319            let query = match eval {
320                Eval::Boolean { criterion, .. } => JudgeQuery {
321                    kind: JudgeKind::Boolean,
322                    criterion,
323                    scale: None,
324                },
325                Eval::Numeric {
326                    criterion,
327                    min,
328                    max,
329                    ..
330                } => JudgeQuery {
331                    kind: JudgeKind::Numeric,
332                    criterion,
333                    scale: Some((*min, *max)),
334                },
335            };
336            let verdict = self
337                .provider
338                .judge(judge_model, &query, &transcript.messages)?;
339            if let Some(u) = &verdict.usage {
340                totals.add(u);
341            }
342            outcomes.push(eval.outcome(&verdict.value, verdict.reason)?);
343        }
344        Ok(outcomes)
345    }
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351    use crate::conversation::Message;
352    use crate::provider::{AssistantTurn, JudgeVerdict, UserTurn};
353    use std::cell::RefCell;
354
355    /// An in-memory provider scripted with canned turns and verdicts, so the
356    /// runner's orchestration can be tested without any subprocess.
357    struct ScriptedProvider {
358        assistant: Vec<AssistantTurn>,
359        user: Vec<UserTurn>,
360        judge: Vec<JudgeVerdict>,
361        calls: RefCell<Calls>,
362    }
363
364    #[derive(Default)]
365    struct Calls {
366        assistant: usize,
367        user: usize,
368        judge: usize,
369    }
370
371    impl Provider for ScriptedProvider {
372        fn respond(
373            &self,
374            _platform: &str,
375            _model: &str,
376            _skill: &SkillRef<'_>,
377            _messages: &[Message],
378            _session: Option<&str>,
379        ) -> Result<AssistantTurn> {
380            let i = self.calls.borrow().assistant;
381            self.calls.borrow_mut().assistant += 1;
382            Ok(self.assistant[i.min(self.assistant.len() - 1)].clone())
383        }
384
385        fn simulate_user(
386            &self,
387            _model: &str,
388            _persona: &str,
389            _messages: &[Message],
390        ) -> Result<UserTurn> {
391            let i = self.calls.borrow().user;
392            self.calls.borrow_mut().user += 1;
393            Ok(self.user[i.min(self.user.len() - 1)].clone())
394        }
395
396        fn judge(
397            &self,
398            _model: &str,
399            _query: &JudgeQuery<'_>,
400            _messages: &[Message],
401        ) -> Result<JudgeVerdict> {
402            let i = self.calls.borrow().judge;
403            self.calls.borrow_mut().judge += 1;
404            let v = &self.judge[i.min(self.judge.len() - 1)];
405            Ok(JudgeVerdict {
406                value: v.value,
407                reason: v.reason.clone(),
408                usage: v.usage.clone(),
409            })
410        }
411    }
412
413    /// Create a throwaway skill directory with a minimal SKILL.md so the runner
414    /// (which loads the skill from disk) has something real to read.
415    fn temp_skill(tag: &str) -> std::path::PathBuf {
416        let dir = std::env::temp_dir().join(format!("skilltest-ut-{}-{tag}", std::process::id()));
417        std::fs::create_dir_all(&dir).unwrap();
418        std::fs::write(
419            dir.join("SKILL.md"),
420            "---\nname: greeter\ndescription: a test skill\n---\nfake-reply: hi\n",
421        )
422        .unwrap();
423        dir
424    }
425
426    fn boolean_case(skill: std::path::PathBuf) -> TestCase {
427        TestCase {
428            name: "greets".into(),
429            skill,
430            input: "Greet Dr. Smith".into(),
431            user: None,
432            evals: vec![Eval::Boolean {
433                criterion: "greets Dr. Smith".into(),
434                expected: true,
435                name: None,
436            }],
437        }
438    }
439
440    #[test]
441    fn single_turn_runs_one_assistant_turn_and_scores() {
442        let provider = ScriptedProvider {
443            assistant: vec![AssistantTurn {
444                message: "Hello, Dr. Smith!".into(),
445                done: false,
446                ..Default::default()
447            }],
448            user: vec![],
449            judge: vec![JudgeVerdict {
450                value: JudgeValue::Bool(true),
451                reason: "names her".into(),
452                usage: None,
453            }],
454            calls: RefCell::new(Calls::default()),
455        };
456        let config = Config::default();
457        let runner = Runner::new(&provider, &config);
458        let runs = runner
459            .run_case(&boolean_case(temp_skill("single")))
460            .unwrap();
461        assert_eq!(runs.len(), 1);
462        assert!(runs[0].passed);
463        assert_eq!(runs[0].turns, 1);
464        assert_eq!(provider.calls.borrow().assistant, 1);
465    }
466
467    #[test]
468    fn multi_turn_stops_when_done_when_holds() {
469        let mut case = boolean_case(temp_skill("multi"));
470        case.user = Some(crate::testcase::SimulatedUser {
471            persona: "a terse patient".into(),
472            done_when: Some("the assistant has greeted".into()),
473            max_turns: Some(5),
474        });
475        let provider = ScriptedProvider {
476            assistant: vec![AssistantTurn {
477                message: "Hi there".into(),
478                done: false,
479                ..Default::default()
480            }],
481            user: vec![UserTurn {
482                message: "continue".into(),
483                stop: false,
484                ..Default::default()
485            }],
486            // First judge call is the done_when check (true -> stop), second is
487            // the eval.
488            judge: vec![
489                JudgeVerdict {
490                    value: JudgeValue::Bool(true),
491                    reason: "done".into(),
492                    usage: None,
493                },
494                JudgeVerdict {
495                    value: JudgeValue::Bool(true),
496                    reason: "greeted".into(),
497                    usage: None,
498                },
499            ],
500            calls: RefCell::new(Calls::default()),
501        };
502        let config = Config::default();
503        let runner = Runner::new(&provider, &config);
504        let runs = runner.run_case(&case).unwrap();
505        assert!(runs[0].passed);
506        // One assistant turn, the simulated user never had to speak.
507        assert_eq!(provider.calls.borrow().assistant, 1);
508        assert_eq!(provider.calls.borrow().user, 0);
509    }
510
511    #[test]
512    fn failing_eval_marks_run_failed() {
513        let provider = ScriptedProvider {
514            assistant: vec![AssistantTurn {
515                message: "Hello".into(),
516                done: false,
517                ..Default::default()
518            }],
519            user: vec![],
520            judge: vec![JudgeVerdict {
521                value: JudgeValue::Bool(false),
522                reason: "no name".into(),
523                usage: None,
524            }],
525            calls: RefCell::new(Calls::default()),
526        };
527        let config = Config::default();
528        let runner = Runner::new(&provider, &config);
529        let report = runner
530            .run_all(&[boolean_case(temp_skill("faileval"))])
531            .unwrap();
532        assert!(!report.passed);
533        assert_eq!(report.summary.failed, 1);
534    }
535
536    #[test]
537    fn matrix_fans_out_over_platforms_and_models() {
538        let provider = ScriptedProvider {
539            assistant: vec![AssistantTurn {
540                message: "Hello".into(),
541                done: false,
542                ..Default::default()
543            }],
544            user: vec![],
545            judge: vec![JudgeVerdict {
546                value: JudgeValue::Bool(true),
547                reason: String::new(),
548                usage: None,
549            }],
550            calls: RefCell::new(Calls::default()),
551        };
552        let config = Config {
553            platforms: vec!["a".into(), "b".into()],
554            models: vec!["m1".into(), "m2".into()],
555            ..Config::default()
556        };
557        let runner = Runner::new(&provider, &config);
558        let runs = runner
559            .run_case(&boolean_case(temp_skill("matrix")))
560            .unwrap();
561        assert_eq!(runs.len(), 4);
562    }
563
564    #[test]
565    fn run_all_streaming_short_circuits_on_break() {
566        use crate::conversation::ToolEvent;
567        // A turn that took a disallowed action; the streaming sink breaks on it.
568        let provider = ScriptedProvider {
569            assistant: vec![AssistantTurn {
570                message: "did a bad thing".into(),
571                done: false,
572                usage: None,
573                session_id: None,
574                events: vec![ToolEvent {
575                    kind: "tool_call".into(),
576                    name: Some("bash".into()),
577                    input: Some(serde_json::json!({ "command": "rm -rf /" })),
578                    output: None,
579                    index: 0,
580                }],
581            }],
582            user: vec![],
583            judge: vec![JudgeVerdict {
584                value: JudgeValue::Bool(true),
585                reason: String::new(),
586                usage: None,
587            }],
588            calls: RefCell::new(Calls::default()),
589        };
590        let config = Config {
591            platforms: vec!["a".into(), "b".into()],
592            ..Config::default()
593        };
594        let runner = Runner::new(&provider, &config);
595        let mut seen = 0usize;
596        let report = runner
597            .run_all_streaming(
598                &[boolean_case(temp_skill("stream-abort"))],
599                &mut |ev: &StreamEvent| {
600                    seen += 1;
601                    assert_eq!(ev.event.name.as_deref(), Some("bash"));
602                    assert_eq!(ev.turn, 1);
603                    ControlFlow::Break(())
604                },
605            )
606            .unwrap();
607        // The sink saw the first event and aborted; only the first matrix cell
608        // ran, and it is not passing.
609        assert_eq!(seen, 1);
610        assert_eq!(report.runs.len(), 1);
611        assert!(!report.runs[0].passed);
612        // No judge call was spent scoring the torn-off run.
613        assert_eq!(provider.calls.borrow().judge, 0);
614    }
615
616    fn usage(input: u64) -> Option<Usage> {
617        Some(Usage {
618            input_tokens: Some(input),
619            output_tokens: None,
620            cost_usd: None,
621        })
622    }
623
624    #[test]
625    fn multi_turn_loops_through_simulated_user_and_aggregates_usage() {
626        // No early stop: the done_when check returns false, so the simulated
627        // user speaks after turn 1, then turn 2 satisfies done_when and the loop
628        // ends. Every provider call reports usage, so totals must accumulate.
629        let mut case = boolean_case(temp_skill("loop"));
630        case.user = Some(crate::testcase::SimulatedUser {
631            persona: "a chatty patient".into(),
632            done_when: Some("the booking is confirmed".into()),
633            max_turns: Some(8),
634        });
635        let provider = ScriptedProvider {
636            assistant: vec![
637                AssistantTurn {
638                    message: "Hello, how can I help?".into(),
639                    done: false,
640                    usage: usage(3),
641                    // A session id the runner should capture for the next turn.
642                    session_id: Some("sess-1".into()),
643                    events: Vec::new(),
644                },
645                AssistantTurn {
646                    message: "Booked!".into(),
647                    done: false,
648                    usage: usage(4),
649                    session_id: Some("sess-2".into()),
650                    events: Vec::new(),
651                },
652            ],
653            user: vec![UserTurn {
654                message: "Please book me in.".into(),
655                stop: false,
656                usage: usage(2),
657            }],
658            judge: vec![
659                // done_when after assistant turn 1 -> not done yet.
660                JudgeVerdict {
661                    value: JudgeValue::Bool(false),
662                    reason: "not yet".into(),
663                    usage: usage(1),
664                },
665                // done_when after assistant turn 2 -> done, the loop ends.
666                JudgeVerdict {
667                    value: JudgeValue::Bool(true),
668                    reason: "confirmed".into(),
669                    usage: usage(1),
670                },
671                // The final eval.
672                JudgeVerdict {
673                    value: JudgeValue::Bool(true),
674                    reason: "greeted".into(),
675                    usage: usage(5),
676                },
677            ],
678            calls: RefCell::new(Calls::default()),
679        };
680        let config = Config::default();
681        let runner = Runner::new(&provider, &config);
682        let runs = runner.run_case(&case).unwrap();
683        assert!(runs[0].passed);
684        // Two assistant turns; the user spoke exactly once between them.
685        assert_eq!(provider.calls.borrow().assistant, 2);
686        assert_eq!(provider.calls.borrow().user, 1);
687        assert_eq!(provider.calls.borrow().judge, 3);
688        // Usage across every call:
689        // 3(resp) + 1(done_when) + 2(user) + 4(resp) + 1(done_when) + 5(eval) = 16.
690        assert_eq!(runs[0].usage.as_ref().unwrap().input_tokens, Some(16));
691    }
692
693    #[test]
694    fn multi_turn_threads_session_when_resume_supported() {
695        // A provider that supports resume should be handed the session id the
696        // previous respond returned. Capture the session arg each respond sees.
697        #[derive(Default)]
698        struct Sessions(RefCell<Vec<Option<String>>>);
699        struct ResumeProvider {
700            sessions: Sessions,
701        }
702        impl Provider for ResumeProvider {
703            fn respond(
704                &self,
705                _platform: &str,
706                _model: &str,
707                _skill: &SkillRef<'_>,
708                _messages: &[Message],
709                session: Option<&str>,
710            ) -> Result<AssistantTurn> {
711                self.sessions
712                    .0
713                    .borrow_mut()
714                    .push(session.map(str::to_string));
715                let n = self.sessions.0.borrow().len();
716                Ok(AssistantTurn {
717                    message: format!("turn {n}"),
718                    done: false,
719                    usage: None,
720                    session_id: Some(format!("sess-{n}")),
721                    events: Vec::new(),
722                })
723            }
724            fn simulate_user(
725                &self,
726                _model: &str,
727                _persona: &str,
728                _messages: &[Message],
729            ) -> Result<UserTurn> {
730                Ok(UserTurn {
731                    message: "go on".into(),
732                    stop: false,
733                    usage: None,
734                })
735            }
736            fn judge(
737                &self,
738                _model: &str,
739                _query: &JudgeQuery<'_>,
740                _messages: &[Message],
741            ) -> Result<JudgeVerdict> {
742                Ok(JudgeVerdict {
743                    value: JudgeValue::Bool(true),
744                    reason: String::new(),
745                    usage: None,
746                })
747            }
748            fn supports_resume(&self, _platform: &str) -> bool {
749                true
750            }
751        }
752        let mut case = boolean_case(temp_skill("resume"));
753        case.user = Some(crate::testcase::SimulatedUser {
754            persona: "a patient".into(),
755            // No done_when, so the loop runs to max_turns.
756            done_when: None,
757            max_turns: Some(2),
758        });
759        let provider = ResumeProvider {
760            sessions: Sessions::default(),
761        };
762        let config = Config::default();
763        let runner = Runner::new(&provider, &config);
764        let runs = runner.run_case(&case).unwrap();
765        assert_eq!(runs[0].turns, 2);
766        // First respond saw no session; the second saw the id from the first.
767        let seen = provider.sessions.0.borrow();
768        assert_eq!(seen[0], None);
769        assert_eq!(seen[1].as_deref(), Some("sess-1"));
770    }
771}