Skip to main content

luft_core/
mock_backend.rs

1//! `MockBackend` (§2.8) — a deterministic test backend. Compiled under `test`
2//! or the `testing` feature so integration tests can reuse it.
3
4use crate::contract::*;
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::Arc;
7use std::time::Duration;
8
9/// How a mocked `run()` call should behave. Behaviours are consumed by call
10/// order; the last entry repeats once exhausted.
11#[derive(Debug, Clone)]
12pub enum MockBehavior {
13    Success {
14        output: serde_json::Value,
15        tokens: TokenUsage,
16        delay: Duration,
17    },
18    /// Fail with a fresh [`BackendError`] of the given kind (errors aren't
19    /// `Clone`, so we reconstruct on each call).
20    Fail { kind: FailKind, delay: Duration },
21    /// Never returns until cancelled; then yields `BackendError::Cancelled`.
22    Hang,
23}
24
25impl MockBehavior {
26    /// Convenience constructor for a zero-delay failure.
27    pub fn fail(kind: FailKind) -> Self {
28        MockBehavior::Fail {
29            kind,
30            delay: Duration::ZERO,
31        }
32    }
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum FailKind {
37    Spawn,
38    Protocol,
39    Timeout,
40    Cancelled,
41}
42
43impl FailKind {
44    fn to_error(self) -> BackendError {
45        match self {
46            FailKind::Spawn => BackendError::Spawn("mock spawn".into()),
47            FailKind::Protocol => BackendError::Protocol("mock protocol".into()),
48            FailKind::Timeout => BackendError::Timeout,
49            FailKind::Cancelled => BackendError::Cancelled,
50        }
51    }
52}
53
54#[derive(Clone)]
55pub struct MockBackend {
56    id: &'static str,
57    behaviors: Vec<MockBehavior>,
58    calls: Arc<AtomicU32>,
59}
60
61impl MockBackend {
62    pub fn new(id: &'static str, behaviors: Vec<MockBehavior>) -> Self {
63        assert!(
64            !behaviors.is_empty(),
65            "MockBackend needs at least one behavior"
66        );
67        Self {
68            id,
69            behaviors,
70            calls: Arc::new(AtomicU32::new(0)),
71        }
72    }
73
74    /// Number of times `run()` has been invoked.
75    pub fn call_count(&self) -> u32 {
76        self.calls.load(Ordering::SeqCst)
77    }
78
79    fn next(&self) -> &MockBehavior {
80        let i = (self.calls.fetch_add(1, Ordering::SeqCst) as usize).min(self.behaviors.len() - 1);
81        &self.behaviors[i]
82    }
83}
84
85#[async_trait::async_trait]
86impl AgentBackend for MockBackend {
87    fn id(&self) -> &'static str {
88        self.id
89    }
90
91    fn capabilities(&self) -> AgentCapabilities {
92        AgentCapabilities::default()
93    }
94
95    fn as_any(&self) -> &dyn std::any::Any {
96        self
97    }
98
99    async fn run(&self, task: AgentTask, ctx: RunContext) -> Result<AgentResult, BackendError> {
100        match self.next() {
101            MockBehavior::Success {
102                output,
103                tokens,
104                delay,
105            } => {
106                tokio::select! {
107                    _ = tokio::time::sleep(*delay) => {}
108                    _ = ctx.cancel.cancelled() => return Err(BackendError::Cancelled),
109                }
110                Ok(AgentResult {
111                    agent_id: task.agent_id,
112                    status: AgentStatus::Ok,
113                    output: output.clone(),
114                    findings: vec![],
115                    tokens_used: *tokens,
116                    artifacts: vec![],
117                    logs: LogRef::default(),
118                    thread_id: None,
119                })
120            }
121            MockBehavior::Fail { kind, delay } => {
122                tokio::select! {
123                    _ = tokio::time::sleep(*delay) => {}
124                    _ = ctx.cancel.cancelled() => return Err(BackendError::Cancelled),
125                }
126                Err(kind.to_error())
127            }
128            MockBehavior::Hang => {
129                ctx.cancel.cancelled().await;
130                Err(BackendError::Cancelled)
131            }
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use crate::contract::backend::AgentTask;
140    use crate::contract::ids::AgentId;
141    use serde_json::json;
142    use std::path::PathBuf;
143    use tokio::sync::broadcast;
144    use uuid::Uuid;
145
146    fn fresh_ctx() -> RunContext {
147        let (tx, _rx) = broadcast::channel(4);
148        RunContext {
149            run_id: Uuid::nil(),
150            cancel: tokio_util::sync::CancellationToken::new(),
151            events: tx,
152        }
153    }
154
155    fn make_ctx() -> RunContext {
156        fresh_ctx()
157    }
158
159    fn make_task() -> AgentTask {
160        AgentTask {
161            agent_id: Uuid::nil(),
162            phase_id: 0,
163            prompt: "test".into(),
164            model: None,
165            description: None,
166            role: None,
167            name: None,
168            agent_seq: 0,
169            allowlist: Some(ToolPolicy::default()),
170            workdir: PathBuf::from("."),
171            mcp_endpoint: None,
172            timeout: None,
173            output_schema: None,
174            workdir_override: None,
175            thread_id: None,
176        }
177    }
178
179    // ── Construction & accessors ─────────────────────────────────
180
181    #[test]
182    #[should_panic(expected = "needs at least one behavior")]
183    fn new_with_empty_behaviors_panics() {
184        let _ = MockBackend::new("mock", vec![]);
185    }
186
187    #[test]
188    fn id_and_capabilities_are_returned() {
189        let b = MockBackend::new(
190            "mock-id",
191            vec![MockBehavior::Success {
192                output: json!(null),
193                tokens: TokenUsage::default(),
194                delay: Duration::ZERO,
195            }],
196        );
197        assert_eq!(b.id(), "mock-id");
198        let _caps = b.capabilities();
199        // as_any roundtrip
200        let _ = b.as_any().is::<MockBackend>();
201    }
202
203    #[test]
204    fn call_count_starts_at_zero() {
205        let b = MockBackend::new("m", vec![MockBehavior::Hang]);
206        assert_eq!(b.call_count(), 0);
207    }
208
209    // ── Success path ─────────────────────────────────────────────
210
211    #[tokio::test]
212    async fn success_returns_ok_with_provided_output_and_tokens() {
213        let tokens = TokenUsage {
214            input: 10,
215            output: 20,
216            cache_read: 0,
217            cache_write: 0,
218        };
219        let b = MockBackend::new(
220            "m",
221            vec![MockBehavior::Success {
222                output: json!({"answer": 42}),
223                tokens,
224                delay: Duration::ZERO,
225            }],
226        );
227        let res = b.run(make_task(), make_ctx()).await.unwrap();
228        assert_eq!(res.status, AgentStatus::Ok);
229        assert_eq!(res.output, json!({"answer": 42}));
230        assert_eq!(res.tokens_used, tokens);
231        assert_eq!(res.agent_id, AgentId::nil());
232        assert!(res.findings.is_empty());
233        assert!(res.artifacts.is_empty());
234        assert_eq!(
235            serde_json::to_string(&res.logs).unwrap(),
236            serde_json::to_string(&LogRef::default()).unwrap()
237        );
238        assert_eq!(b.call_count(), 1);
239    }
240
241    #[tokio::test]
242    async fn success_respects_delay_then_returns() {
243        let start = std::time::Instant::now();
244        let b = MockBackend::new(
245            "m",
246            vec![MockBehavior::Success {
247                output: json!(null),
248                tokens: TokenUsage::default(),
249                delay: Duration::from_millis(40),
250            }],
251        );
252        let res = b.run(make_task(), make_ctx()).await.unwrap();
253        assert_eq!(res.status, AgentStatus::Ok);
254        assert!(start.elapsed() >= Duration::from_millis(35));
255    }
256
257    #[tokio::test]
258    async fn success_yields_cancelled_when_cancel_signal_arrives() {
259        let b = MockBackend::new(
260            "m",
261            vec![MockBehavior::Success {
262                output: json!(null),
263                tokens: TokenUsage::default(),
264                delay: Duration::from_millis(500),
265            }],
266        );
267        let cancel = tokio_util::sync::CancellationToken::new();
268        let ctx = RunContext {
269            run_id: Uuid::nil(),
270            cancel: cancel.clone(),
271            events: broadcast::channel(8).0,
272        };
273        let ctx2 = ctx.clone();
274        let h = tokio::spawn(async move { b.run(make_task(), ctx2).await });
275        // Let the task start its sleep.
276        tokio::time::sleep(Duration::from_millis(20)).await;
277        cancel.cancel();
278        let res = h.await.unwrap();
279        assert!(matches!(res, Err(BackendError::Cancelled)));
280    }
281
282    // ── Failure path ─────────────────────────────────────────────
283
284    #[tokio::test]
285    async fn fail_spawn_returns_spawn_error() {
286        let b = MockBackend::new("m", vec![MockBehavior::fail(FailKind::Spawn)]);
287        let res = b.run(make_task(), make_ctx()).await;
288        assert!(matches!(res, Err(BackendError::Spawn(_))));
289    }
290
291    #[tokio::test]
292    async fn fail_protocol_returns_protocol_error() {
293        let b = MockBackend::new("m", vec![MockBehavior::fail(FailKind::Protocol)]);
294        let res = b.run(make_task(), make_ctx()).await;
295        assert!(matches!(res, Err(BackendError::Protocol(_))));
296    }
297
298    #[tokio::test]
299    async fn fail_timeout_returns_timeout_error() {
300        let b = MockBackend::new("m", vec![MockBehavior::fail(FailKind::Timeout)]);
301        let res = b.run(make_task(), make_ctx()).await;
302        assert!(matches!(res, Err(BackendError::Timeout)));
303    }
304
305    #[tokio::test]
306    async fn fail_cancelled_returns_cancelled_error() {
307        let b = MockBackend::new("m", vec![MockBehavior::fail(FailKind::Cancelled)]);
308        let res = b.run(make_task(), make_ctx()).await;
309        assert!(matches!(res, Err(BackendError::Cancelled)));
310    }
311
312    #[tokio::test]
313    async fn fail_with_delay_yields_cancelled_if_cancelled_during_delay() {
314        let b = MockBackend::new(
315            "m",
316            vec![MockBehavior::Fail {
317                kind: FailKind::Spawn,
318                delay: Duration::from_millis(500),
319            }],
320        );
321        let cancel = tokio_util::sync::CancellationToken::new();
322        let ctx = RunContext {
323            run_id: Uuid::nil(),
324            cancel: cancel.clone(),
325            events: broadcast::channel(8).0,
326        };
327        let ctx2 = ctx.clone();
328        let b_clone = b.clone();
329        let h = tokio::spawn(async move { b_clone.run(make_task(), ctx2).await });
330        tokio::time::sleep(Duration::from_millis(20)).await;
331        cancel.cancel();
332        let res = h.await.unwrap();
333        // Cancelled during delay takes priority over the eventual fail error.
334        assert!(matches!(res, Err(BackendError::Cancelled)));
335    }
336
337    // ── Hang path ────────────────────────────────────────────────
338
339    #[tokio::test]
340    async fn hang_only_resolves_on_cancellation() {
341        let b = MockBackend::new("m", vec![MockBehavior::Hang]);
342        let cancel = tokio_util::sync::CancellationToken::new();
343        let ctx = RunContext {
344            run_id: Uuid::nil(),
345            cancel: cancel.clone(),
346            events: broadcast::channel(8).0,
347        };
348        let ctx2 = ctx.clone();
349        let b_clone = b.clone();
350        let h = tokio::spawn(async move { b_clone.run(make_task(), ctx2).await });
351        // Confirm it really is hanging — nothing happens in 50ms.
352        tokio::time::sleep(Duration::from_millis(50)).await;
353        assert!(!h.is_finished());
354        cancel.cancel();
355        let res = h.await.unwrap();
356        assert!(matches!(res, Err(BackendError::Cancelled)));
357        assert_eq!(b.call_count(), 1);
358    }
359
360    // ── Behavior sequencing ──────────────────────────────────────
361
362    #[tokio::test]
363    async fn behaviors_are_consumed_in_order() {
364        let b = MockBackend::new(
365            "m",
366            vec![
367                MockBehavior::fail(FailKind::Spawn),
368                MockBehavior::Success {
369                    output: json!({"step": 2}),
370                    tokens: TokenUsage::default(),
371                    delay: Duration::ZERO,
372                },
373                MockBehavior::fail(FailKind::Timeout),
374            ],
375        );
376        let r1 = b.run(make_task(), make_ctx()).await;
377        assert!(matches!(r1, Err(BackendError::Spawn(_))));
378        let r2 = b.run(make_task(), make_ctx()).await.unwrap();
379        assert_eq!(r2.output, json!({"step": 2}));
380        let r3 = b.run(make_task(), make_ctx()).await;
381        assert!(matches!(r3, Err(BackendError::Timeout)));
382        assert_eq!(b.call_count(), 3);
383    }
384
385    #[tokio::test]
386    async fn last_behavior_repeats_after_list_exhausted() {
387        // After 3 behaviors, the 4th call must repeat behavior #2 (Hang).
388        let b = MockBackend::new(
389            "m",
390            vec![
391                MockBehavior::Success {
392                    output: json!(1),
393                    tokens: TokenUsage::default(),
394                    delay: Duration::ZERO,
395                },
396                MockBehavior::Success {
397                    output: json!(2),
398                    tokens: TokenUsage::default(),
399                    delay: Duration::ZERO,
400                },
401                MockBehavior::Hang,
402            ],
403        );
404        let cancel = tokio_util::sync::CancellationToken::new();
405        let ctx = RunContext {
406            run_id: Uuid::nil(),
407            cancel: cancel.clone(),
408            events: broadcast::channel(8).0,
409        };
410        let r1 = b.run(make_task(), make_ctx()).await.unwrap();
411        assert_eq!(r1.output, json!(1));
412        let r2 = b.run(make_task(), make_ctx()).await.unwrap();
413        assert_eq!(r2.output, json!(2));
414        // 3rd call should hang.
415        let ctx2 = ctx.clone();
416        let b_for_spawn = b.clone();
417        let h = tokio::spawn(async move { b_for_spawn.run(make_task(), ctx2).await });
418        tokio::time::sleep(Duration::from_millis(30)).await;
419        assert!(!h.is_finished());
420        cancel.cancel();
421        let r3 = h.await.unwrap();
422        assert!(matches!(r3, Err(BackendError::Cancelled)));
423        // 4th call must still hang (last behavior repeats).
424        let ctx3 = tokio_util::sync::CancellationToken::new();
425        let ctx3_clone = ctx3.clone();
426        let b_for_spawn2 = b.clone();
427        let h2 = tokio::spawn(async move {
428            let c = RunContext {
429                run_id: Uuid::nil(),
430                cancel: ctx3_clone,
431                events: broadcast::channel(8).0,
432            };
433            b_for_spawn2.run(make_task(), c).await
434        });
435        tokio::time::sleep(Duration::from_millis(30)).await;
436        assert!(!h2.is_finished());
437        ctx3.cancel();
438        let r4 = h2.await.unwrap();
439        assert!(matches!(r4, Err(BackendError::Cancelled)));
440    }
441
442    #[tokio::test]
443    async fn single_behavior_repeats_forever() {
444        let b = MockBackend::new(
445            "m",
446            vec![MockBehavior::Success {
447                output: json!("ok"),
448                tokens: TokenUsage::default(),
449                delay: Duration::ZERO,
450            }],
451        );
452        for i in 1..=5 {
453            let r = b.run(make_task(), make_ctx()).await.unwrap();
454            assert_eq!(r.output, json!("ok"));
455            assert_eq!(b.call_count(), i);
456        }
457    }
458
459    #[tokio::test]
460    async fn call_count_increments_even_when_behavior_is_hang() {
461        let b = MockBackend::new("m", vec![MockBehavior::Hang]);
462        let cancel = tokio_util::sync::CancellationToken::new();
463        let ctx = RunContext {
464            run_id: Uuid::nil(),
465            cancel: cancel.clone(),
466            events: broadcast::channel(8).0,
467        };
468        let ctx2 = ctx.clone();
469        let b_clone = b.clone();
470        let h = tokio::spawn(async move { b_clone.run(make_task(), ctx2).await });
471        // The call counter is incremented synchronously before the await,
472        // so by the time the spawned task is waiting on cancellation,
473        // the count is already 1.
474        tokio::time::sleep(Duration::from_millis(10)).await;
475        assert_eq!(b.call_count(), 1);
476        cancel.cancel();
477        let _ = h.await.unwrap();
478        assert_eq!(b.call_count(), 1);
479    }
480
481    // ── MockBehavior helper ──────────────────────────────────────
482
483    #[test]
484    fn mock_behavior_fail_helper_uses_zero_delay() {
485        let b = MockBehavior::fail(FailKind::Spawn);
486        match b {
487            MockBehavior::Fail { kind, delay } => {
488                assert!(matches!(kind, FailKind::Spawn));
489                assert_eq!(delay, Duration::ZERO);
490            }
491            _ => panic!("wrong variant"),
492        }
493    }
494
495    #[test]
496    fn mock_behavior_clone_is_independent() {
497        let b = MockBehavior::Success {
498            output: json!({"x": 1}),
499            tokens: TokenUsage {
500                input: 1,
501                output: 2,
502                cache_read: 0,
503                cache_write: 0,
504            },
505            delay: Duration::from_millis(50),
506        };
507        let c = b.clone();
508        match (b, c) {
509            (
510                MockBehavior::Success {
511                    output: o1,
512                    tokens: t1,
513                    delay: d1,
514                },
515                MockBehavior::Success {
516                    output: o2,
517                    tokens: t2,
518                    delay: d2,
519                },
520            ) => {
521                assert_eq!(o1, o2);
522                assert_eq!(t1, t2);
523                assert_eq!(d1, d2);
524            }
525            _ => panic!("not success"),
526        }
527    }
528
529    #[test]
530    fn fail_kind_is_copy_and_eq() {
531        let k = FailKind::Spawn;
532        let k2 = k;
533        assert_eq!(k, k2);
534        assert_ne!(FailKind::Spawn, FailKind::Protocol);
535    }
536
537    #[test]
538    fn mock_behavior_debug_format_is_human_readable() {
539        let b = MockBehavior::Success {
540            output: json!({"k": "v"}),
541            tokens: TokenUsage::default(),
542            delay: Duration::from_millis(100),
543        };
544        let dbg = format!("{:?}", b);
545        assert!(dbg.contains("Success"));
546        assert!(dbg.contains("delay"));
547    }
548}