1use std::path::Path;
7use std::sync::{Arc, Mutex};
8
9use crate::agent::{AgentAdapter, BackendHealth, SpawnConfig};
10use crate::prompt::PromptPatterns;
11
12#[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#[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
65pub 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 pub fn default_mock() -> Self {
81 Self::new(MockConfig::default())
82 }
83
84 pub fn call_log(&self) -> Arc<Mutex<Vec<MockCall>>> {
86 Arc::clone(&self.calls)
87 }
88
89 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 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 #[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 #[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 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 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 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 let patterns = healthy_mock.prompt_patterns();
318 assert!(patterns.detect("normal log output").is_none());
320 }
321
322 #[test]
323 fn restart_triggers_correctly() {
324 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 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 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 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 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 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 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 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 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 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 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 let _ = alpha.spawn_config("task-a", Path::new("/a"));
437 assert_eq!(alpha.calls().len(), 3); assert_eq!(beta.calls().len(), 2); 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 #[test]
458 fn consumer_adapter_from_name_returns_usable_trait_object() {
459 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 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 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 assert!(health_check_by_name("nonexistent").is_none());
497 }
498
499 #[test]
500 fn consumer_heterogeneous_backend_vec() {
501 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 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 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 use crate::agent::adapter_from_name;
537
538 let agent_name = "claude";
539 let adapter = adapter_from_name(agent_name).unwrap();
540
541 let cmd = adapter
543 .launch_command("implement feature X", false, false, None)
544 .unwrap();
545 assert!(cmd.contains("claude"));
546
547 let health = adapter.health_check();
549 let _ = health.is_healthy();
552 let _ = health.as_str();
553 }
554}