Skip to main content

magi_core/
orchestrator.rs

1// Author: Julian Bolivar
2// Version: 1.0.0
3// Date: 2026-04-05
4
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::agent::{Agent, AgentFactory};
11use crate::consensus::{ConsensusConfig, ConsensusEngine};
12use crate::error::{MagiError, ProviderError};
13use crate::provider::{CompletionConfig, LlmProvider};
14use crate::reporting::{MagiReport, ReportConfig, ReportFormatter};
15use crate::schema::{AgentName, AgentOutput, Mode};
16use crate::validate::{ValidationLimits, Validator};
17use tokio::task::AbortHandle;
18
19/// Configuration for the MAGI orchestrator.
20///
21/// Controls timeout per agent, maximum input size, and LLM completion parameters.
22#[non_exhaustive]
23#[derive(Debug, Clone)]
24pub struct MagiConfig {
25    /// Maximum time to wait for each agent (default: 300 seconds).
26    pub timeout: Duration,
27    /// Maximum content size in bytes (default: 1_048_576 = 1MB).
28    pub max_input_len: usize,
29    /// Completion parameters forwarded to each agent.
30    pub completion: CompletionConfig,
31}
32
33impl Default for MagiConfig {
34    fn default() -> Self {
35        Self {
36            timeout: Duration::from_secs(300),
37            max_input_len: 1_048_576,
38            completion: CompletionConfig::default(),
39        }
40    }
41}
42
43/// Consuming builder for constructing [`Magi`] instances.
44///
45/// The only required field is `default_provider`, passed to the constructor.
46/// All other fields have sensible defaults.
47///
48/// # Examples
49///
50/// ```no_run
51/// # use std::sync::Arc;
52/// # use std::time::Duration;
53/// # use magi_core::orchestrator::MagiBuilder;
54/// # use magi_core::schema::AgentName;
55/// // let magi = MagiBuilder::new(provider)
56/// //     .with_provider(AgentName::Caspar, caspar_provider)
57/// //     .with_timeout(Duration::from_secs(60))
58/// //     .build()
59/// //     .expect("build");
60/// ```
61pub struct MagiBuilder {
62    default_provider: Arc<dyn LlmProvider>,
63    agent_providers: BTreeMap<AgentName, Arc<dyn LlmProvider>>,
64    custom_prompts: BTreeMap<AgentName, String>,
65    prompts_dir: Option<PathBuf>,
66    config: MagiConfig,
67    validation_limits: ValidationLimits,
68    consensus_config: ConsensusConfig,
69    report_config: ReportConfig,
70}
71
72impl MagiBuilder {
73    /// Creates a new builder with the given default provider.
74    ///
75    /// # Parameters
76    /// - `default_provider`: The LLM provider shared by all agents unless overridden.
77    pub fn new(default_provider: Arc<dyn LlmProvider>) -> Self {
78        Self {
79            default_provider,
80            agent_providers: BTreeMap::new(),
81            custom_prompts: BTreeMap::new(),
82            prompts_dir: None,
83            config: MagiConfig::default(),
84            validation_limits: ValidationLimits::default(),
85            consensus_config: ConsensusConfig::default(),
86            report_config: ReportConfig::default(),
87        }
88    }
89
90    /// Sets a per-agent provider override.
91    ///
92    /// # Parameters
93    /// - `name`: Which agent to override.
94    /// - `provider`: The provider for that agent.
95    pub fn with_provider(mut self, name: AgentName, provider: Arc<dyn LlmProvider>) -> Self {
96        self.agent_providers.insert(name, provider);
97        self
98    }
99
100    /// Sets a custom system prompt for a specific agent.
101    ///
102    /// # Parameters
103    /// - `name`: Which agent to override.
104    /// - `prompt`: The custom system prompt.
105    pub fn with_custom_prompt(mut self, name: AgentName, prompt: String) -> Self {
106        self.custom_prompts.insert(name, prompt);
107        self
108    }
109
110    /// Sets a directory from which to load custom prompt files.
111    ///
112    /// # Parameters
113    /// - `dir`: Path to the prompts directory.
114    pub fn with_prompts_dir(mut self, dir: PathBuf) -> Self {
115        self.prompts_dir = Some(dir);
116        self
117    }
118
119    /// Sets the per-agent timeout.
120    ///
121    /// # Parameters
122    /// - `timeout`: Maximum wait time per agent.
123    pub fn with_timeout(mut self, timeout: Duration) -> Self {
124        self.config.timeout = timeout;
125        self
126    }
127
128    /// Sets the maximum input content length in bytes.
129    ///
130    /// # Parameters
131    /// - `max`: Maximum content size.
132    pub fn with_max_input_len(mut self, max: usize) -> Self {
133        self.config.max_input_len = max;
134        self
135    }
136
137    /// Sets the completion configuration forwarded to agents.
138    ///
139    /// # Parameters
140    /// - `config`: Completion parameters (max_tokens, temperature).
141    pub fn with_completion_config(mut self, config: CompletionConfig) -> Self {
142        self.config.completion = config;
143        self
144    }
145
146    /// Sets custom validation limits.
147    ///
148    /// # Parameters
149    /// - `limits`: Validation thresholds for agent outputs.
150    pub fn with_validation_limits(mut self, limits: ValidationLimits) -> Self {
151        self.validation_limits = limits;
152        self
153    }
154
155    /// Sets custom consensus engine configuration.
156    ///
157    /// # Parameters
158    /// - `config`: Consensus parameters (min_agents, epsilon).
159    pub fn with_consensus_config(mut self, config: ConsensusConfig) -> Self {
160        self.consensus_config = config;
161        self
162    }
163
164    /// Sets custom report formatter configuration.
165    ///
166    /// # Parameters
167    /// - `config`: Report parameters (banner_width, agent_titles).
168    pub fn with_report_config(mut self, config: ReportConfig) -> Self {
169        self.report_config = config;
170        self
171    }
172
173    /// Builds the [`Magi`] orchestrator from accumulated configuration.
174    ///
175    /// Loads prompts from `prompts_dir` if set (may fail with `MagiError::Io`).
176    ///
177    /// # Errors
178    /// Returns `MagiError::Io` if `prompts_dir` is set and cannot be read.
179    pub fn build(self) -> Result<Magi, MagiError> {
180        let mut factory = AgentFactory::new(self.default_provider);
181        for (name, provider) in self.agent_providers {
182            factory = factory.with_provider(name, provider);
183        }
184        for (name, prompt) in self.custom_prompts {
185            factory = factory.with_custom_prompt(name, prompt);
186        }
187        if let Some(dir) = self.prompts_dir {
188            factory = factory.from_directory(&dir)?;
189        }
190
191        Ok(Magi {
192            config: self.config,
193            agent_factory: factory,
194            validator: Validator::with_limits(self.validation_limits),
195            consensus_engine: ConsensusEngine::new(self.consensus_config),
196            formatter: ReportFormatter::with_config(self.report_config),
197        })
198    }
199}
200
201/// RAII guard that aborts spawned tasks when dropped.
202///
203/// Ensures that if [`Magi::analyze`] is cancelled (e.g., the caller wraps it
204/// in `tokio::time::timeout`), all in-flight agent tasks are aborted instead
205/// of continuing to run in the background and consuming LLM API quota.
206struct AbortGuard(Vec<AbortHandle>);
207
208impl Drop for AbortGuard {
209    fn drop(&mut self) {
210        for handle in &self.0 {
211            handle.abort();
212        }
213    }
214}
215
216/// Main entry point for the MAGI multi-perspective analysis system.
217///
218/// Composes agents, validation, consensus, and reporting into a single
219/// orchestration flow. The [`analyze`](Magi::analyze) method launches three
220/// agents in parallel, parses and validates their responses, computes consensus,
221/// and generates a formatted report.
222///
223/// # Examples
224///
225/// ```no_run
226/// # use std::sync::Arc;
227/// # use magi_core::orchestrator::Magi;
228/// # use magi_core::schema::Mode;
229/// // let magi = Magi::new(provider);
230/// // let report = magi.analyze(&Mode::CodeReview, content).await?;
231/// ```
232pub struct Magi {
233    config: MagiConfig,
234    agent_factory: AgentFactory,
235    validator: Validator,
236    consensus_engine: ConsensusEngine,
237    formatter: ReportFormatter,
238}
239
240impl Magi {
241    /// Creates a MAGI orchestrator with a single provider and all defaults.
242    ///
243    /// Equivalent to `MagiBuilder::new(provider).build().unwrap()`.
244    /// This cannot fail because all defaults are valid.
245    ///
246    /// # Parameters
247    /// - `provider`: The LLM provider shared by all three agents.
248    pub fn new(provider: Arc<dyn LlmProvider>) -> Self {
249        // Safe to unwrap: no prompts_dir means no I/O, so build cannot fail.
250        MagiBuilder::new(provider).build().expect(
251            "Magi::new uses all defaults and cannot fail; \
252             this is an internal invariant violation",
253        )
254    }
255
256    /// Returns a builder for configuring a MAGI orchestrator.
257    ///
258    /// # Parameters
259    /// - `provider`: The default LLM provider.
260    pub fn builder(provider: Arc<dyn LlmProvider>) -> MagiBuilder {
261        MagiBuilder::new(provider)
262    }
263
264    /// Runs a full multi-perspective analysis.
265    ///
266    /// Launches three agents in parallel, parses their JSON responses,
267    /// validates outputs, computes consensus, and generates a formatted report.
268    ///
269    /// # Parameters
270    /// - `mode`: The analysis mode (CodeReview, Design, Analysis).
271    /// - `content`: The content to analyze.
272    ///
273    /// # Errors
274    /// - [`MagiError::InputTooLarge`] if `content.len()` exceeds `max_input_len`.
275    /// - [`MagiError::InsufficientAgents`] if fewer than 2 agents succeed.
276    pub async fn analyze(&self, mode: &Mode, content: &str) -> Result<MagiReport, MagiError> {
277        // 1. Input validation
278        if content.len() > self.config.max_input_len {
279            return Err(MagiError::InputTooLarge {
280                size: content.len(),
281                max: self.config.max_input_len,
282            });
283        }
284
285        // 2. Create agents
286        let agents = self.agent_factory.create_agents(*mode);
287
288        // 3. Build user prompt
289        let prompt = build_prompt(mode, content);
290
291        // 4. Launch agents in parallel and collect results
292        let agent_results = self.launch_agents(agents, &prompt).await;
293
294        // 5. Process results: parse, validate, separate successes/failures
295        let (successful, failed_agents) = self.process_results(agent_results)?;
296
297        // 6. Consensus
298        let consensus = self.consensus_engine.determine(&successful)?;
299
300        // 7. Report
301        let banner = self.formatter.format_banner(&successful, &consensus);
302        let report = self.formatter.format_report(&successful, &consensus);
303
304        // 8. Build MagiReport
305        let degraded = successful.len() < 3;
306        Ok(MagiReport {
307            agents: successful,
308            consensus,
309            banner,
310            report,
311            degraded,
312            failed_agents,
313        })
314    }
315
316    /// Launches all agents in parallel using individual `tokio::spawn` tasks.
317    ///
318    /// Each agent task is wrapped in `tokio::time::timeout`. Agent names are
319    /// tracked alongside their `JoinHandle`s so that panicked tasks are correctly
320    /// attributed to the right agent (unlike `JoinSet`, which loses task identity
321    /// on panic).
322    ///
323    /// An [`AbortGuard`] holds abort handles for all spawned tasks. If this
324    /// future is dropped (e.g., the caller times out), the guard aborts every
325    /// running task, preventing wasted LLM API quota.
326    async fn launch_agents(
327        &self,
328        agents: Vec<Agent>,
329        prompt: &str,
330    ) -> Vec<(AgentName, Result<String, MagiError>)> {
331        let timeout = self.config.timeout;
332        let completion = self.config.completion.clone();
333        let mut handles = Vec::new();
334        let mut abort_handles = Vec::new();
335
336        for agent in agents {
337            let name = agent.name();
338            let user_prompt = prompt.to_string();
339            let config = completion.clone();
340
341            let handle = tokio::spawn(async move {
342                let result =
343                    tokio::time::timeout(timeout, agent.execute(&user_prompt, &config)).await;
344                match result {
345                    Ok(Ok(response)) => Ok(response),
346                    Ok(Err(provider_err)) => Err(MagiError::Provider(provider_err)),
347                    Err(_elapsed) => Err(MagiError::Provider(ProviderError::Timeout {
348                        message: format!("agent timed out after {timeout:?}"),
349                    })),
350                }
351            });
352            abort_handles.push(handle.abort_handle());
353            handles.push((name, handle));
354        }
355
356        // Guard aborts all tasks if this future is cancelled before completion.
357        // Once all handles are awaited below, abort() on a finished task is a no-op.
358        let _guard = AbortGuard(abort_handles);
359
360        let mut results = Vec::new();
361        for (name, handle) in handles {
362            match handle.await {
363                Ok(result) => results.push((name, result)),
364                Err(join_err) => results.push((
365                    name,
366                    Err(MagiError::Provider(ProviderError::Process {
367                        exit_code: None,
368                        stderr: format!("agent task panicked: {join_err}"),
369                    })),
370                )),
371            }
372        }
373
374        results
375    }
376
377    /// Separates successful agent outputs from failures.
378    ///
379    /// Parses and validates each raw response, preserving failure reasons
380    /// for diagnostic visibility. Returns an error if fewer than 2 agents
381    /// succeeded.
382    fn process_results(
383        &self,
384        results: Vec<(AgentName, Result<String, MagiError>)>,
385    ) -> Result<(Vec<AgentOutput>, BTreeMap<AgentName, String>), MagiError> {
386        let mut successful = Vec::new();
387        let mut failed_agents = BTreeMap::new();
388
389        for (name, result) in results {
390            match result {
391                Ok(raw) => match parse_agent_response(&raw) {
392                    Ok(output) => match self.validator.validate(&output) {
393                        Ok(()) => successful.push(output),
394                        Err(e) => {
395                            failed_agents.insert(name, format!("validation: {e}"));
396                        }
397                    },
398                    Err(e) => {
399                        failed_agents.insert(name, format!("parse: {e}"));
400                    }
401                },
402                Err(e) => {
403                    failed_agents.insert(name, e.to_string());
404                }
405            }
406        }
407
408        let min_agents = self.consensus_engine.min_agents();
409        if successful.len() < min_agents {
410            return Err(MagiError::InsufficientAgents {
411                succeeded: successful.len(),
412                required: min_agents,
413            });
414        }
415
416        Ok((successful, failed_agents))
417    }
418}
419
420/// Formats the user prompt sent to each agent.
421///
422/// # Parameters
423/// - `mode`: The analysis mode.
424/// - `content`: The content to analyze.
425fn build_prompt(mode: &Mode, content: &str) -> String {
426    format!("MODE: {mode}\nCONTEXT:\n{content}")
427}
428
429/// Extracts an [`AgentOutput`] from raw LLM response text.
430///
431/// Handles common LLM output quirks:
432/// 1. Strips code fences (` ```json ` and ` ``` `).
433/// 2. Tries to parse JSON from each `{` position until one succeeds.
434/// 3. Deserializes via serde (unknown fields are ignored).
435///
436/// This approach is resilient to LLM responses that contain stray `{}`
437/// in prose before the actual JSON payload.
438///
439/// # Errors
440/// Returns `MagiError::Deserialization` if no valid JSON object is found.
441fn parse_agent_response(raw: &str) -> Result<AgentOutput, MagiError> {
442    let trimmed = raw.trim();
443
444    // Strip code fences
445    let stripped = if trimmed.starts_with("```") {
446        let without_opening = if let Some(rest) = trimmed.strip_prefix("```json") {
447            rest
448        } else {
449            trimmed.strip_prefix("```").unwrap_or(trimmed)
450        };
451        without_opening
452            .strip_suffix("```")
453            .unwrap_or(without_opening)
454            .trim()
455    } else {
456        trimmed
457    };
458
459    // Fast path: try parsing the entire string as a single JSON object.
460    // This handles the common case where the LLM returns only JSON.
461    if let Ok(output) = serde_json::from_str::<AgentOutput>(stripped) {
462        return Ok(output);
463    }
464
465    // Fallback: search forward through each '{' position for the first valid
466    // AgentOutput JSON. Forward search is preferred because it finds the first
467    // complete object, avoiding false matches from trailing prose that might
468    // coincidentally contain valid JSON.
469    for (start, _) in stripped.match_indices('{') {
470        let candidate = &stripped[start..];
471        if let Ok(output) = serde_json::from_str::<AgentOutput>(candidate) {
472            return Ok(output);
473        }
474    }
475
476    Err(MagiError::Deserialization(
477        "no valid JSON object found in agent response".to_string(),
478    ))
479}
480
481#[cfg(test)]
482mod tests {
483    use super::*;
484    use crate::schema::*;
485    use std::sync::Arc;
486    use std::sync::atomic::{AtomicUsize, Ordering};
487    use std::time::Duration;
488
489    /// Helper: build a valid AgentOutput JSON string for a given agent name and verdict.
490    fn mock_agent_json(agent: &str, verdict: &str, confidence: f64) -> String {
491        format!(
492            r#"{{
493                "agent": "{agent}",
494                "verdict": "{verdict}",
495                "confidence": {confidence},
496                "summary": "Summary from {agent}",
497                "reasoning": "Reasoning from {agent}",
498                "findings": [],
499                "recommendation": "Recommendation from {agent}"
500            }}"#
501        )
502    }
503
504    /// Mock provider that returns a configurable response per call.
505    /// Uses a call counter to track invocations and can return different
506    /// responses for each agent by cycling through the responses vec.
507    struct MockProvider {
508        name: String,
509        model: String,
510        responses: Vec<Result<String, ProviderError>>,
511        call_count: AtomicUsize,
512    }
513
514    impl MockProvider {
515        fn success(name: &str, model: &str, responses: Vec<String>) -> Self {
516            Self {
517                name: name.to_string(),
518                model: model.to_string(),
519                responses: responses.into_iter().map(Ok).collect(),
520                call_count: AtomicUsize::new(0),
521            }
522        }
523
524        fn mixed(name: &str, model: &str, responses: Vec<Result<String, ProviderError>>) -> Self {
525            Self {
526                name: name.to_string(),
527                model: model.to_string(),
528                responses,
529                call_count: AtomicUsize::new(0),
530            }
531        }
532
533        fn calls(&self) -> usize {
534            self.call_count.load(Ordering::SeqCst)
535        }
536    }
537
538    #[async_trait::async_trait]
539    impl LlmProvider for MockProvider {
540        async fn complete(
541            &self,
542            _system_prompt: &str,
543            _user_prompt: &str,
544            _config: &CompletionConfig,
545        ) -> Result<String, ProviderError> {
546            let idx = self.call_count.fetch_add(1, Ordering::SeqCst);
547            let idx = idx % self.responses.len();
548            self.responses[idx].clone()
549        }
550
551        fn name(&self) -> &str {
552            &self.name
553        }
554
555        fn model(&self) -> &str {
556            &self.model
557        }
558    }
559
560    // -- BDD Scenario 1: successful analysis with 3 unanimous agents --
561
562    /// analyze returns MagiReport with 3 outputs, consensus, banner, report, degraded=false.
563    #[tokio::test]
564    async fn test_analyze_unanimous_approve_returns_complete_report() {
565        let responses = vec![
566            mock_agent_json("melchior", "approve", 0.9),
567            mock_agent_json("balthasar", "approve", 0.85),
568            mock_agent_json("caspar", "approve", 0.95),
569        ];
570        let provider = Arc::new(MockProvider::success("mock", "test-model", responses));
571        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
572
573        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
574        let report = result.expect("analyze should succeed");
575
576        assert_eq!(report.agents.len(), 3);
577        assert!(!report.degraded);
578        assert!(report.failed_agents.is_empty());
579        assert_eq!(report.consensus.consensus_verdict, Verdict::Approve);
580        assert!(!report.banner.is_empty());
581        assert!(!report.report.is_empty());
582    }
583
584    // -- BDD Scenario 6: degradation - 1 agent timeout --
585
586    /// 2 succeed + 1 timeout produces Ok(MagiReport), degraded=true, failed_agents contains agent.
587    #[tokio::test]
588    async fn test_analyze_one_agent_timeout_degrades_gracefully() {
589        let responses = vec![
590            Ok(mock_agent_json("melchior", "approve", 0.9)),
591            Ok(mock_agent_json("balthasar", "approve", 0.85)),
592            Err(ProviderError::Timeout {
593                message: "exceeded timeout".to_string(),
594            }),
595        ];
596        let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
597        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
598
599        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
600        let report = result.expect("analyze should succeed with degradation");
601
602        assert!(report.degraded);
603        assert_eq!(report.failed_agents.len(), 1);
604        assert_eq!(report.agents.len(), 2);
605    }
606
607    // -- BDD Scenario 7: degradation - 1 agent invalid JSON --
608
609    /// 2 succeed + 1 bad JSON produces Ok(MagiReport), degraded=true.
610    #[tokio::test]
611    async fn test_analyze_one_agent_bad_json_degrades_gracefully() {
612        let responses = vec![
613            Ok(mock_agent_json("melchior", "approve", 0.9)),
614            Ok(mock_agent_json("balthasar", "approve", 0.85)),
615            Ok("not valid json at all".to_string()),
616        ];
617        let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
618        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
619
620        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
621        let report = result.expect("analyze should succeed with degradation");
622
623        assert!(report.degraded);
624    }
625
626    // -- BDD Scenario 8: 2 agents fail --
627
628    /// 1 succeed + 2 fail returns Err(InsufficientAgents { succeeded: 1, required: 2 }).
629    #[tokio::test]
630    async fn test_analyze_two_agents_fail_returns_insufficient_agents() {
631        let responses = vec![
632            Ok(mock_agent_json("melchior", "approve", 0.9)),
633            Err(ProviderError::Timeout {
634                message: "timeout".to_string(),
635            }),
636            Err(ProviderError::Network {
637                message: "connection refused".to_string(),
638            }),
639        ];
640        let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
641        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
642
643        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
644
645        match result {
646            Err(MagiError::InsufficientAgents {
647                succeeded,
648                required,
649            }) => {
650                assert_eq!(succeeded, 1);
651                assert_eq!(required, 2);
652            }
653            other => panic!("Expected InsufficientAgents, got: {other:?}"),
654        }
655    }
656
657    // -- BDD Scenario 9: all agents fail --
658
659    /// 0 succeed returns Err(InsufficientAgents { succeeded: 0, required: 2 }).
660    #[tokio::test]
661    async fn test_analyze_all_agents_fail_returns_insufficient_agents() {
662        let responses = vec![
663            Err(ProviderError::Timeout {
664                message: "timeout".to_string(),
665            }),
666            Err(ProviderError::Network {
667                message: "network".to_string(),
668            }),
669            Err(ProviderError::Auth {
670                message: "auth".to_string(),
671            }),
672        ];
673        let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
674        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
675
676        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
677
678        match result {
679            Err(MagiError::InsufficientAgents {
680                succeeded,
681                required,
682            }) => {
683                assert_eq!(succeeded, 0);
684                assert_eq!(required, 2);
685            }
686            other => panic!("Expected InsufficientAgents, got: {other:?}"),
687        }
688    }
689
690    // -- BDD Scenario 14: LLM returns non-JSON --
691
692    /// Agent returns plain text, treated as failed, system continues with remaining.
693    #[tokio::test]
694    async fn test_analyze_plain_text_response_treated_as_failure() {
695        let responses = vec![
696            Ok(mock_agent_json("melchior", "approve", 0.9)),
697            Ok(mock_agent_json("balthasar", "approve", 0.85)),
698            Ok("I think the code is good".to_string()),
699        ];
700        let provider = Arc::new(MockProvider::mixed("mock", "test-model", responses));
701        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
702
703        let result = magi.analyze(&Mode::CodeReview, "fn main() {}").await;
704        let report = result.expect("should succeed with degradation");
705
706        assert!(report.degraded);
707        assert_eq!(report.agents.len(), 2);
708    }
709
710    // -- BDD Scenario 28: Magi::new with single provider --
711
712    /// new creates Magi with 3 agents sharing same provider, all defaults.
713    #[tokio::test]
714    async fn test_magi_new_creates_with_defaults() {
715        let responses = vec![
716            mock_agent_json("melchior", "approve", 0.9),
717            mock_agent_json("balthasar", "approve", 0.85),
718            mock_agent_json("caspar", "approve", 0.95),
719        ];
720        let provider = Arc::new(MockProvider::success(
721            "test-provider",
722            "test-model",
723            responses,
724        ));
725        let magi = Magi::new(provider as Arc<dyn LlmProvider>);
726
727        let result = magi.analyze(&Mode::CodeReview, "test content").await;
728        let report = result.expect("should succeed");
729
730        // All 3 agents used the same provider
731        assert_eq!(report.agents.len(), 3);
732    }
733
734    // -- BDD Scenario 29: builder with mixed providers and custom config --
735
736    /// Builder sets per-agent providers and custom timeout.
737    #[tokio::test]
738    async fn test_builder_with_mixed_providers_and_custom_config() {
739        let default_responses = vec![
740            mock_agent_json("melchior", "approve", 0.9),
741            mock_agent_json("balthasar", "approve", 0.85),
742        ];
743        let caspar_responses = vec![mock_agent_json("caspar", "reject", 0.8)];
744
745        let default_provider = Arc::new(MockProvider::success(
746            "default-provider",
747            "model-a",
748            default_responses,
749        ));
750        let caspar_provider = Arc::new(MockProvider::success(
751            "caspar-provider",
752            "model-b",
753            caspar_responses,
754        ));
755
756        let magi = MagiBuilder::new(default_provider.clone() as Arc<dyn LlmProvider>)
757            .with_provider(
758                AgentName::Caspar,
759                caspar_provider.clone() as Arc<dyn LlmProvider>,
760            )
761            .with_timeout(Duration::from_secs(60))
762            .build()
763            .expect("build should succeed");
764
765        let result = magi.analyze(&Mode::CodeReview, "test content").await;
766        let report = result.expect("should succeed");
767
768        assert_eq!(report.agents.len(), 3);
769        // Caspar used the override provider
770        assert!(caspar_provider.calls() > 0);
771    }
772
773    // -- BDD Scenario 32: input too large --
774
775    /// Content exceeding max_input_len returns Err(InputTooLarge) without launching agents.
776    #[tokio::test]
777    async fn test_analyze_input_too_large_rejects_without_launching_agents() {
778        let responses = vec![mock_agent_json("melchior", "approve", 0.9)];
779        let provider = Arc::new(MockProvider::success("mock", "test-model", responses));
780
781        let magi = MagiBuilder::new(provider.clone() as Arc<dyn LlmProvider>)
782            .with_max_input_len(100)
783            .build()
784            .expect("build should succeed");
785
786        let content = "x".repeat(200);
787        let result = magi.analyze(&Mode::CodeReview, &content).await;
788
789        match result {
790            Err(MagiError::InputTooLarge { size, max }) => {
791                assert_eq!(size, 200);
792                assert_eq!(max, 100);
793            }
794            other => panic!("Expected InputTooLarge, got: {other:?}"),
795        }
796
797        // Provider should NOT have been called
798        assert_eq!(provider.calls(), 0, "No agents should have been launched");
799    }
800
801    // -- MagiConfig defaults --
802
803    /// MagiConfig::default has timeout=300s, max_input_len=1MB.
804    #[test]
805    fn test_magi_config_default_values() {
806        let config = MagiConfig::default();
807        assert_eq!(config.timeout, Duration::from_secs(300));
808        assert_eq!(config.max_input_len, 1_048_576);
809    }
810
811    // -- build_prompt formatting --
812
813    /// build_prompt formats "MODE: {mode}\nCONTEXT:\n{content}".
814    #[test]
815    fn test_build_prompt_formats_mode_and_content() {
816        let result = build_prompt(&Mode::CodeReview, "fn main() {}");
817        assert_eq!(result, "MODE: code-review\nCONTEXT:\nfn main() {}");
818    }
819
820    // -- parse_agent_response --
821
822    /// parse_agent_response strips code fences from JSON.
823    #[test]
824    fn test_parse_agent_response_strips_code_fences() {
825        let json = mock_agent_json("melchior", "approve", 0.9);
826        let raw = format!("```json\n{json}\n```");
827
828        let result = parse_agent_response(&raw);
829        let output = result.expect("should parse successfully");
830        assert_eq!(output.agent, AgentName::Melchior);
831        assert_eq!(output.verdict, Verdict::Approve);
832    }
833
834    /// parse_agent_response finds JSON object in preamble text.
835    #[test]
836    fn test_parse_agent_response_extracts_json_from_preamble() {
837        let json = mock_agent_json("melchior", "approve", 0.9);
838        let raw = format!("Here is my analysis:\n{json}");
839
840        let result = parse_agent_response(&raw);
841        assert!(result.is_ok(), "should find JSON in preamble text");
842    }
843
844    /// parse_agent_response fails on completely invalid input.
845    #[test]
846    fn test_parse_agent_response_fails_on_invalid_input() {
847        let result = parse_agent_response("no json here");
848        assert!(result.is_err(), "should fail on invalid input");
849    }
850
851    // -- MagiBuilder --
852
853    /// MagiBuilder::build returns Ok(Magi) with required provider.
854    #[test]
855    fn test_magi_builder_build_returns_result() {
856        let responses = vec![mock_agent_json("melchior", "approve", 0.9)];
857        let provider =
858            Arc::new(MockProvider::success("mock", "model", responses)) as Arc<dyn LlmProvider>;
859
860        let magi = MagiBuilder::new(provider).build();
861        assert!(magi.is_ok());
862    }
863}