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