Skip to main content

ironflow_engine/testing/
mocks.rs

1//! Canned step results and the closure-backed doubles that serve them.
2//!
3//! [`MockInterceptor`] answers shell and HTTP steps from closures; agent steps
4//! are answered one seam lower, by [`MockAgentProvider`] or
5//! [`MissingAgentProvider`]. Everything here mirrors the shape the real
6//! executors persist, so a handler cannot tell the difference.
7//!
8//! The value types are defined locally rather than reusing
9//! [`ShellOutput`](ironflow_core::operations::shell::ShellOutput) and
10//! [`HttpOutput`](ironflow_core::operations::http::HttpOutput): those have
11//! private fields and no public constructor.
12
13use std::collections::BTreeMap;
14use std::fmt;
15use std::sync::Arc;
16
17use rust_decimal::Decimal;
18use serde_json::{Value, json, to_string};
19
20use ironflow_core::error::{AgentError, OperationError};
21use ironflow_core::provider::{AgentConfig, AgentOutput, AgentProvider, InvokeFuture};
22
23use crate::config::{ApprovalConfig, HttpConfig, HumanInputConfig, ShellConfig, StepConfig};
24use crate::error::EngineError;
25use crate::executor::{
26    ApprovalOutcome, HumanInputOutcome, SignalOutcome, StepArtifacts, StepInterceptor, StepOutput,
27};
28
29/// Message carried by [`MissingAgentProvider`] failures.
30const MISSING_AGENT_PROVIDER: &str = "TestEngine has no agent provider: call with_mock_agent(...), with_recorded_agent(...) or \
31     with_agent_provider(...)";
32
33/// Canned result of a mocked shell step.
34///
35/// # Examples
36///
37/// ```
38/// use ironflow_engine::testing::MockShellOutput;
39///
40/// let ok = MockShellOutput::ok("built 3 crates");
41/// assert_eq!(ok.exit_code, 0);
42///
43/// let ko = MockShellOutput::failed(2, "linker not found");
44/// assert_eq!(ko.stderr, "linker not found");
45/// ```
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47pub struct MockShellOutput {
48    /// Standard output the step reports.
49    pub stdout: String,
50    /// Standard error the step reports.
51    pub stderr: String,
52    /// Process exit code. Anything but `0` fails the step.
53    pub exit_code: i32,
54}
55
56impl MockShellOutput {
57    /// A successful command that printed `stdout`.
58    ///
59    /// # Examples
60    ///
61    /// ```
62    /// use ironflow_engine::testing::MockShellOutput;
63    ///
64    /// let output = MockShellOutput::ok("ok\n");
65    /// assert_eq!(output.stdout, "ok\n");
66    /// assert!(output.stderr.is_empty());
67    /// ```
68    pub fn ok(stdout: &str) -> Self {
69        Self {
70            stdout: stdout.to_string(),
71            ..Self::default()
72        }
73    }
74
75    /// A command that exited with `exit_code` after printing `stderr`.
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use ironflow_engine::testing::MockShellOutput;
81    ///
82    /// let output = MockShellOutput::failed(127, "command not found");
83    /// assert_eq!(output.exit_code, 127);
84    /// ```
85    pub fn failed(exit_code: i32, stderr: &str) -> Self {
86        Self {
87            stdout: String::new(),
88            stderr: stderr.to_string(),
89            exit_code,
90        }
91    }
92
93    /// Convert to what the step lifecycle expects.
94    ///
95    /// Mirrors [`ShellExecutor`](crate::executor::ShellExecutor): a non-zero
96    /// exit code is an error, not an output, unless the step set
97    /// `exit_code_as_output`. `allow_failure`, step retry policies and run
98    /// failure all key off that error.
99    pub(crate) fn into_step_result(
100        self,
101        exit_code_as_output: bool,
102    ) -> Result<StepOutput, EngineError> {
103        if self.exit_code != 0 && !exit_code_as_output {
104            return Err(EngineError::Operation(OperationError::Shell {
105                exit_code: self.exit_code,
106                stderr: self.stderr,
107            }));
108        }
109
110        Ok(StepOutput {
111            output: json!({
112                "stdout": self.stdout,
113                "stderr": self.stderr,
114                "exit_code": self.exit_code,
115            }),
116            duration_ms: 0,
117            cost_usd: Decimal::ZERO,
118            input_tokens: None,
119            cache_read_input_tokens: None,
120            cache_creation_input_tokens: None,
121            output_tokens: None,
122            model: None,
123            debug_messages: None,
124            artifacts: StepArtifacts::default(),
125            account_id: None,
126            environment_id: None,
127        })
128    }
129}
130
131/// Canned response of a mocked HTTP step.
132///
133/// A non-2xx status is *not* an error, exactly like the real
134/// [`HttpExecutor`](crate::executor::HttpExecutor): the status lands in the
135/// step output. A transport failure is expressed by returning
136/// `Err(OperationError::Http { status: None, .. })` from the mock closure.
137///
138/// # Examples
139///
140/// ```
141/// use ironflow_engine::testing::MockHttpResponse;
142/// use serde_json::json;
143///
144/// let created = MockHttpResponse::json(201, &json!({"id": 7}))
145///     .header("location", "/things/7");
146/// assert_eq!(created.status, 201);
147/// assert_eq!(created.headers, vec![("location".to_string(), "/things/7".to_string())]);
148/// ```
149#[derive(Debug, Clone, PartialEq, Eq)]
150pub struct MockHttpResponse {
151    /// HTTP status code the step reports.
152    pub status: u16,
153    /// Response headers, in insertion order.
154    pub headers: Vec<(String, String)>,
155    /// Raw response body.
156    pub body: String,
157}
158
159impl Default for MockHttpResponse {
160    fn default() -> Self {
161        Self {
162            status: 200,
163            headers: Vec::new(),
164            body: String::new(),
165        }
166    }
167}
168
169impl MockHttpResponse {
170    /// A `200 OK` carrying `body` serialized as JSON.
171    ///
172    /// # Examples
173    ///
174    /// ```
175    /// use ironflow_engine::testing::MockHttpResponse;
176    /// use serde_json::json;
177    ///
178    /// let response = MockHttpResponse::ok(&json!({"ok": true}));
179    /// assert_eq!(response.status, 200);
180    /// assert_eq!(response.body, r#"{"ok":true}"#);
181    /// ```
182    pub fn ok(body: &Value) -> Self {
183        Self::json(200, body)
184    }
185
186    /// A response with the given status carrying `body` serialized as JSON.
187    ///
188    /// # Examples
189    ///
190    /// ```
191    /// use ironflow_engine::testing::MockHttpResponse;
192    /// use serde_json::json;
193    ///
194    /// let response = MockHttpResponse::json(404, &json!({"error": "not found"}));
195    /// assert_eq!(response.status, 404);
196    /// ```
197    pub fn json(status: u16, body: &Value) -> Self {
198        Self {
199            status,
200            headers: Vec::new(),
201            // `Value` always serializes; the fallback keeps the mock infallible.
202            body: to_string(body).unwrap_or_else(|_| body.to_string()),
203        }
204    }
205
206    /// A response with the given status carrying a raw text body.
207    ///
208    /// # Examples
209    ///
210    /// ```
211    /// use ironflow_engine::testing::MockHttpResponse;
212    ///
213    /// let response = MockHttpResponse::text(503, "upstream is down");
214    /// assert_eq!(response.body, "upstream is down");
215    /// ```
216    pub fn text(status: u16, body: &str) -> Self {
217        Self {
218            status,
219            headers: Vec::new(),
220            body: body.to_string(),
221        }
222    }
223
224    /// Add a response header.
225    ///
226    /// # Examples
227    ///
228    /// ```
229    /// use ironflow_engine::testing::MockHttpResponse;
230    ///
231    /// let response = MockHttpResponse::text(200, "pong").header("x-trace", "abc");
232    /// assert_eq!(response.headers.len(), 1);
233    /// ```
234    pub fn header(mut self, name: &str, value: &str) -> Self {
235        self.headers.push((name.to_string(), value.to_string()));
236        self
237    }
238
239    /// Convert to the exact shape [`HttpExecutor`](crate::executor::HttpExecutor)
240    /// persists.
241    pub(crate) fn into_step_output(self) -> StepOutput {
242        let headers: BTreeMap<String, String> = self.headers.into_iter().collect();
243        StepOutput {
244            output: json!({
245                "status": self.status,
246                "headers": headers,
247                "body": self.body,
248            }),
249            duration_ms: 0,
250            cost_usd: Decimal::ZERO,
251            input_tokens: None,
252            cache_read_input_tokens: None,
253            cache_creation_input_tokens: None,
254            output_tokens: None,
255            model: None,
256            debug_messages: None,
257            artifacts: StepArtifacts::default(),
258            account_id: None,
259            environment_id: None,
260        }
261    }
262}
263
264/// Closure answering a shell step from its config.
265pub type ShellMock =
266    Arc<dyn Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync>;
267
268/// Closure answering an HTTP step from its config.
269pub type HttpMock =
270    Arc<dyn Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync>;
271
272/// Closure answering a human input step from its name and config.
273pub type HumanInputMock = Arc<dyn Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync>;
274
275/// Closure answering a signal step from its step name, signal name and key.
276pub type SignalMock = Arc<dyn Fn(&str, &str, &str) -> SignalOutcome + Send + Sync>;
277
278/// Closure answering an agent invocation from its config.
279pub type AgentMock = Arc<dyn Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync>;
280
281/// A [`StepInterceptor`] built from closures.
282///
283/// Built by [`TestEngine`](crate::testing::TestEngine); a step kind with no
284/// mock attached falls through to the real executor.
285///
286/// # Examples
287///
288/// ```
289/// use ironflow_engine::config::{ShellConfig, StepConfig};
290/// use ironflow_engine::executor::StepInterceptor;
291/// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
292///
293/// let interceptor = MockInterceptor::new().shell(|_cfg| Ok(MockShellOutput::ok("mocked")));
294/// let config = StepConfig::Shell(ShellConfig::new("./deploy.sh"));
295///
296/// let output = interceptor
297///     .intercept(&config)
298///     .expect("shell steps are mocked")
299///     .expect("the mock succeeded");
300/// assert_eq!(output.stdout(), "mocked");
301/// ```
302#[derive(Clone, Default)]
303pub struct MockInterceptor {
304    shell: Option<ShellMock>,
305    http: Option<HttpMock>,
306    approval: Option<ApprovalOutcome>,
307    human_input: Option<HumanInputMock>,
308    signal: Option<SignalMock>,
309}
310
311impl MockInterceptor {
312    /// An interceptor that mocks nothing.
313    ///
314    /// # Examples
315    ///
316    /// ```
317    /// use ironflow_engine::config::{ShellConfig, StepConfig};
318    /// use ironflow_engine::executor::StepInterceptor;
319    /// use ironflow_engine::testing::MockInterceptor;
320    ///
321    /// let interceptor = MockInterceptor::new();
322    /// let config = StepConfig::Shell(ShellConfig::new("echo hi"));
323    /// assert!(interceptor.intercept(&config).is_none());
324    /// ```
325    pub fn new() -> Self {
326        Self::default()
327    }
328
329    /// Answer every shell step with `f`.
330    ///
331    /// # Examples
332    ///
333    /// ```
334    /// use ironflow_engine::testing::{MockInterceptor, MockShellOutput};
335    ///
336    /// let interceptor = MockInterceptor::new()
337    ///     .shell(|cfg| Ok(MockShellOutput::ok(&format!("ran {}", cfg.command))));
338    /// # let _ = interceptor;
339    /// ```
340    pub fn shell(
341        mut self,
342        f: impl Fn(&ShellConfig) -> Result<MockShellOutput, OperationError> + Send + Sync + 'static,
343    ) -> Self {
344        self.shell = Some(Arc::new(f));
345        self
346    }
347
348    /// Answer every HTTP step with `f`.
349    ///
350    /// # Examples
351    ///
352    /// ```
353    /// use ironflow_engine::testing::{MockHttpResponse, MockInterceptor};
354    /// use serde_json::json;
355    ///
356    /// let interceptor = MockInterceptor::new()
357    ///     .http(|_cfg| Ok(MockHttpResponse::ok(&json!({"ok": true}))));
358    /// # let _ = interceptor;
359    /// ```
360    pub fn http(
361        mut self,
362        f: impl Fn(&HttpConfig) -> Result<MockHttpResponse, OperationError> + Send + Sync + 'static,
363    ) -> Self {
364        self.http = Some(Arc::new(f));
365        self
366    }
367
368    /// Resolve every approval gate with `outcome`.
369    ///
370    /// # Examples
371    ///
372    /// ```
373    /// use ironflow_engine::testing::{ApprovalOutcome, MockInterceptor};
374    ///
375    /// let interceptor = MockInterceptor::new().approval(ApprovalOutcome::Approved);
376    /// # let _ = interceptor;
377    /// ```
378    pub fn approval(mut self, outcome: ApprovalOutcome) -> Self {
379        self.approval = Some(outcome);
380        self
381    }
382
383    /// Answer every human input step with `f`.
384    ///
385    /// # Examples
386    ///
387    /// ```
388    /// use ironflow_engine::testing::{HumanInputOutcome, MockInterceptor};
389    /// use serde_json::json;
390    ///
391    /// let interceptor = MockInterceptor::new()
392    ///     .human_input(|_name, _cfg| HumanInputOutcome::Provided(json!({"answers": ["yes"]})));
393    /// # let _ = interceptor;
394    /// ```
395    pub fn human_input(
396        mut self,
397        f: impl Fn(&str, &HumanInputConfig) -> HumanInputOutcome + Send + Sync + 'static,
398    ) -> Self {
399        self.human_input = Some(Arc::new(f));
400        self
401    }
402
403    /// Resolve every signal step with `f`.
404    ///
405    /// `f` receives the step name, the signal name and the key.
406    ///
407    /// # Examples
408    ///
409    /// ```
410    /// use ironflow_engine::testing::{MockInterceptor, SignalOutcome};
411    /// use serde_json::json;
412    ///
413    /// let interceptor = MockInterceptor::new()
414    ///     .signal(|_step, _name, _key| SignalOutcome::Received(json!({"status": "success"})));
415    /// # let _ = interceptor;
416    /// ```
417    pub fn signal(
418        mut self,
419        f: impl Fn(&str, &str, &str) -> SignalOutcome + Send + Sync + 'static,
420    ) -> Self {
421        self.signal = Some(Arc::new(f));
422        self
423    }
424}
425
426impl fmt::Debug for MockInterceptor {
427    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
428        // Closures are not `Debug`: report which seams are mocked instead.
429        f.debug_struct("MockInterceptor")
430            .field("shell", &self.shell.is_some())
431            .field("http", &self.http.is_some())
432            .field("approval", &self.approval)
433            .field("human_input", &self.human_input.is_some())
434            .field("signal", &self.signal.is_some())
435            .finish()
436    }
437}
438
439impl StepInterceptor for MockInterceptor {
440    fn intercept(&self, config: &StepConfig) -> Option<Result<StepOutput, EngineError>> {
441        match config {
442            StepConfig::Shell(cfg) => {
443                let mock = self.shell.as_ref()?;
444                Some(match mock(cfg) {
445                    Ok(out) => out.into_step_result(cfg.exit_code_as_output),
446                    Err(err) => Err(EngineError::Operation(err)),
447                })
448            }
449            StepConfig::Http(cfg) => {
450                let mock = self.http.as_ref()?;
451                Some(match mock(cfg) {
452                    Ok(res) => Ok(res.into_step_output()),
453                    Err(err) => Err(EngineError::Operation(err)),
454                })
455            }
456            // Agent steps are mocked at the provider seam instead.
457            _ => None,
458        }
459    }
460
461    fn intercept_approval(&self, _name: &str, _config: &ApprovalConfig) -> Option<ApprovalOutcome> {
462        self.approval.clone()
463    }
464
465    fn intercept_human_input(
466        &self,
467        name: &str,
468        config: &HumanInputConfig,
469        _schema: &Value,
470    ) -> Option<HumanInputOutcome> {
471        self.human_input.as_ref().map(|f| f(name, config))
472    }
473
474    fn intercept_signal(
475        &self,
476        name: &str,
477        signal_name: &str,
478        key: &str,
479        _schema: &Value,
480    ) -> Option<SignalOutcome> {
481        self.signal.as_ref().map(|f| f(name, signal_name, key))
482    }
483}
484
485/// An [`AgentProvider`] backed by a closure.
486///
487/// # Examples
488///
489/// ```
490/// use ironflow_core::provider::AgentOutput;
491/// use ironflow_engine::testing::MockAgentProvider;
492/// use serde_json::json;
493///
494/// let provider = MockAgentProvider::new(|cfg| {
495///     assert!(cfg.prompt.contains("review"));
496///     Ok(AgentOutput::new(json!({"score": 9})))
497/// });
498/// # let _ = provider;
499/// ```
500pub struct MockAgentProvider {
501    f: AgentMock,
502}
503
504impl MockAgentProvider {
505    /// Answer every invocation with `f`.
506    ///
507    /// # Examples
508    ///
509    /// ```
510    /// use ironflow_core::provider::AgentOutput;
511    /// use ironflow_engine::testing::MockAgentProvider;
512    /// use serde_json::json;
513    ///
514    /// let provider = MockAgentProvider::new(|_cfg| Ok(AgentOutput::new(json!("done"))));
515    /// # let _ = provider;
516    /// ```
517    pub fn new(
518        f: impl Fn(&AgentConfig) -> Result<AgentOutput, AgentError> + Send + Sync + 'static,
519    ) -> Self {
520        Self { f: Arc::new(f) }
521    }
522}
523
524impl fmt::Debug for MockAgentProvider {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        f.debug_struct("MockAgentProvider").finish_non_exhaustive()
527    }
528}
529
530impl AgentProvider for MockAgentProvider {
531    fn invoke<'a>(&'a self, config: &'a AgentConfig) -> InvokeFuture<'a> {
532        let result = (self.f)(config);
533        Box::pin(async move { result })
534    }
535}
536
537/// The provider a [`TestEngine`](crate::testing::TestEngine) uses when no agent
538/// backend was configured.
539///
540/// Every invocation fails with an explanation instead of reaching the Claude
541/// CLI, so a forgotten `with_mock_agent` is a loud test failure, not a silent
542/// network call.
543///
544/// # Examples
545///
546/// ```
547/// use ironflow_engine::testing::MissingAgentProvider;
548///
549/// // Usable anywhere an `AgentProvider` is expected, including as the inner
550/// // provider of a `RecordReplayProvider` in replay mode.
551/// let provider = MissingAgentProvider;
552/// assert_eq!(format!("{provider:?}"), "MissingAgentProvider");
553/// ```
554#[derive(Debug, Default, Clone, Copy)]
555pub struct MissingAgentProvider;
556
557impl AgentProvider for MissingAgentProvider {
558    fn invoke<'a>(&'a self, _config: &'a AgentConfig) -> InvokeFuture<'a> {
559        Box::pin(async move {
560            Err(AgentError::ProcessFailed {
561                exit_code: -1,
562                stderr: MISSING_AGENT_PROVIDER.to_string(),
563            })
564        })
565    }
566}
567
568#[cfg(test)]
569mod tests {
570    use super::*;
571
572    use crate::config::AgentStepConfig;
573
574    #[test]
575    fn shell_ok_maps_to_the_real_executor_output_shape() {
576        let output = MockShellOutput::ok("hello\n")
577            .into_step_result(false)
578            .expect("exit code 0 succeeds");
579
580        assert_eq!(output.output["stdout"], "hello\n");
581        assert_eq!(output.output["stderr"], "");
582        assert_eq!(output.output["exit_code"], 0);
583        assert_eq!(output.cost_usd, Decimal::ZERO);
584    }
585
586    #[test]
587    fn shell_default_is_an_empty_success() {
588        let default = MockShellOutput::default();
589        assert_eq!(default.exit_code, 0);
590        assert!(default.stdout.is_empty());
591        assert!(default.stderr.is_empty());
592    }
593
594    #[test]
595    fn shell_non_zero_exit_is_an_operation_error() {
596        let err = MockShellOutput::failed(2, "x")
597            .into_step_result(false)
598            .expect_err("a non-zero exit code fails the step");
599
600        match err {
601            EngineError::Operation(OperationError::Shell { exit_code, stderr }) => {
602                assert_eq!(exit_code, 2);
603                assert_eq!(stderr, "x");
604            }
605            other => panic!("expected a shell operation error, got {other}"),
606        }
607    }
608
609    #[test]
610    fn shell_non_zero_exit_with_option_is_an_output() {
611        let output = MockShellOutput::failed(2, "x")
612            .into_step_result(true)
613            .expect("the option turns a non-zero exit into an output");
614
615        assert_eq!(output.output["exit_code"], 2);
616        assert_eq!(output.output["stderr"], "x");
617        assert!(!output.is_success());
618    }
619
620    #[test]
621    fn intercept_shell_non_zero_with_option_completes() {
622        let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::failed(2, "x")));
623        let config = StepConfig::Shell(ShellConfig::new("x").exit_code_as_output());
624
625        let output = interceptor
626            .intercept(&config)
627            .expect("the shell mock answers")
628            .expect("the step completes");
629        assert_eq!(output.exit_code(), Some(2));
630
631        let plain = StepConfig::Shell(ShellConfig::new("x"));
632        assert!(
633            interceptor
634                .intercept(&plain)
635                .expect("the shell mock answers")
636                .is_err()
637        );
638    }
639
640    #[test]
641    fn http_json_carries_status_body_and_headers() {
642        let output = MockHttpResponse::json(201, &json!({"id": 7}))
643            .header("location", "/things/7")
644            .into_step_output();
645
646        assert_eq!(output.output["status"], 201);
647        assert_eq!(output.output["body"], r#"{"id":7}"#);
648        assert_eq!(output.output["headers"]["location"], "/things/7");
649    }
650
651    #[test]
652    fn http_default_is_an_empty_200() {
653        let default = MockHttpResponse::default();
654        assert_eq!(default.status, 200);
655        assert!(default.body.is_empty());
656        assert!(default.headers.is_empty());
657    }
658
659    #[test]
660    fn http_non_2xx_is_still_an_output() {
661        let output = MockHttpResponse::text(500, "boom").into_step_output();
662        assert_eq!(output.status(), Some(500));
663        assert_eq!(output.body(), "boom");
664    }
665
666    #[test]
667    fn intercept_declines_agent_steps() {
668        let interceptor = MockInterceptor::new().shell(|_| Ok(MockShellOutput::ok("x")));
669        let config = StepConfig::Agent(AgentStepConfig::new("review this"));
670
671        assert!(interceptor.intercept(&config).is_none());
672    }
673
674    #[test]
675    fn intercept_declines_shell_steps_without_a_shell_mock() {
676        let interceptor = MockInterceptor::new();
677        let config = StepConfig::Shell(ShellConfig::new("echo hi"));
678
679        assert!(interceptor.intercept(&config).is_none());
680    }
681
682    #[test]
683    fn intercept_approval_returns_the_configured_outcome() {
684        let interceptor = MockInterceptor::new().approval(ApprovalOutcome::reject("nope"));
685        let config = ApprovalConfig::new("Approve?");
686
687        assert_eq!(
688            interceptor.intercept_approval("gate", &config),
689            Some(ApprovalOutcome::reject("nope"))
690        );
691        assert_eq!(
692            MockInterceptor::new().intercept_approval("gate", &config),
693            None
694        );
695    }
696
697    #[test]
698    fn intercept_human_input_returns_the_mocked_answer() {
699        let interceptor = MockInterceptor::new().human_input(|name, cfg| {
700            HumanInputOutcome::Provided(json!({"step": name, "message": cfg.message()}))
701        });
702        let config = HumanInputConfig::new("Answer?");
703        let answer = json!({"step": "clarify", "message": "Answer?"});
704
705        assert_eq!(
706            interceptor.intercept_human_input("clarify", &config, &json!({})),
707            Some(HumanInputOutcome::Provided(answer))
708        );
709    }
710
711    #[test]
712    fn intercept_human_input_declines_without_a_mock() {
713        let config = HumanInputConfig::new("Answer?");
714
715        assert_eq!(
716            MockInterceptor::new().intercept_human_input("clarify", &config, &json!({})),
717            None
718        );
719    }
720
721    #[test]
722    fn intercept_signal_returns_the_mocked_outcome() {
723        let interceptor = MockInterceptor::new().signal(|step, name, key| {
724            SignalOutcome::Received(json!({"step": step, "name": name, "key": key}))
725        });
726        let expected = json!({"step": "wait-ci", "name": "ci.done", "key": "abc"});
727
728        assert_eq!(
729            interceptor.intercept_signal("wait-ci", "ci.done", "abc", &json!({})),
730            Some(SignalOutcome::Received(expected))
731        );
732        assert_eq!(
733            MockInterceptor::new().intercept_signal("wait-ci", "ci.done", "abc", &json!({})),
734            None
735        );
736    }
737
738    #[test]
739    fn debug_reports_which_seams_are_mocked() {
740        let interceptor = MockInterceptor::new().http(|_| Ok(MockHttpResponse::default()));
741        let rendered = format!("{interceptor:?}");
742
743        assert!(rendered.contains("shell: false"));
744        assert!(rendered.contains("http: true"));
745    }
746
747    #[tokio::test]
748    async fn missing_agent_provider_names_the_three_constructors() {
749        let config = AgentConfig::new("anything");
750        let err = MissingAgentProvider
751            .invoke(&config)
752            .await
753            .expect_err("no agent backend is configured");
754
755        let message = err.to_string();
756        assert!(message.contains("with_mock_agent"));
757        assert!(message.contains("with_recorded_agent"));
758        assert!(message.contains("with_agent_provider"));
759    }
760
761    #[tokio::test]
762    async fn mock_agent_provider_runs_the_closure() {
763        let provider = MockAgentProvider::new(|cfg| {
764            let echoed = json!({"echoed": cfg.prompt.clone()});
765            Ok(AgentOutput::new(echoed))
766        });
767        let config = AgentConfig::new("say hi");
768
769        let output = provider.invoke(&config).await.expect("the mock succeeded");
770
771        assert_eq!(output.value["echoed"], "say hi");
772    }
773}