Skip to main content

ralph_workflow/agents/
fallback.rs

1//! Fallback chain configuration for agent fault tolerance.
2//!
3//! This module defines the `FallbackConfig` structure that controls how Ralph
4//! handles agent failures. It supports:
5//! - Agent-level fallback (try different agents)
6//! - Provider-level fallback (try different models within same agent)
7//! - Exponential backoff with cycling
8
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12/// Agent role (developer, reviewer, or commit).
13///
14/// Each role can have its own chain of fallback agents.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum AgentRole {
17    /// Developer agent: implements features based on PROMPT.md.
18    Developer,
19    /// Reviewer agent: reviews code and fixes issues.
20    Reviewer,
21    /// Commit agent: generates commit messages from diffs.
22    Commit,
23    /// Analysis agent: independently verifies progress (diff vs plan).
24    Analysis,
25}
26
27/// Runtime consumer of an agent chain.
28///
29/// Drains represent distinct runtime contexts that consume a concrete chain,
30/// even when multiple drains share the same underlying ordered agent list.
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub enum AgentDrain {
33    Planning,
34    Development,
35    Review,
36    Fix,
37    Commit,
38    Analysis,
39}
40
41impl AgentDrain {
42    /// Return the broad capability role associated with this drain.
43    #[must_use]
44    pub const fn role(self) -> AgentRole {
45        match self {
46            Self::Planning | Self::Development => AgentRole::Developer,
47            Self::Review | Self::Fix => AgentRole::Reviewer,
48            Self::Commit => AgentRole::Commit,
49            Self::Analysis => AgentRole::Analysis,
50        }
51    }
52
53    /// Return the built-in drain key used by TOML and diagnostics.
54    #[must_use]
55    pub const fn as_str(self) -> &'static str {
56        match self {
57            Self::Planning => "planning",
58            Self::Development => "development",
59            Self::Review => "review",
60            Self::Fix => "fix",
61            Self::Commit => "commit",
62            Self::Analysis => "analysis",
63        }
64    }
65
66    /// Parse a drain name from config.
67    #[must_use]
68    pub fn from_name(name: &str) -> Option<Self> {
69        match name {
70            "planning" => Some(Self::Planning),
71            "development" => Some(Self::Development),
72            "review" => Some(Self::Review),
73            "fix" => Some(Self::Fix),
74            "commit" => Some(Self::Commit),
75            "analysis" => Some(Self::Analysis),
76            _ => None,
77        }
78    }
79
80    /// Built-in drains in deterministic order.
81    #[must_use]
82    pub const fn all() -> [Self; 6] {
83        [
84            Self::Planning,
85            Self::Development,
86            Self::Review,
87            Self::Fix,
88            Self::Commit,
89            Self::Analysis,
90        ]
91    }
92}
93
94impl From<AgentRole> for AgentDrain {
95    fn from(value: AgentRole) -> Self {
96        match value {
97            AgentRole::Developer => Self::Development,
98            AgentRole::Reviewer => Self::Review,
99            AgentRole::Commit => Self::Commit,
100            AgentRole::Analysis => Self::Analysis,
101        }
102    }
103}
104
105impl std::fmt::Display for AgentDrain {
106    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107        write!(f, "{}", self.as_str())
108    }
109}
110
111/// Drain-local execution mode.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
113pub enum DrainMode {
114    #[default]
115    Normal,
116    Continuation,
117    SameAgentRetry,
118    XsdRetry,
119}
120
121/// Concrete runtime chain binding for one drain.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct ResolvedDrainBinding {
124    pub chain_name: String,
125    pub agents: Vec<String>,
126}
127
128/// Fully resolved drain configuration consumed by the runtime.
129#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
130pub struct ResolvedDrainConfig {
131    pub bindings: HashMap<AgentDrain, ResolvedDrainBinding>,
132    pub provider_fallback: HashMap<String, Vec<String>>,
133    pub max_retries: u32,
134    pub retry_delay_ms: u64,
135    pub backoff_multiplier: f64,
136    pub max_backoff_ms: u64,
137    pub max_cycles: u32,
138}
139
140impl ResolvedDrainConfig {
141    /// Return the binding for a specific drain.
142    #[must_use]
143    pub fn binding(&self, drain: AgentDrain) -> Option<&ResolvedDrainBinding> {
144        self.bindings.get(&drain)
145    }
146
147    /// Build a resolved drain config from the legacy role-shaped fallback config.
148    #[must_use]
149    pub fn from_legacy(fallback: &FallbackConfig) -> Self {
150        let bindings = AgentDrain::all()
151            .into_iter()
152            .map(|drain| {
153                let role = drain.role();
154                let chain_name = fallback.effective_chain_name_for_role(role).to_string();
155                (
156                    drain,
157                    ResolvedDrainBinding {
158                        chain_name,
159                        agents: fallback.get_fallbacks(role).to_vec(),
160                    },
161                )
162            })
163            .collect();
164
165        Self {
166            bindings,
167            provider_fallback: fallback.provider_fallback.clone(),
168            max_retries: fallback.max_retries,
169            retry_delay_ms: fallback.retry_delay_ms,
170            backoff_multiplier: fallback.backoff_multiplier,
171            max_backoff_ms: fallback.max_backoff_ms,
172            max_cycles: fallback.max_cycles,
173        }
174    }
175
176    /// Project resolved drain bindings back into the legacy role-shaped config.
177    ///
178    /// This is a compatibility view for config/error reporting. Runtime code
179    /// should consume resolved drain bindings directly.
180    #[must_use]
181    pub fn to_legacy_fallback(&self) -> FallbackConfig {
182        FallbackConfig {
183            developer: self
184                .binding(AgentDrain::Development)
185                .map_or_else(Vec::new, |binding| binding.agents.clone()),
186            reviewer: self
187                .binding(AgentDrain::Review)
188                .map_or_else(Vec::new, |binding| binding.agents.clone()),
189            commit: self
190                .binding(AgentDrain::Commit)
191                .map_or_else(Vec::new, |binding| binding.agents.clone()),
192            analysis: self
193                .binding(AgentDrain::Analysis)
194                .map_or_else(Vec::new, |binding| binding.agents.clone()),
195            provider_fallback: self.provider_fallback.clone(),
196            max_retries: self.max_retries,
197            retry_delay_ms: self.retry_delay_ms,
198            backoff_multiplier: self.backoff_multiplier,
199            max_backoff_ms: self.max_backoff_ms,
200            max_cycles: self.max_cycles,
201            legacy_role_keys_present: false,
202        }
203    }
204}
205
206impl std::fmt::Display for AgentRole {
207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
208        match self {
209            Self::Developer => write!(f, "developer"),
210            Self::Reviewer => write!(f, "reviewer"),
211            Self::Commit => write!(f, "commit"),
212            Self::Analysis => write!(f, "analysis"),
213        }
214    }
215}
216
217/// Agent chain configuration for preferred agents and fallback switching.
218///
219/// The agent chain defines both:
220/// 1. The **preferred agent** (first in the list) for each role
221/// 2. The **fallback agents** (remaining in the list) to try if the preferred fails
222///
223/// This provides a unified way to configure which agents to use and in what order.
224/// Ralph automatically switches to the next agent in the chain when encountering
225/// errors like rate limits or auth failures.
226///
227/// ## Provider-Level Fallback
228///
229/// In addition to agent-level fallback, you can configure provider-level fallback
230/// within a single agent using the `provider_fallback` field. This is useful for
231/// agents like opencode that support multiple providers/models via the `-m` flag.
232///
233/// Example:
234/// ```toml
235/// [agent_chain]
236/// provider_fallback.opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]
237/// ```
238///
239/// ## Exponential Backoff and Cycling
240///
241/// When all fallbacks are exhausted, Ralph uses exponential backoff and cycles
242/// back to the first agent in the chain:
243/// - Base delay starts at `retry_delay_ms` (default: 1000ms)
244/// - Each cycle multiplies by `backoff_multiplier` (default: 2.0)
245/// - Capped at `max_backoff_ms` (default: 60000ms = 1 minute)
246/// - Maximum cycles controlled by `max_cycles` (default: 3)
247#[derive(Debug, Clone, Serialize)]
248pub struct FallbackConfig {
249    /// Ordered list of agents for developer role (first = preferred, rest = fallbacks).
250    #[serde(default)]
251    pub developer: Vec<String>,
252    /// Ordered list of agents for reviewer role (first = preferred, rest = fallbacks).
253    #[serde(default)]
254    pub reviewer: Vec<String>,
255    /// Ordered list of agents for commit role (first = preferred, rest = fallbacks).
256    #[serde(default)]
257    pub commit: Vec<String>,
258    /// Ordered list of agents for analysis role (first = preferred, rest = fallbacks).
259    ///
260    /// If empty, analysis falls back to the developer chain.
261    #[serde(default)]
262    pub analysis: Vec<String>,
263    /// Provider-level fallback: maps agent name to list of model flags to try.
264    /// Example: `opencode = ["-m opencode/glm-4.7-free", "-m opencode/claude-sonnet-4"]`
265    #[serde(default)]
266    pub provider_fallback: HashMap<String, Vec<String>>,
267    /// Maximum number of retries per agent before moving to next.
268    #[serde(default = "default_max_retries")]
269    pub max_retries: u32,
270    /// Base delay between retries in milliseconds.
271    #[serde(default = "default_retry_delay_ms")]
272    pub retry_delay_ms: u64,
273    /// Multiplier for exponential backoff (default: 2.0).
274    #[serde(default = "default_backoff_multiplier")]
275    pub backoff_multiplier: f64,
276    /// Maximum backoff delay in milliseconds (default: 60000 = 1 minute).
277    #[serde(default = "default_max_backoff_ms")]
278    pub max_backoff_ms: u64,
279    /// Maximum number of cycles through all agents before giving up (default: 3).
280    #[serde(default = "default_max_cycles")]
281    pub max_cycles: u32,
282    #[serde(skip)]
283    pub(crate) legacy_role_keys_present: bool,
284}
285
286impl<'de> Deserialize<'de> for FallbackConfig {
287    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
288    where
289        D: serde::Deserializer<'de>,
290    {
291        #[derive(Deserialize)]
292        struct FallbackConfigSerde {
293            #[serde(default)]
294            developer: Option<Vec<String>>,
295            #[serde(default)]
296            reviewer: Option<Vec<String>>,
297            #[serde(default)]
298            commit: Option<Vec<String>>,
299            #[serde(default)]
300            analysis: Option<Vec<String>>,
301            #[serde(default)]
302            provider_fallback: HashMap<String, Vec<String>>,
303            #[serde(default = "default_max_retries")]
304            max_retries: u32,
305            #[serde(default = "default_retry_delay_ms")]
306            retry_delay_ms: u64,
307            #[serde(default = "default_backoff_multiplier")]
308            backoff_multiplier: f64,
309            #[serde(default = "default_max_backoff_ms")]
310            max_backoff_ms: u64,
311            #[serde(default = "default_max_cycles")]
312            max_cycles: u32,
313        }
314
315        let raw = FallbackConfigSerde::deserialize(deserializer)?;
316        let legacy_role_keys_present = raw.developer.is_some()
317            || raw.reviewer.is_some()
318            || raw.commit.is_some()
319            || raw.analysis.is_some();
320
321        Ok(Self {
322            developer: raw.developer.unwrap_or_default(),
323            reviewer: raw.reviewer.unwrap_or_default(),
324            commit: raw.commit.unwrap_or_default(),
325            analysis: raw.analysis.unwrap_or_default(),
326            provider_fallback: raw.provider_fallback,
327            max_retries: raw.max_retries,
328            retry_delay_ms: raw.retry_delay_ms,
329            backoff_multiplier: raw.backoff_multiplier,
330            max_backoff_ms: raw.max_backoff_ms,
331            max_cycles: raw.max_cycles,
332            legacy_role_keys_present,
333        })
334    }
335}
336
337const fn default_max_retries() -> u32 {
338    3
339}
340
341const fn default_retry_delay_ms() -> u64 {
342    1000
343}
344
345const fn default_backoff_multiplier() -> f64 {
346    2.0
347}
348
349const fn default_max_backoff_ms() -> u64 {
350    60000 // 1 minute
351}
352
353const fn default_max_cycles() -> u32 {
354    3
355}
356
357// IEEE 754 double precision constants for f64_to_u64_via_bits
358const IEEE_754_EXP_BIAS: i32 = 1023;
359const IEEE_754_EXP_MASK: u64 = 0x7FF;
360const IEEE_754_MANTISSA_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
361const IEEE_754_IMPLICIT_ONE: u64 = 1u64 << 52;
362
363/// Convert f64 to u64 using IEEE 754 bit manipulation to avoid cast lints.
364///
365/// This function handles the conversion by extracting the raw bits of the f64
366/// and manually decoding the IEEE 754 format. For values in the range [0, 100000],
367/// this produces correct results without triggering clippy's cast lints.
368#[expect(
369    clippy::arithmetic_side_effects,
370    reason = "IEEE 754 bit manipulation with bounded values"
371)]
372fn f64_to_u64_via_bits(value: f64) -> u64 {
373    // Handle special cases first
374    if !value.is_finite() || value < 0.0 {
375        return 0;
376    }
377
378    // Use to_bits() to get the raw IEEE 754 representation
379    let bits = value.to_bits();
380
381    // IEEE 754 double precision:
382    // - Bit 63: sign (we know it's 0 for non-negative values)
383    // - Bits 52-62: exponent (biased by 1023)
384    // - Bits 0-51: mantissa (with implicit leading 1 for normalized numbers)
385    let exp_biased = ((bits >> 52) & IEEE_754_EXP_MASK) as i32;
386    let mantissa = bits & IEEE_754_MANTISSA_MASK;
387
388    // Check for denormal numbers (exponent == 0)
389    if exp_biased == 0 {
390        // Denormal: value = mantissa * 2^(-1022)
391        // For small values (< 1), this results in 0
392        return 0;
393    }
394
395    // Normalized number
396    let exp = exp_biased - IEEE_754_EXP_BIAS;
397
398    // For integer values, the exponent tells us where the binary point is
399    // If exp < 0, the value is < 1, so round to 0
400    if exp < 0 {
401        return 0;
402    }
403
404    // For exp >= 0, we have an integer value
405    // The value is (1.mantissa) * 2^exp where 1.mantissa has 53 bits
406    let full_mantissa = mantissa | IEEE_754_IMPLICIT_ONE;
407
408    // Shift to get the integer value
409    // We need to shift right by (52 - exp) to get the integer
410    let shift = 52i32 - exp;
411
412    if shift <= 0 {
413        // Value is very large, saturate at u64::MAX
414        // But our input is clamped to [0, 100000], so this won't happen
415        u64::MAX
416    } else if shift < 64 {
417        full_mantissa >> shift
418    } else {
419        0
420    }
421}
422
423impl Default for FallbackConfig {
424    fn default() -> Self {
425        Self {
426            developer: Vec::new(),
427            reviewer: Vec::new(),
428            commit: Vec::new(),
429            analysis: Vec::new(),
430            provider_fallback: HashMap::new(),
431            max_retries: default_max_retries(),
432            retry_delay_ms: default_retry_delay_ms(),
433            backoff_multiplier: default_backoff_multiplier(),
434            max_backoff_ms: default_max_backoff_ms(),
435            max_cycles: default_max_cycles(),
436            legacy_role_keys_present: false,
437        }
438    }
439}
440
441impl FallbackConfig {
442    /// Return whether this legacy config carries any role-keyed chain bindings.
443    #[must_use]
444    pub fn has_role_bindings(&self) -> bool {
445        [
446            self.developer.as_slice(),
447            self.reviewer.as_slice(),
448            self.commit.as_slice(),
449            self.analysis.as_slice(),
450        ]
451        .into_iter()
452        .any(|chain| !chain.is_empty())
453    }
454
455    /// Return whether any legacy role key was explicitly present in the source config.
456    #[must_use]
457    pub const fn has_legacy_role_key_presence(&self) -> bool {
458        self.legacy_role_keys_present
459    }
460
461    /// Return whether the legacy role-keyed schema is in use.
462    #[must_use]
463    pub fn uses_legacy_role_schema(&self) -> bool {
464        self.legacy_role_keys_present || self.has_role_bindings()
465    }
466
467    const fn effective_chain_name_for_role(&self, role: AgentRole) -> &'static str {
468        match role {
469            AgentRole::Developer => "developer",
470            AgentRole::Reviewer => "reviewer",
471            AgentRole::Commit => {
472                if self.commit.is_empty() {
473                    "reviewer"
474                } else {
475                    "commit"
476                }
477            }
478            AgentRole::Analysis => {
479                if self.analysis.is_empty() {
480                    "developer"
481                } else {
482                    "analysis"
483                }
484            }
485        }
486    }
487
488    /// Calculate exponential backoff delay for a given cycle.
489    ///
490    /// Uses the formula: min(base * multiplier^cycle, `max_backoff`)
491    ///
492    /// Uses integer arithmetic to avoid floating-point casting issues.
493    #[must_use]
494    pub fn calculate_backoff(&self, cycle: u32) -> u64 {
495        // For common multiplier values, use direct integer computation
496        // to avoid f64->u64 conversion and associated clippy lints.
497        let multiplier_hundredths = self.get_multiplier_hundredths();
498        let base_hundredths = self.retry_delay_ms.saturating_mul(100);
499
500        // Calculate: base * (multiplier^cycle) / 100^cycle
501        // Use fold with saturating arithmetic to avoid overflow
502        let delay_hundredths = (0..cycle).fold(base_hundredths, |acc, _| {
503            acc.saturating_mul(multiplier_hundredths)
504                .saturating_div(100)
505        });
506
507        // Convert back to milliseconds
508        delay_hundredths.div_euclid(100).min(self.max_backoff_ms)
509    }
510
511    /// Get the multiplier as hundredths (e.g., 2.0 -> 200, 1.5 -> 150).
512    ///
513    /// Uses a lookup table for common values to avoid f64->u64 casts.
514    /// For uncommon values, uses a safe conversion with validation.
515    fn get_multiplier_hundredths(&self) -> u64 {
516        const EPSILON: f64 = 0.0001;
517
518        // Common multiplier values - use exact integer matches
519        // This avoids the cast for the vast majority of cases
520        let m = self.backoff_multiplier;
521        if (m - 1.0).abs() < EPSILON {
522            return 100;
523        } else if (m - 1.5).abs() < EPSILON {
524            return 150;
525        } else if (m - 2.0).abs() < EPSILON {
526            return 200;
527        } else if (m - 2.5).abs() < EPSILON {
528            return 250;
529        } else if (m - 3.0).abs() < EPSILON {
530            return 300;
531        } else if (m - 4.0).abs() < EPSILON {
532            return 400;
533        } else if (m - 5.0).abs() < EPSILON {
534            return 500;
535        } else if (m - 10.0).abs() < EPSILON {
536            return 1000;
537        }
538
539        // For uncommon values, compute using the original formula
540        // The value is clamped to [0.0, 1000.0] so the result is in [0.0, 100000.0]
541        // We use to_bits() and manual decoding to avoid cast lints
542        let clamped = m.clamp(0.0, 1000.0);
543        let multiplied = clamped * 100.0;
544        let rounded = multiplied.round();
545
546        // Manual f64 to u64 conversion using IEEE 754 bit representation
547        f64_to_u64_via_bits(rounded)
548    }
549
550    /// Get fallback agents for a role.
551    #[must_use]
552    pub fn get_fallbacks(&self, role: AgentRole) -> &[String] {
553        match role {
554            AgentRole::Developer => &self.developer,
555            AgentRole::Reviewer => &self.reviewer,
556            AgentRole::Commit => self.get_effective_commit_fallbacks(),
557            AgentRole::Analysis => self.get_effective_analysis_fallbacks(),
558        }
559    }
560
561    /// Get effective fallback agents for analysis role.
562    ///
563    /// Falls back to developer chain if analysis chain is empty.
564    fn get_effective_analysis_fallbacks(&self) -> &[String] {
565        if self.analysis.is_empty() {
566            &self.developer
567        } else {
568            &self.analysis
569        }
570    }
571
572    /// Get effective fallback agents for commit role.
573    ///
574    /// Falls back to reviewer chain if commit chain is empty.
575    /// This ensures commit message generation can use the same agents
576    /// configured for code review when no dedicated commit agents are specified.
577    fn get_effective_commit_fallbacks(&self) -> &[String] {
578        if self.commit.is_empty() {
579            &self.reviewer
580        } else {
581            &self.commit
582        }
583    }
584
585    /// Check if fallback is configured for a role.
586    #[must_use]
587    pub fn has_fallbacks(&self, role: AgentRole) -> bool {
588        !self.get_fallbacks(role).is_empty()
589    }
590
591    /// Get provider-level fallback model flags for an agent.
592    ///
593    /// Returns the list of model flags to try for the given agent name.
594    /// Empty slice if no provider fallback is configured for this agent.
595    pub fn get_provider_fallbacks(&self, agent_name: &str) -> &[String] {
596        self.provider_fallback
597            .get(agent_name)
598            .map_or(&[], std::vec::Vec::as_slice)
599    }
600
601    /// Check if provider-level fallback is configured for an agent.
602    #[must_use]
603    pub fn has_provider_fallbacks(&self, agent_name: &str) -> bool {
604        self.provider_fallback
605            .get(agent_name)
606            .is_some_and(|v| !v.is_empty())
607    }
608
609    /// Resolve this legacy fallback config into explicit built-in drains.
610    #[must_use]
611    pub fn resolve_drains(&self) -> ResolvedDrainConfig {
612        ResolvedDrainConfig::from_legacy(self)
613    }
614}
615
616#[cfg(test)]
617mod tests {
618    use super::*;
619
620    #[test]
621    fn test_agent_role_display() {
622        assert_eq!(format!("{}", AgentRole::Developer), "developer");
623        assert_eq!(format!("{}", AgentRole::Reviewer), "reviewer");
624        assert_eq!(format!("{}", AgentRole::Commit), "commit");
625        assert_eq!(format!("{}", AgentRole::Analysis), "analysis");
626    }
627
628    #[test]
629    fn test_agent_drain_role_mapping() {
630        assert_eq!(AgentDrain::Planning.role(), AgentRole::Developer);
631        assert_eq!(AgentDrain::Development.role(), AgentRole::Developer);
632        assert_eq!(AgentDrain::Review.role(), AgentRole::Reviewer);
633        assert_eq!(AgentDrain::Fix.role(), AgentRole::Reviewer);
634        assert_eq!(AgentDrain::Commit.role(), AgentRole::Commit);
635        assert_eq!(AgentDrain::Analysis.role(), AgentRole::Analysis);
636    }
637
638    #[test]
639    fn test_fallback_config_defaults() {
640        let config = FallbackConfig::default();
641        assert!(config.developer.is_empty());
642        assert!(config.reviewer.is_empty());
643        assert!(config.commit.is_empty());
644        assert!(config.analysis.is_empty());
645        assert_eq!(config.max_retries, 3);
646        assert_eq!(config.retry_delay_ms, 1000);
647        // Use approximate comparison for floating point
648        assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
649        assert_eq!(config.max_backoff_ms, 60000);
650        assert_eq!(config.max_cycles, 3);
651    }
652
653    #[test]
654    fn test_fallback_config_calculate_backoff() {
655        let config = FallbackConfig {
656            retry_delay_ms: 1000,
657            backoff_multiplier: 2.0,
658            max_backoff_ms: 60000,
659            ..Default::default()
660        };
661
662        assert_eq!(config.calculate_backoff(0), 1000);
663        assert_eq!(config.calculate_backoff(1), 2000);
664        assert_eq!(config.calculate_backoff(2), 4000);
665        assert_eq!(config.calculate_backoff(3), 8000);
666
667        // Should cap at max
668        assert_eq!(config.calculate_backoff(10), 60000);
669    }
670
671    #[test]
672    fn test_fallback_config_get_fallbacks() {
673        let config = FallbackConfig {
674            developer: vec!["claude".to_string(), "codex".to_string()],
675            reviewer: vec!["codex".to_string()],
676            ..Default::default()
677        };
678
679        assert_eq!(
680            config.get_fallbacks(AgentRole::Developer),
681            &["claude", "codex"]
682        );
683        assert_eq!(config.get_fallbacks(AgentRole::Reviewer), &["codex"]);
684
685        // Analysis defaults to developer chain when not configured.
686        assert_eq!(
687            config.get_fallbacks(AgentRole::Analysis),
688            &["claude", "codex"]
689        );
690    }
691
692    #[test]
693    fn test_fallback_config_has_fallbacks() {
694        let config = FallbackConfig {
695            developer: vec!["claude".to_string()],
696            reviewer: vec![],
697            ..Default::default()
698        };
699
700        assert!(config.has_fallbacks(AgentRole::Developer));
701        assert!(config.has_fallbacks(AgentRole::Analysis));
702        assert!(!config.has_fallbacks(AgentRole::Reviewer));
703    }
704
705    #[test]
706    fn test_fallback_config_defaults_provider_fallback() {
707        let config = FallbackConfig::default();
708        assert!(config.get_provider_fallbacks("opencode").is_empty());
709        assert!(!config.has_provider_fallbacks("opencode"));
710    }
711
712    #[test]
713    fn test_provider_fallback_config() {
714        let provider_fallback = HashMap::from([(
715            "opencode".to_string(),
716            vec![
717                "-m opencode/glm-4.7-free".to_string(),
718                "-m opencode/claude-sonnet-4".to_string(),
719            ],
720        )]);
721
722        let config = FallbackConfig {
723            provider_fallback,
724            ..Default::default()
725        };
726
727        let fallbacks = config.get_provider_fallbacks("opencode");
728        assert_eq!(fallbacks.len(), 2);
729        assert_eq!(fallbacks[0], "-m opencode/glm-4.7-free");
730        assert_eq!(fallbacks[1], "-m opencode/claude-sonnet-4");
731
732        assert!(config.has_provider_fallbacks("opencode"));
733        assert!(!config.has_provider_fallbacks("claude"));
734    }
735
736    #[test]
737    fn test_fallback_config_from_toml() {
738        let toml_str = r#"
739            developer = ["claude", "codex"]
740            reviewer = ["codex", "claude"]
741            max_retries = 5
742            retry_delay_ms = 2000
743
744            [provider_fallback]
745            opencode = ["-m opencode/glm-4.7-free", "-m zai/glm-4.7"]
746        "#;
747
748        let config: FallbackConfig = toml::from_str(toml_str).unwrap();
749        assert_eq!(config.developer, vec!["claude", "codex"]);
750        assert_eq!(config.reviewer, vec!["codex", "claude"]);
751        assert_eq!(config.max_retries, 5);
752        assert_eq!(config.retry_delay_ms, 2000);
753        assert_eq!(config.get_provider_fallbacks("opencode").len(), 2);
754    }
755
756    #[test]
757    fn test_commit_uses_reviewer_chain_when_empty() {
758        // When commit chain is empty, it should fall back to reviewer chain
759        let config = FallbackConfig {
760            commit: vec![],
761            reviewer: vec!["agent1".to_string(), "agent2".to_string()],
762            ..Default::default()
763        };
764
765        // Commit role should use reviewer chain when commit chain is empty
766        assert_eq!(
767            config.get_fallbacks(AgentRole::Commit),
768            &["agent1", "agent2"]
769        );
770        assert!(config.has_fallbacks(AgentRole::Commit));
771    }
772
773    #[test]
774    fn test_commit_uses_own_chain_when_configured() {
775        // When commit chain is configured, it should use its own chain
776        let config = FallbackConfig {
777            commit: vec!["commit-agent".to_string()],
778            reviewer: vec!["reviewer-agent".to_string()],
779            ..Default::default()
780        };
781
782        // Commit role should use its own chain
783        assert_eq!(config.get_fallbacks(AgentRole::Commit), &["commit-agent"]);
784    }
785}