1use crate::contract::*;
5use std::sync::atomic::{AtomicU32, Ordering};
6use std::sync::Arc;
7use std::time::Duration;
8
9#[derive(Debug, Clone)]
12pub enum MockBehavior {
13 Success {
14 output: serde_json::Value,
15 tokens: TokenUsage,
16 delay: Duration,
17 },
18 Fail { kind: FailKind, delay: Duration },
21 Hang,
23}
24
25impl MockBehavior {
26 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 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 #[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 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 #[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 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 #[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 assert!(matches!(res, Err(BackendError::Cancelled)));
335 }
336
337 #[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 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 #[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 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 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 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 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 #[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}