Skip to main content

batty_cli/agent/
mock.rs

1//! Mock agent backend for testing.
2//!
3//! Provides a configurable mock that implements `AgentAdapter`, tracking all
4//! method calls and supporting error injection. No real agents or tmux needed.
5
6use std::path::Path;
7use std::sync::{Arc, Mutex};
8
9use crate::agent::{AgentAdapter, BackendHealth, SpawnConfig};
10use crate::prompt::PromptPatterns;
11
12/// Record of a method call on MockBackend.
13#[derive(Debug, Clone, PartialEq)]
14pub enum MockCall {
15    Name,
16    SpawnConfig {
17        task: String,
18        work_dir: String,
19    },
20    PromptPatterns,
21    InstructionCandidates,
22    WrapLaunchPrompt {
23        prompt: String,
24    },
25    FormatInput {
26        response: String,
27    },
28    LaunchCommand {
29        prompt: String,
30        idle: bool,
31        resume: bool,
32        session_id: Option<String>,
33    },
34    NewSessionId,
35    SupportsResume,
36    HealthCheck,
37}
38
39/// Configurable behavior for the mock backend.
40#[derive(Debug, Clone)]
41pub struct MockConfig {
42    pub name: String,
43    pub launch_command_result: Result<String, String>,
44    pub session_id: Option<String>,
45    pub supports_resume: bool,
46    pub health: BackendHealth,
47    pub format_input_suffix: String,
48    pub wrap_prompt_prefix: String,
49}
50
51impl Default for MockConfig {
52    fn default() -> Self {
53        Self {
54            name: "mock-agent".to_string(),
55            launch_command_result: Ok("exec mock-agent --run".to_string()),
56            session_id: None,
57            supports_resume: false,
58            health: BackendHealth::Healthy,
59            format_input_suffix: "\n".to_string(),
60            wrap_prompt_prefix: String::new(),
61        }
62    }
63}
64
65/// A mock backend that records all calls and returns configurable values.
66pub struct MockBackend {
67    config: MockConfig,
68    calls: Arc<Mutex<Vec<MockCall>>>,
69}
70
71impl MockBackend {
72    pub fn new(config: MockConfig) -> Self {
73        Self {
74            config,
75            calls: Arc::new(Mutex::new(Vec::new())),
76        }
77    }
78
79    /// Create with default config.
80    pub fn default_mock() -> Self {
81        Self::new(MockConfig::default())
82    }
83
84    /// Get a clone of the call log shared handle.
85    pub fn call_log(&self) -> Arc<Mutex<Vec<MockCall>>> {
86        Arc::clone(&self.calls)
87    }
88
89    /// Get a snapshot of recorded calls.
90    pub fn calls(&self) -> Vec<MockCall> {
91        self.calls.lock().unwrap().clone()
92    }
93
94    fn record(&self, call: MockCall) {
95        self.calls.lock().unwrap().push(call);
96    }
97}
98
99impl AgentAdapter for MockBackend {
100    fn name(&self) -> &str {
101        self.record(MockCall::Name);
102        &self.config.name
103    }
104
105    fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig {
106        self.record(MockCall::SpawnConfig {
107            task: task_description.to_string(),
108            work_dir: work_dir.to_string_lossy().to_string(),
109        });
110        SpawnConfig {
111            program: self.config.name.clone(),
112            args: vec![task_description.to_string()],
113            work_dir: work_dir.to_string_lossy().to_string(),
114            env: vec![],
115        }
116    }
117
118    fn prompt_patterns(&self) -> PromptPatterns {
119        self.record(MockCall::PromptPatterns);
120        // Return claude patterns as a reasonable default
121        PromptPatterns::claude_code()
122    }
123
124    fn instruction_candidates(&self) -> &'static [&'static str] {
125        self.record(MockCall::InstructionCandidates);
126        &["CLAUDE.md", "AGENTS.md"]
127    }
128
129    fn wrap_launch_prompt(&self, prompt: &str) -> String {
130        self.record(MockCall::WrapLaunchPrompt {
131            prompt: prompt.to_string(),
132        });
133        if self.config.wrap_prompt_prefix.is_empty() {
134            prompt.to_string()
135        } else {
136            format!("{}{}", self.config.wrap_prompt_prefix, prompt)
137        }
138    }
139
140    fn format_input(&self, response: &str) -> String {
141        self.record(MockCall::FormatInput {
142            response: response.to_string(),
143        });
144        format!("{response}{}", self.config.format_input_suffix)
145    }
146
147    fn launch_command(
148        &self,
149        prompt: &str,
150        idle: bool,
151        resume: bool,
152        session_id: Option<&str>,
153    ) -> anyhow::Result<String> {
154        self.record(MockCall::LaunchCommand {
155            prompt: prompt.to_string(),
156            idle,
157            resume,
158            session_id: session_id.map(String::from),
159        });
160        self.config
161            .launch_command_result
162            .clone()
163            .map_err(|e| anyhow::anyhow!(e))
164    }
165
166    fn new_session_id(&self) -> Option<String> {
167        self.record(MockCall::NewSessionId);
168        self.config.session_id.clone()
169    }
170
171    fn supports_resume(&self) -> bool {
172        self.record(MockCall::SupportsResume);
173        self.config.supports_resume
174    }
175
176    fn health_check(&self) -> BackendHealth {
177        self.record(MockCall::HealthCheck);
178        self.config.health
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    // --- MockBackend unit tests ---
187
188    #[test]
189    fn default_mock_returns_expected_name() {
190        let mock = MockBackend::default_mock();
191        assert_eq!(mock.name(), "mock-agent");
192    }
193
194    #[test]
195    fn mock_records_all_calls() {
196        let mock = MockBackend::default_mock();
197        let _ = mock.name();
198        let _ = mock.spawn_config("task", Path::new("/tmp"));
199        let _ = mock.format_input("y");
200        let _ = mock.health_check();
201
202        let calls = mock.calls();
203        assert_eq!(calls.len(), 4);
204        assert_eq!(calls[0], MockCall::Name);
205        assert!(matches!(calls[1], MockCall::SpawnConfig { .. }));
206        assert!(matches!(calls[2], MockCall::FormatInput { .. }));
207        assert_eq!(calls[3], MockCall::HealthCheck);
208    }
209
210    #[test]
211    fn mock_configurable_name() {
212        let mock = MockBackend::new(MockConfig {
213            name: "custom-bot".to_string(),
214            ..MockConfig::default()
215        });
216        assert_eq!(mock.name(), "custom-bot");
217    }
218
219    #[test]
220    fn mock_configurable_health() {
221        let mock = MockBackend::new(MockConfig {
222            health: BackendHealth::Degraded,
223            ..MockConfig::default()
224        });
225        assert_eq!(mock.health_check(), BackendHealth::Degraded);
226    }
227
228    #[test]
229    fn mock_configurable_launch_error() {
230        let mock = MockBackend::new(MockConfig {
231            launch_command_result: Err("API key expired".to_string()),
232            ..MockConfig::default()
233        });
234        let result = mock.launch_command("test", false, false, None);
235        assert!(result.is_err());
236        assert!(result.unwrap_err().to_string().contains("API key expired"));
237    }
238
239    #[test]
240    fn mock_call_log_shared_handle() {
241        let mock = MockBackend::default_mock();
242        let log = mock.call_log();
243        let _ = mock.name();
244        assert_eq!(log.lock().unwrap().len(), 1);
245    }
246
247    // --- Trait Contract Tests ---
248
249    #[test]
250    fn spawn_creates_agent() {
251        let mock = MockBackend::new(MockConfig {
252            name: "test-agent".to_string(),
253            ..MockConfig::default()
254        });
255        let config = mock.spawn_config("Fix the auth bug", Path::new("/project/worktree"));
256
257        assert_eq!(config.program, "test-agent");
258        assert_eq!(config.args, vec!["Fix the auth bug"]);
259        assert_eq!(config.work_dir, "/project/worktree");
260
261        // Verify the call was recorded with correct parameters
262        let calls = mock.calls();
263        assert_eq!(
264            calls[0],
265            MockCall::SpawnConfig {
266                task: "Fix the auth bug".to_string(),
267                work_dir: "/project/worktree".to_string(),
268            }
269        );
270    }
271
272    #[test]
273    fn send_message_delivers() {
274        // "Send message" maps to format_input — the mechanism for delivering
275        // text to an agent's stdin through the adapter.
276        let mock = MockBackend::new(MockConfig {
277            format_input_suffix: "\n".to_string(),
278            ..MockConfig::default()
279        });
280        let formatted = mock.format_input("deploy to staging");
281        assert_eq!(formatted, "deploy to staging\n");
282
283        let calls = mock.calls();
284        assert_eq!(
285            calls[0],
286            MockCall::FormatInput {
287                response: "deploy to staging".to_string(),
288            }
289        );
290    }
291
292    #[test]
293    fn detect_status_correct() {
294        // Status detection flows through health_check + prompt_patterns.
295        // Verify both paths return correct values.
296        let healthy_mock = MockBackend::new(MockConfig {
297            health: BackendHealth::Healthy,
298            ..MockConfig::default()
299        });
300        assert_eq!(healthy_mock.health_check(), BackendHealth::Healthy);
301        assert!(healthy_mock.health_check().is_healthy());
302
303        let degraded_mock = MockBackend::new(MockConfig {
304            health: BackendHealth::Degraded,
305            ..MockConfig::default()
306        });
307        assert_eq!(degraded_mock.health_check(), BackendHealth::Degraded);
308        assert!(!degraded_mock.health_check().is_healthy());
309
310        let unreachable_mock = MockBackend::new(MockConfig {
311            health: BackendHealth::Unreachable,
312            ..MockConfig::default()
313        });
314        assert_eq!(unreachable_mock.health_check(), BackendHealth::Unreachable);
315
316        // prompt_patterns should return a usable detector
317        let patterns = healthy_mock.prompt_patterns();
318        // It should not crash on normal text
319        assert!(patterns.detect("normal log output").is_none());
320    }
321
322    #[test]
323    fn restart_triggers_correctly() {
324        // Restart flow: launch_command with resume flag.
325        let mock = MockBackend::new(MockConfig {
326            supports_resume: true,
327            session_id: Some("sess-42".to_string()),
328            launch_command_result: Ok("exec mock-agent --resume sess-42".to_string()),
329            ..MockConfig::default()
330        });
331
332        // Relaunch with resume
333        assert!(mock.supports_resume());
334        let sid = mock.new_session_id().unwrap();
335        assert_eq!(sid, "sess-42");
336        let cmd = mock
337            .launch_command("resume prompt", false, true, Some(&sid))
338            .unwrap();
339        assert!(cmd.contains("resume"));
340
341        // Verify call sequence
342        let calls = mock.calls();
343        assert!(calls.contains(&MockCall::SupportsResume));
344        assert!(calls.contains(&MockCall::NewSessionId));
345    }
346
347    #[test]
348    fn get_output_returns_content() {
349        // Output capture flows through launch_command (which produces the
350        // agent process) and prompt_patterns (which detects completion).
351        let mock = MockBackend::new(MockConfig {
352            launch_command_result: Ok("exec mock-agent --run 'task prompt'".to_string()),
353            ..MockConfig::default()
354        });
355
356        let cmd = mock
357            .launch_command("task prompt", false, false, None)
358            .unwrap();
359        assert_eq!(cmd, "exec mock-agent --run 'task prompt'");
360
361        // Prompt patterns can detect completion signals
362        let patterns = mock.prompt_patterns();
363        let completion = patterns.detect(r#"{"type": "result", "subtype": "success"}"#);
364        assert!(completion.is_some());
365    }
366
367    #[test]
368    fn error_propagation() {
369        // Errors from launch_command should propagate through the trait.
370        let mock = MockBackend::new(MockConfig {
371            launch_command_result: Err("binary not found".to_string()),
372            ..MockConfig::default()
373        });
374
375        let result = mock.launch_command("test", false, false, None);
376        assert!(result.is_err());
377        let err = result.unwrap_err();
378        assert!(err.to_string().contains("binary not found"));
379
380        // Health can also signal errors
381        let unreachable = MockBackend::new(MockConfig {
382            health: BackendHealth::Unreachable,
383            ..MockConfig::default()
384        });
385        assert_eq!(unreachable.health_check(), BackendHealth::Unreachable);
386        assert!(!unreachable.health_check().is_healthy());
387
388        // Error patterns detected in output
389        let patterns = mock.prompt_patterns();
390        let err_detect = patterns.detect(r#"{"type": "result", "is_error": true}"#);
391        assert!(err_detect.is_some());
392    }
393
394    #[test]
395    fn trait_object_dispatch() {
396        // Verify Box<dyn AgentAdapter> works — the trait must be object-safe.
397        let mock: Box<dyn AgentAdapter> = Box::new(MockBackend::new(MockConfig {
398            name: "boxed-mock".to_string(),
399            ..MockConfig::default()
400        }));
401
402        assert_eq!(mock.name(), "boxed-mock");
403        let config = mock.spawn_config("task via dyn", Path::new("/dyn/path"));
404        assert_eq!(config.program, "boxed-mock");
405        assert_eq!(config.args, vec!["task via dyn"]);
406
407        let cmd = mock.launch_command("go", false, false, None).unwrap();
408        assert!(!cmd.is_empty());
409
410        assert_eq!(mock.health_check(), BackendHealth::Healthy);
411    }
412
413    #[test]
414    fn multiple_backends_coexist() {
415        // Two different mock backends in the same test, each independent.
416        let alpha = MockBackend::new(MockConfig {
417            name: "alpha".to_string(),
418            health: BackendHealth::Healthy,
419            launch_command_result: Ok("exec alpha --go".to_string()),
420            ..MockConfig::default()
421        });
422        let beta = MockBackend::new(MockConfig {
423            name: "beta".to_string(),
424            health: BackendHealth::Degraded,
425            launch_command_result: Ok("exec beta --go".to_string()),
426            ..MockConfig::default()
427        });
428
429        // Each has independent name and health
430        assert_eq!(alpha.name(), "alpha");
431        assert_eq!(beta.name(), "beta");
432        assert_eq!(alpha.health_check(), BackendHealth::Healthy);
433        assert_eq!(beta.health_check(), BackendHealth::Degraded);
434
435        // Each has independent call logs
436        let _ = alpha.spawn_config("task-a", Path::new("/a"));
437        assert_eq!(alpha.calls().len(), 3); // name + health + spawn
438        assert_eq!(beta.calls().len(), 2); // name + health only
439
440        // Both work as trait objects simultaneously
441        let backends: Vec<Box<dyn AgentAdapter>> = vec![
442            Box::new(MockBackend::new(MockConfig {
443                name: "dyn-1".to_string(),
444                ..MockConfig::default()
445            })),
446            Box::new(MockBackend::new(MockConfig {
447                name: "dyn-2".to_string(),
448                ..MockConfig::default()
449            })),
450        ];
451        let names: Vec<&str> = backends.iter().map(|b| b.name()).collect();
452        assert_eq!(names, vec!["dyn-1", "dyn-2"]);
453    }
454
455    // --- Consumer Tests ---
456
457    #[test]
458    fn consumer_adapter_from_name_returns_usable_trait_object() {
459        // Verify that adapter_from_name returns a Box<dyn AgentAdapter> that
460        // can be used by consumers without knowing the concrete type.
461        use crate::agent::adapter_from_name;
462
463        for name in &["claude", "codex", "kiro"] {
464            let adapter = adapter_from_name(name).expect("should resolve");
465            // Consumer can call all trait methods through the box
466            assert!(!adapter.name().is_empty());
467            let config = adapter.spawn_config("consumer test", Path::new("/consumer"));
468            assert!(!config.program.is_empty());
469            let cmd = adapter.launch_command("go", false, false, None);
470            assert!(cmd.is_ok());
471            let _ = adapter.health_check();
472            let _ = adapter.prompt_patterns();
473            let _ = adapter.format_input("yes");
474            let _ = adapter.instruction_candidates();
475            let _ = adapter.wrap_launch_prompt("prompt");
476            let _ = adapter.new_session_id();
477            let _ = adapter.supports_resume();
478        }
479    }
480
481    #[test]
482    fn consumer_health_check_by_name_dispatches_correctly() {
483        use crate::agent::health_check_by_name;
484
485        // Known backends return Some health status
486        let health = health_check_by_name("claude");
487        assert!(health.is_some());
488
489        let health = health_check_by_name("codex");
490        assert!(health.is_some());
491
492        let health = health_check_by_name("kiro");
493        assert!(health.is_some());
494
495        // Unknown returns None
496        assert!(health_check_by_name("nonexistent").is_none());
497    }
498
499    #[test]
500    fn consumer_heterogeneous_backend_vec() {
501        // Simulate what daemon code does: hold multiple backends in a Vec
502        // and iterate over them for status checks.
503        use crate::agent::{claude, codex, kiro};
504
505        let backends: Vec<Box<dyn AgentAdapter>> = vec![
506            Box::new(claude::ClaudeCodeAdapter::new(None)),
507            Box::new(codex::CodexCliAdapter::new(None)),
508            Box::new(kiro::KiroCliAdapter::new(None)),
509            Box::new(MockBackend::default_mock()),
510        ];
511
512        // All backends respond to the same trait API
513        for backend in &backends {
514            let name = backend.name();
515            assert!(!name.is_empty());
516
517            let config = backend.spawn_config("health check", Path::new("/tmp"));
518            assert_eq!(config.work_dir, "/tmp");
519
520            let cmd = backend.launch_command("ping", true, false, None);
521            assert!(cmd.is_ok());
522        }
523
524        // Can collect names
525        let names: Vec<&str> = backends.iter().map(|b| b.name()).collect();
526        assert_eq!(names.len(), 4);
527        assert!(names.contains(&"claude-code"));
528        assert!(names.contains(&"codex-cli"));
529        assert!(names.contains(&"kiro-cli"));
530        assert!(names.contains(&"mock-agent"));
531    }
532
533    #[test]
534    fn consumer_backend_selection_pattern() {
535        // Pattern used by daemon: select backend by name, configure, use.
536        use crate::agent::adapter_from_name;
537
538        let agent_name = "claude";
539        let adapter = adapter_from_name(agent_name).unwrap();
540
541        // Consumer builds launch command
542        let cmd = adapter
543            .launch_command("implement feature X", false, false, None)
544            .unwrap();
545        assert!(cmd.contains("claude"));
546
547        // Consumer checks health before dispatching
548        let health = adapter.health_check();
549        // (We don't assert specific health since the binary may or may not exist,
550        // but the call should not panic.)
551        let _ = health.is_healthy();
552        let _ = health.as_str();
553    }
554}