Skip to main content

gemini_adk_fluent_rs/spec/
simulate.rs

1//! Offline conformance tests embedded in a [`SessionSpec`].
2//!
3//! A [`SpecTest`] scripts a conversation as data — user turns, tool calls,
4//! state writes — and asserts flow state at checkpoints: which steps are done
5//! or active, which tools are admitted or blocked, what the state holds. The
6//! script replays through the *real* [`FlowStack`] with the declared tools'
7//! mock semantics, so governance is exercised exactly as a live session would
8//! — with no model, no network, and no API key. Run in CI, or scrub through
9//! one in the Studio.
10
11use std::collections::BTreeMap;
12
13use serde::{Deserialize, Serialize};
14use serde_json::Value;
15
16use gemini_adk_rs::flow::{Enforcement, FlowMonitor, FlowSnapshot, FlowStack};
17use gemini_adk_rs::state::State;
18
19use super::SessionSpec;
20
21/// One scripted event in a [`SpecTest`].
22#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
23#[serde(rename_all = "snake_case")]
24pub enum SimEvent {
25    /// A user turn (advances the turn counter and re-latches guards). The
26    /// text is documentation — the simulator does not run a model.
27    User(String),
28    /// The model calls a declared tool. Applies the tool's `set_state` mock
29    /// semantics and records a successful completion — unless the flow blocks
30    /// it, in which case nothing is recorded (assert with
31    /// [`TestExpectation::blocked`]).
32    Tool(String),
33    /// Write state directly — stands in for extraction filling slots
34    /// mid-conversation.
35    Set(BTreeMap<String, Value>),
36    /// A checkpoint: assert the current flow state.
37    Expect(TestExpectation),
38}
39
40/// Assertions at a checkpoint. Every listed item must hold; omitted fields
41/// are not checked.
42#[derive(Debug, Clone, Default, Serialize, Deserialize, schemars::JsonSchema)]
43pub struct TestExpectation {
44    /// Steps that must have latched done.
45    #[serde(default, skip_serializing_if = "Vec::is_empty")]
46    pub done: Vec<String>,
47    /// Steps that must be active (eligible, not done).
48    #[serde(default, skip_serializing_if = "Vec::is_empty")]
49    pub active: Vec<String>,
50    /// Tools that must currently be admitted.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub allowed: Vec<String>,
53    /// Tools that must currently be blocked.
54    #[serde(default, skip_serializing_if = "Vec::is_empty")]
55    pub blocked: Vec<String>,
56    /// State keys that must hold exactly these values.
57    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
58    pub state: BTreeMap<String, Value>,
59    /// Whether the stack must be complete (all required steps done with no
60    /// active overlay, or a terminating overlay ended the conversation).
61    #[serde(default, skip_serializing_if = "Option::is_none")]
62    pub complete: Option<bool>,
63}
64
65/// A named, scripted conformance test embedded in the spec.
66#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
67pub struct SpecTest {
68    /// Test name.
69    pub name: String,
70    /// The scripted events, in order.
71    pub script: Vec<SimEvent>,
72}
73
74/// The outcome of one scripted event.
75#[derive(Debug, Clone, Serialize)]
76pub struct TestStepResult {
77    /// Event index in the script.
78    pub index: usize,
79    /// Human-readable event label.
80    pub event: String,
81    /// Failures at this event (empty = passed). Tool blocks are reported here
82    /// when the script called a blocked tool without asserting it.
83    pub failures: Vec<String>,
84}
85
86/// The outcome of one [`SpecTest`].
87#[derive(Debug, Clone, Serialize)]
88pub struct TestReport {
89    /// Test name.
90    pub name: String,
91    /// Whether every assertion held.
92    pub passed: bool,
93    /// Per-event outcomes (only events with failures, plus a summary count).
94    pub failures: Vec<TestStepResult>,
95    /// Events executed.
96    pub events: usize,
97}
98
99/// One per-event snapshot of the flow's state during a scripted replay — the
100/// Studio's Preview scrubber steps through these, lighting up the DAG exactly
101/// as a live session would, with no model and no API key.
102#[derive(Debug, Clone, Serialize)]
103pub struct SimSnapshot {
104    /// Event index in the script (0 = state before any event).
105    pub index: usize,
106    /// Human-readable event label ("start", "tool: charge_card", …).
107    pub event: String,
108    /// Assertion failures at this event (empty when none).
109    #[serde(default, skip_serializing_if = "Vec::is_empty")]
110    pub failures: Vec<String>,
111    /// The runtime snapshot shared by live sessions and offline replay.
112    #[serde(flatten)]
113    pub status: FlowSnapshot,
114}
115
116/// Replay one named test and return a snapshot after every event (plus an
117/// initial "start" snapshot), for scrubbing. Errors when the flow cannot be
118/// built or the test name is unknown.
119pub fn trace_test(spec: &SessionSpec, test_name: &str) -> Result<Vec<SimSnapshot>, Vec<String>> {
120    if let Some((skill_name, local_name)) = test_name.split_once('/')
121        && let Some(skill) = spec.skills.iter().find(|skill| skill.name == skill_name)
122    {
123        let validation = spec.validate_for_replay();
124        if !validation.valid {
125            return Err(validation.errors);
126        }
127        return trace_test(&skill.definition(), local_name);
128    }
129    let test = spec
130        .tests
131        .iter()
132        .find(|t| t.name == test_name)
133        .ok_or_else(|| vec![format!("no test named '{test_name}' in the spec")])?;
134    replay(spec, test)
135}
136
137/// Compile the same governance stack installed by a live session. Resolvers
138/// remain stubs: tests supply state directly and tools use declared mock effects.
139fn replay_stack(spec: &SessionSpec) -> Result<FlowStack, Vec<String>> {
140    let validation = spec.validate_for_replay();
141    if !validation.valid {
142        return Err(validation.errors);
143    }
144    if let Some(conversation) = &spec.conversation {
145        crate::conversation::Conversation::from_spec_stubbing_resolvers(conversation.clone())
146            .map(|compiled| compiled.stack(Enforcement::Enforce))
147            .map_err(|error| vec![format!("conversation: {error}")])
148    } else {
149        FlowMonitor::try_new(spec.effective_flow()?, Enforcement::Enforce)
150            .map(FlowMonitor::into_stack)
151            .map_err(|errors| errors.0.iter().map(ToString::to_string).collect())
152    }
153}
154
155/// The shared replay engine: run the script through a fresh stack, snapshot
156/// after every event.
157fn replay(spec: &SessionSpec, test: &SpecTest) -> Result<Vec<SimSnapshot>, Vec<String>> {
158    let state = State::new();
159    // Mirror `apply()`: declared defaults are seeded and computed variables
160    // recompute after every state change, so guards over derived keys latch
161    // exactly as they do live.
162    spec.seed_state_defaults(&state);
163    spec.recompute_computed(&state);
164    let mut stack = replay_stack(spec)?;
165    stack.relatch(&state);
166
167    let snapshot =
168        |index: usize, event: String, failures: Vec<String>, stack: &FlowStack, state: &State| {
169            SimSnapshot {
170                index,
171                event,
172                failures,
173                status: stack.snapshot(state),
174            }
175        };
176
177    let mut snapshots = vec![snapshot(0, "start".into(), Vec::new(), &stack, &state)];
178    for (index, event) in test.script.iter().enumerate() {
179        let mut failures = Vec::new();
180        let label = match event {
181            SimEvent::User(text) => {
182                spec.recompute_computed(&state);
183                stack.on_turn(&state);
184                format!("user: {text}")
185            }
186            SimEvent::Tool(name) => {
187                match stack.admits_tool(name, &state) {
188                    Ok(()) => {
189                        spec.apply_tool_state(name, &state);
190                        spec.recompute_computed(&state);
191                        stack.on_tool_ok(name, &state);
192                    }
193                    Err(reason) => {
194                        let anticipated = matches!(
195                            test.script.get(index + 1),
196                            Some(SimEvent::Expect(e)) if e.blocked.iter().any(|t| t == name)
197                        );
198                        if !anticipated {
199                            failures.push(format!("tool '{name}' was blocked: {reason}"));
200                        }
201                    }
202                }
203                format!("tool: {name}")
204            }
205            SimEvent::Set(map) => {
206                for (key, value) in map {
207                    let _ = state.set(key, value.clone());
208                }
209                spec.recompute_computed(&state);
210                stack.relatch(&state);
211                format!(
212                    "set: {}",
213                    map.keys().cloned().collect::<Vec<_>>().join(", ")
214                )
215            }
216            SimEvent::Expect(expect) => {
217                check(expect, &stack, &state, &mut failures);
218                "expect".to_string()
219            }
220        };
221        snapshots.push(snapshot(index + 1, label, failures, &stack, &state));
222    }
223    Ok(snapshots)
224}
225
226/// Run every embedded test against the same governance stack as live sessions.
227pub(crate) fn run_tests(spec: &SessionSpec) -> Vec<TestReport> {
228    spec.tests.iter().map(|test| run_one(spec, test)).collect()
229}
230
231fn run_one(spec: &SessionSpec, test: &SpecTest) -> TestReport {
232    let snapshots = match replay(spec, test) {
233        Ok(snapshots) => snapshots,
234        Err(errors) => {
235            return TestReport {
236                name: test.name.clone(),
237                passed: false,
238                failures: vec![TestStepResult {
239                    index: 0,
240                    event: "setup".into(),
241                    failures: errors,
242                }],
243                events: 0,
244            };
245        }
246    };
247    let failures: Vec<TestStepResult> = snapshots
248        .into_iter()
249        .skip(1) // the "start" snapshot carries no event
250        .filter(|s| !s.failures.is_empty())
251        .map(|s| TestStepResult {
252            index: s.index - 1,
253            event: s.event,
254            failures: s.failures,
255        })
256        .collect();
257
258    TestReport {
259        name: test.name.clone(),
260        passed: failures.is_empty(),
261        failures,
262        events: test.script.len(),
263    }
264}
265
266fn check(expect: &TestExpectation, stack: &FlowStack, state: &State, failures: &mut Vec<String>) {
267    let explanation = stack.explain(state);
268    for step in &expect.done {
269        if !stack.marking().done.contains(step) {
270            failures.push(format!(
271                "expected step '{step}' done; done = [{}]",
272                join(&stack.marking().done.iter().cloned().collect::<Vec<_>>())
273            ));
274        }
275    }
276    for step in &expect.active {
277        if !explanation.active.contains(step) {
278            failures.push(format!(
279                "expected step '{step}' active; active = [{}]",
280                join(&explanation.active)
281            ));
282        }
283    }
284    for tool in &expect.allowed {
285        if !explanation.allowed_tools.contains(tool) {
286            failures.push(format!(
287                "expected tool '{tool}' allowed; allowed = [{}]",
288                join(&explanation.allowed_tools)
289            ));
290        }
291    }
292    for tool in &expect.blocked {
293        if stack.admits_tool(tool, state).is_ok() {
294            failures.push(format!("expected tool '{tool}' blocked; it was admitted"));
295        }
296    }
297    for (key, expected) in &expect.state {
298        let actual = state.get::<Value>(key);
299        if actual.as_ref() != Some(expected) {
300            failures.push(format!(
301                "expected state '{key}' = {expected}; got {}",
302                actual.map_or("<absent>".to_string(), |v| v.to_string())
303            ));
304        }
305    }
306    if let Some(complete) = expect.complete
307        && stack.is_complete() != complete
308    {
309        failures.push(format!(
310            "expected complete = {complete}; got {}",
311            stack.is_complete()
312        ));
313    }
314}
315
316fn join(items: &[String]) -> String {
317    items.join(", ")
318}
319
320#[cfg(test)]
321mod tests {
322    use super::super::SessionSpec;
323    use serde_json::{Value, json};
324
325    fn spec_with_tests() -> SessionSpec {
326        SessionSpec::from_value(json!({
327            "name": "collections",
328            "instruction": "Collect.",
329            "tools": [
330                {"name": "verify_identity", "set_state": {"identity_verified": true}},
331                {"name": "charge_card", "response": {"charged": true}}
332            ],
333            "flow": {
334                "steps": [
335                    {"id": "verify", "posture": "Verify.", "allow": ["verify_identity"],
336                     "done": {"is_true": "identity_verified"}},
337                    {"id": "pay", "after": ["verify"], "posture": "Pay.",
338                     "allow": ["charge_card"], "done": {"called_ok": "charge_card"}}
339                ],
340                "constraints": [
341                    {"never_until": {"tool": "charge_card",
342                                     "until": {"is_true": "identity_verified"}}},
343                    {"require": ["pay"]}
344                ]
345            },
346            "tests": [
347                {"name": "happy path", "script": [
348                    {"expect": {"active": ["verify"], "blocked": ["charge_card"],
349                                "complete": false}},
350                    {"tool": "verify_identity"},
351                    {"expect": {"done": ["verify"], "active": ["pay"],
352                                "allowed": ["charge_card"],
353                                "state": {"identity_verified": true}}},
354                    {"tool": "charge_card"},
355                    {"expect": {"done": ["pay"], "complete": true}}
356                ]},
357                {"name": "premature charge is blocked", "script": [
358                    {"tool": "charge_card"},
359                    {"expect": {"blocked": ["charge_card"], "complete": false}}
360                ]},
361                {"name": "deliberately wrong", "script": [
362                    {"expect": {"done": ["pay"]}}
363                ]}
364            ]
365        }))
366        .expect("spec parses")
367    }
368
369    #[test]
370    fn scripted_tests_replay_through_the_real_monitor() {
371        let reports = spec_with_tests().run_tests();
372        assert_eq!(reports.len(), 3);
373        assert!(reports[0].passed, "happy path: {:?}", reports[0].failures);
374        assert!(
375            reports[1].passed,
376            "anticipated block passes: {:?}",
377            reports[1].failures
378        );
379        assert!(!reports[2].passed, "wrong expectation fails");
380        assert!(reports[2].failures[0].failures[0].contains("expected step 'pay' done"));
381    }
382
383    #[test]
384    fn trace_snapshots_every_event() {
385        let spec = spec_with_tests();
386        let snapshots = super::trace_test(&spec, "happy path").expect("traces");
387        // start + 5 script events.
388        assert_eq!(snapshots.len(), 6);
389        assert_eq!(snapshots[0].event, "start");
390        assert!(
391            snapshots[0]
392                .status
393                .explanation
394                .active
395                .contains(&"verify".to_string())
396        );
397        // After verify_identity (event 2), verify is done and pay is active.
398        assert!(snapshots[2].status.done.contains(&"verify".to_string()));
399        assert!(
400            snapshots[2]
401                .status
402                .explanation
403                .active
404                .contains(&"pay".to_string())
405        );
406        // Final snapshot: complete.
407        assert!(snapshots[5].status.complete);
408        assert!(super::trace_test(&spec, "no such test").is_err());
409    }
410
411    #[test]
412    fn computed_variables_latch_guards_in_replay() {
413        let spec = SessionSpec::from_value(json!({
414            "instruction": "x",
415            "state": {"attempts": {"type": "number", "default": 0}},
416            "tools": [{"name": "record_score", "set_state": {"score": 0.9}}],
417            "computed": [{"key": "high_risk",
418                          "from": {"gt": [{"key": "score"}, {"const": 0.5}]}}],
419            "flow": {"steps": [
420                {"id": "assess", "posture": "Assess.", "allow": ["record_score"],
421                 "done": {"is_true": "high_risk"}},
422                {"id": "wrap", "after": ["assess"], "terminal": true}
423            ], "constraints": [{"require": ["wrap"]}]},
424            "tests": [{"name": "risk computes", "script": [
425                {"expect": {"active": ["assess"],
426                            "state": {"attempts": 0}}},
427                {"tool": "record_score"},
428                {"expect": {"done": ["assess", "wrap"], "complete": true,
429                            "state": {"derived:high_risk": true}}}
430            ]}]
431        }))
432        .expect("parses");
433        let reports = spec.run_tests();
434        assert!(
435            reports[0].passed,
436            "computed guard latches offline: {:?}",
437            reports[0].failures
438        );
439    }
440
441    #[test]
442    fn unanticipated_block_is_a_failure() {
443        let mut spec = spec_with_tests();
444        // Script calls charge_card first with no `blocked` assertion after.
445        spec.tests = vec![super::SpecTest {
446            name: "unanticipated".into(),
447            script: vec![super::SimEvent::Tool("charge_card".into())],
448        }];
449        let reports = spec.run_tests();
450        assert!(!reports[0].passed);
451        assert!(reports[0].failures[0].failures[0].contains("was blocked"));
452    }
453
454    fn conversation_spec(script: Value) -> SessionSpec {
455        use crate::conversation::Conversation;
456        use gemini_adk_rs::flow::{Guard, Resume};
457
458        let conversation = Conversation::new("support")
459            .stage("main")
460            .allow(["finish_main"])
461            .complete_when(Guard::called_ok("finish_main"))
462            .next("main_end", Guard::called_ok("finish_main"))
463            .stage("main_end")
464            .terminal()
465            .require(["main_end"])
466            .overlay("faq")
467            .trigger(Guard::is_true("intent:faq"))
468            .stage("answer")
469            .allow(["finish_faq"])
470            .complete_when(Guard::called_ok("finish_faq"))
471            .next("faq_end", Guard::called_ok("finish_faq"))
472            .stage("faq_end")
473            .terminal()
474            .require(["faq_end"])
475            .resume(Resume::Previous)
476            .end_overlay()
477            .overlay("clarify")
478            .trigger(Guard::is_true("intent:clarify"))
479            .stage("clarification")
480            .allow(["finish_clarify"])
481            .complete_when(Guard::called_ok("finish_clarify"))
482            .next("clarify_end", Guard::called_ok("finish_clarify"))
483            .stage("clarify_end")
484            .terminal()
485            .require(["clarify_end"])
486            .resume(Resume::Previous)
487            .end_overlay()
488            .overlay("cancel")
489            .trigger(Guard::is_true("intent:cancel"))
490            .stage("cancelled")
491            .terminal()
492            .require(["cancelled"])
493            .resume(Resume::Terminate)
494            .end_overlay()
495            .into_spec();
496        SessionSpec::from_value(json!({
497            "conversation": conversation,
498            "tools": [
499                {"name": "finish_main"},
500                {"name": "finish_faq", "set_state": {"intent:faq": false}},
501                {"name": "finish_clarify", "set_state": {"intent:clarify": false}}
502            ],
503            "tests": [{"name": "conversation", "script": script}]
504        }))
505        .expect("spec parses")
506    }
507
508    #[test]
509    fn preview_and_tests_preserve_nested_overlays_and_resume() {
510        let spec = conversation_spec(json!([
511            {"set": {"intent:faq": true}},
512            {"user": "FAQ"},
513            {"expect": {"active": ["answer"], "allowed": ["finish_faq"],
514                        "complete": false}},
515            {"set": {"intent:clarify": true}},
516            {"user": "Clarify"},
517            {"expect": {"active": ["clarification"], "allowed": ["finish_clarify"],
518                        "complete": false}},
519            {"tool": "finish_clarify"},
520            {"user": "Back to FAQ"},
521            {"expect": {"active": ["answer"], "allowed": ["finish_faq"]}},
522            {"tool": "finish_faq"},
523            {"user": "Back to main"},
524            {"expect": {"active": ["main"], "allowed": ["finish_main"],
525                        "complete": false}},
526            {"tool": "finish_main"},
527            {"expect": {"done": ["main", "main_end"], "complete": true}}
528        ]));
529        let reports = spec.run_tests();
530        assert!(reports[0].passed, "{:?}", reports[0].failures);
531        let snapshots = super::trace_test(&spec, "conversation").expect("traces");
532        assert!(snapshots.iter().all(|s| s.failures.is_empty()));
533        assert_eq!(snapshots[2].status.overlay_path, ["faq"]);
534        assert_eq!(snapshots[5].status.overlay_path, ["faq", "clarify"]);
535        assert_eq!(snapshots[7].status.overlay_path, ["faq", "clarify"]);
536        assert_eq!(snapshots[8].status.overlay_path, ["faq"]);
537        assert!(snapshots[11].status.overlay_path.is_empty());
538        assert!(snapshots.last().expect("final").status.complete);
539        let value = serde_json::to_value(&snapshots[5]).expect("serializes");
540        assert_eq!(value["overlay_path"], json!(["faq", "clarify"]));
541        assert_eq!(value["terminated"], false);
542        assert!(value.get("status").is_none(), "runtime fields stay flat");
543    }
544
545    #[test]
546    fn nested_termination_is_reported_and_blocks_mock_tool_effects() {
547        let mut spec = conversation_spec(json!([
548            {"set": {"intent:faq": true}},
549            {"user": "FAQ"},
550            {"set": {"intent:cancel": true}},
551            {"user": "Cancel"},
552            {"expect": {"complete": false}},
553            {"user": "Closing turn"},
554            {"expect": {"complete": true, "blocked": ["finish_main"]}},
555            {"tool": "finish_main"},
556            {"expect": {"complete": true, "blocked": ["finish_main"],
557                        "state": {"charged": false}}}
558        ]));
559        spec.state = serde_json::from_value(json!({
560            "charged": {"type": "boolean", "default": false}
561        }))
562        .expect("state parses");
563        spec.tools[0]
564            .set_state
565            .insert("charged".into(), json!(true));
566        let reports = spec.run_tests();
567        assert!(reports[0].passed, "{:?}", reports[0].failures);
568        let snapshots = super::trace_test(&spec, "conversation").expect("traces");
569        assert_eq!(snapshots[4].status.overlay_path, ["faq", "cancel"]);
570        assert!(!snapshots[4].status.complete);
571        let final_status = &snapshots.last().expect("final").status;
572        assert!(final_status.terminated);
573        assert!(final_status.complete);
574        assert!(final_status.overlay_path.is_empty());
575        assert!(final_status.explanation.active.is_empty());
576        assert!(final_status.explanation.allowed_tools.is_empty());
577    }
578
579    #[test]
580    fn overlay_denial_can_be_asserted_for_a_suspended_layers_tool() {
581        let mut spec = conversation_spec(json!([
582            {"set": {"intent:faq": true, "charged": false}},
583            {"user": "FAQ"},
584            {"tool": "finish_main"},
585            {"expect": {"blocked": ["finish_main"], "state": {"charged": false}}}
586        ]));
587        spec.tools[0]
588            .set_state
589            .insert("charged".into(), json!(true));
590        let reports = spec.run_tests();
591        assert!(reports[0].passed, "{:?}", reports[0].failures);
592        let trace = super::trace_test(&spec, "conversation").unwrap();
593        assert!(trace.iter().all(|snapshot| snapshot.failures.is_empty()));
594    }
595
596    #[test]
597    fn replay_uses_conversation_repair_and_set_does_not_count_as_a_turn() {
598        use crate::conversation::Conversation;
599        use gemini_adk_rs::flow::{Guard, RepairPolicy, reprompt_flag};
600
601        let conversation = Conversation::new("repair")
602            .stage("collect")
603            .complete_when(Guard::is_true("info"))
604            .next("done", Guard::is_true("info"))
605            .repair(RepairPolicy::new(2, 3).escalate_to("handoff"))
606            .stage("done")
607            .terminal()
608            .stage("handoff")
609            .complete_when(Guard::is_true("handoff_complete"))
610            .require(["done"])
611            .into_spec();
612        let spec = SessionSpec::from_value(json!({
613            "conversation": conversation,
614            "tests": [{"name": "repair", "script": [
615                {"set": {"unrelated": 1}},
616                {"set": {"unrelated": 2}},
617                {"user": "First turn"},
618                {"expect": {"active": ["collect"], "complete": false}},
619                {"user": "Second turn"},
620                {"expect": {"active": ["collect"],
621                            "state": {reprompt_flag("collect"): true}}},
622                {"user": "Third turn"},
623                {"expect": {"active": ["handoff"],
624                            "state": {"repair:collect:escalate": true}}}
625            ]}]
626        }))
627        .expect("parses");
628        let reports = spec.run_tests();
629        assert!(reports[0].passed, "{:?}", reports[0].failures);
630        let snapshots = super::trace_test(&spec, "repair").expect("traces");
631        assert!(
632            snapshots
633                .last()
634                .expect("final")
635                .status
636                .explanation
637                .active
638                .contains(&"handoff".into())
639        );
640    }
641
642    #[test]
643    fn invalid_specs_fail_preview_and_tests_at_setup() {
644        let mut spec = spec_with_tests();
645        spec.flow.as_mut().expect("flow").steps[1].after[0].step = "missing".into();
646        assert!(!spec.validate().valid);
647        assert!(super::trace_test(&spec, "happy path").is_err());
648        let reports = spec.run_tests();
649        assert!(reports.iter().all(|r| !r.passed && r.events == 0));
650        assert!(reports.iter().all(|r| r.failures[0].event == "setup"));
651    }
652
653    #[test]
654    fn offline_http_tools_keep_mock_effects_without_transport_features() {
655        let mut spec = spec_with_tests();
656        spec.tools[0].http = Some(
657            serde_json::from_value(json!({
658                "url": "http://127.0.0.1:1/never-executed"
659            }))
660            .expect("binding parses"),
661        );
662        let reports = spec.run_tests();
663        assert!(reports[0].passed, "{:?}", reports[0].failures);
664        assert!(reports[1].passed, "{:?}", reports[1].failures);
665    }
666
667    #[test]
668    fn offline_validation_preserves_conflicting_binding_errors() {
669        let mut spec = spec_with_tests();
670        spec.tools[0].http = Some(
671            serde_json::from_value(json!({
672                "url": "http://127.0.0.1:1/never-executed"
673            }))
674            .unwrap(),
675        );
676        spec.tools[0].mcp = Some("never-executed-tool-server".into());
677        let validation = spec.validate_for_replay();
678        assert!(!validation.valid);
679        assert!(
680            validation
681                .errors
682                .iter()
683                .any(|error| error.contains("both an http and an mcp"))
684        );
685        assert!(super::trace_test(&spec, "happy path").is_err());
686        assert!(
687            spec.run_tests()
688                .iter()
689                .all(|report| !report.passed && report.events == 0)
690        );
691    }
692}