Skip to main content

ralph_workflow/reducer/
state.rs

1//! Pipeline state types for reducer architecture.
2//!
3//! Defines immutable state structures that capture complete pipeline execution context.
4//! These state structures can be serialized as checkpoints for resume functionality.
5
6use crate::agents::AgentRole;
7use crate::checkpoint::execution_history::ExecutionStep;
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10
11use super::event::PipelinePhase;
12
13/// Development status from agent output.
14///
15/// These values map to the `<ralph-status>` element in development_result.xml.
16/// Used to track whether work is complete or needs continuation.
17#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
18pub enum DevelopmentStatus {
19    /// Work completed successfully - no continuation needed.
20    Completed,
21    /// Work partially done - needs continuation.
22    Partial,
23    /// Work failed - needs continuation with different approach.
24    Failed,
25}
26
27impl std::fmt::Display for DevelopmentStatus {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self {
30            Self::Completed => write!(f, "completed"),
31            Self::Partial => write!(f, "partial"),
32            Self::Failed => write!(f, "failed"),
33        }
34    }
35}
36
37/// Continuation state for development iterations.
38///
39/// Tracks context from previous attempts within the same iteration to enable
40/// continuation-aware prompting when status is "partial" or "failed".
41///
42/// # When Fields Are Populated
43///
44/// - `previous_status`: Set when DevelopmentIterationContinuationTriggered event fires
45/// - `previous_summary`: Set when DevelopmentIterationContinuationTriggered event fires
46/// - `previous_files_changed`: Set when DevelopmentIterationContinuationTriggered event fires
47/// - `previous_next_steps`: Set when DevelopmentIterationContinuationTriggered event fires
48/// - `continuation_attempt`: Incremented on each continuation within same iteration
49///
50/// # Reset Triggers
51///
52/// The continuation state is reset (cleared) when:
53/// - A new iteration starts (DevelopmentIterationStarted event)
54/// - Status becomes "completed" (ContinuationSucceeded event)
55/// - Phase transitions away from Development
56#[derive(Clone, Debug, Serialize, Deserialize, Default, PartialEq, Eq)]
57pub struct ContinuationState {
58    /// Status from the previous attempt ("partial" or "failed").
59    pub previous_status: Option<DevelopmentStatus>,
60    /// Summary of what was accomplished in the previous attempt.
61    pub previous_summary: Option<String>,
62    /// Files changed in the previous attempt.
63    pub previous_files_changed: Option<Vec<String>>,
64    /// Agent's recommended next steps from the previous attempt.
65    pub previous_next_steps: Option<String>,
66    /// Current continuation attempt number (0 = first attempt, 1+ = continuation).
67    pub continuation_attempt: u32,
68}
69
70impl ContinuationState {
71    /// Create a new empty continuation state.
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Check if this is a continuation attempt (not the first attempt).
77    pub fn is_continuation(&self) -> bool {
78        self.continuation_attempt > 0
79    }
80
81    /// Reset the continuation state for a new iteration.
82    pub fn reset(&self) -> Self {
83        Self::default()
84    }
85
86    /// Trigger a continuation with context from the previous attempt.
87    pub fn trigger_continuation(
88        &self,
89        status: DevelopmentStatus,
90        summary: String,
91        files_changed: Option<Vec<String>>,
92        next_steps: Option<String>,
93    ) -> Self {
94        Self {
95            previous_status: Some(status),
96            previous_summary: Some(summary),
97            previous_files_changed: files_changed,
98            previous_next_steps: next_steps,
99            continuation_attempt: self.continuation_attempt + 1,
100        }
101    }
102}
103
104/// Immutable pipeline state (this IS the checkpoint).
105///
106/// Contains all information needed to resume pipeline execution at any point.
107/// The reducer updates this state by returning new immutable copies on each event.
108#[derive(Clone, Serialize, Deserialize, Debug)]
109pub struct PipelineState {
110    pub phase: PipelinePhase,
111    pub previous_phase: Option<PipelinePhase>,
112    pub iteration: u32,
113    pub total_iterations: u32,
114    pub reviewer_pass: u32,
115    pub total_reviewer_passes: u32,
116    pub review_issues_found: bool,
117    pub context_cleaned: bool,
118    pub agent_chain: AgentChainState,
119    pub rebase: RebaseState,
120    pub commit: CommitState,
121    pub execution_history: Vec<ExecutionStep>,
122    /// Continuation state for development iterations.
123    ///
124    /// Tracks context from previous attempts when status is "partial" or "failed"
125    /// to enable continuation-aware prompting.
126    #[serde(default)]
127    pub continuation: ContinuationState,
128}
129
130impl PipelineState {
131    pub fn initial(developer_iters: u32, reviewer_reviews: u32) -> Self {
132        // Determine initial phase based on what work needs to be done
133        let initial_phase = if developer_iters == 0 {
134            // No development iterations → skip Planning and Development
135            if reviewer_reviews == 0 {
136                // No review passes either → go straight to commit
137                PipelinePhase::CommitMessage
138            } else {
139                PipelinePhase::Review
140            }
141        } else {
142            PipelinePhase::Planning
143        };
144
145        Self {
146            phase: initial_phase,
147            previous_phase: None,
148            iteration: 0,
149            total_iterations: developer_iters,
150            reviewer_pass: 0,
151            total_reviewer_passes: reviewer_reviews,
152            review_issues_found: false,
153            context_cleaned: false,
154            agent_chain: AgentChainState::initial(),
155            rebase: RebaseState::NotStarted,
156            commit: CommitState::NotStarted,
157            execution_history: Vec::new(),
158            continuation: ContinuationState::new(),
159        }
160    }
161
162    pub fn is_complete(&self) -> bool {
163        matches!(
164            self.phase,
165            PipelinePhase::Complete | PipelinePhase::Interrupted
166        )
167    }
168
169    pub fn current_head(&self) -> String {
170        self.rebase
171            .current_head()
172            .unwrap_or_else(|| "HEAD".to_string())
173    }
174}
175
176/// Agent fallback chain state (explicit, not loop indices).
177///
178/// Tracks position in the multi-level fallback chain:
179/// - Agent level (primary → fallback1 → fallback2)
180/// - Model level (within each agent, try different models)
181/// - Retry cycle (exhaust all agents, start over with exponential backoff)
182#[derive(Clone, Serialize, Deserialize, Debug)]
183pub struct AgentChainState {
184    pub agents: Vec<String>,
185    pub current_agent_index: usize,
186    pub models_per_agent: Vec<Vec<String>>,
187    pub current_model_index: usize,
188    pub retry_cycle: u32,
189    pub max_cycles: u32,
190    pub current_role: AgentRole,
191    /// Prompt context preserved from rate-limited agent for continuation.
192    ///
193    /// When an agent hits 429, we save the prompt here so the next agent
194    /// can continue the same work instead of starting from scratch.
195    #[serde(default)]
196    pub rate_limit_continuation_prompt: Option<String>,
197}
198
199impl AgentChainState {
200    pub fn initial() -> Self {
201        Self {
202            agents: Vec::new(),
203            current_agent_index: 0,
204            models_per_agent: Vec::new(),
205            current_model_index: 0,
206            retry_cycle: 0,
207            max_cycles: 3,
208            current_role: AgentRole::Developer,
209            rate_limit_continuation_prompt: None,
210        }
211    }
212
213    pub fn with_agents(
214        mut self,
215        agents: Vec<String>,
216        models_per_agent: Vec<Vec<String>>,
217        role: AgentRole,
218    ) -> Self {
219        self.agents = agents;
220        self.models_per_agent = models_per_agent;
221        self.current_role = role;
222        self
223    }
224
225    /// Builder method to set the maximum number of retry cycles.
226    ///
227    /// A retry cycle is when all agents have been exhausted and we start
228    /// over with exponential backoff.
229    pub fn with_max_cycles(mut self, max_cycles: u32) -> Self {
230        self.max_cycles = max_cycles;
231        self
232    }
233
234    pub fn current_agent(&self) -> Option<&String> {
235        self.agents.get(self.current_agent_index)
236    }
237
238    /// Get the currently selected model for the current agent.
239    ///
240    /// Returns `None` if:
241    /// - No models are configured
242    /// - The current agent index is out of bounds
243    /// - The current model index is out of bounds
244    pub fn current_model(&self) -> Option<&String> {
245        self.models_per_agent
246            .get(self.current_agent_index)
247            .and_then(|models| models.get(self.current_model_index))
248    }
249
250    pub fn is_exhausted(&self) -> bool {
251        self.retry_cycle >= self.max_cycles
252            && self.current_agent_index == 0
253            && self.current_model_index == 0
254    }
255
256    pub fn advance_to_next_model(&self) -> Self {
257        let mut new = self.clone();
258        if let Some(models) = new.models_per_agent.get(new.current_agent_index) {
259            if new.current_model_index + 1 < models.len() {
260                new.current_model_index += 1;
261            } else {
262                new.current_model_index = 0;
263            }
264        }
265        new
266    }
267
268    pub fn switch_to_next_agent(&self) -> Self {
269        let mut new = self.clone();
270        if new.current_agent_index + 1 < new.agents.len() {
271            new.current_agent_index += 1;
272            new.current_model_index = 0;
273        } else {
274            new.current_agent_index = 0;
275            new.current_model_index = 0;
276            new.retry_cycle += 1;
277        }
278        new
279    }
280
281    /// Switch to next agent after rate limit, preserving prompt for continuation.
282    ///
283    /// This is used when an agent hits a 429 rate limit error. Instead of
284    /// retrying with the same agent (which would likely hit rate limits again),
285    /// we switch to the next agent and preserve the prompt so the new agent
286    /// can continue the same work.
287    pub fn switch_to_next_agent_with_prompt(&self, prompt: Option<String>) -> Self {
288        // Rate-limit fallback is special: it should never retry an agent that has
289        // already been rate-limited in the current chain.
290        //
291        // For single-agent chains (or when switching would wrap around), we
292        // treat the chain as exhausted to avoid immediately re-invoking the same
293        // rate-limited agent.
294        if self.agents.len() <= 1 {
295            let mut exhausted = self.clone();
296            exhausted.current_agent_index = 0;
297            exhausted.current_model_index = 0;
298            exhausted.retry_cycle = exhausted.max_cycles;
299            exhausted.rate_limit_continuation_prompt = None;
300            return exhausted;
301        }
302
303        if self.current_agent_index + 1 >= self.agents.len() {
304            let mut exhausted = self.clone();
305            exhausted.current_agent_index = 0;
306            exhausted.current_model_index = 0;
307            exhausted.retry_cycle = exhausted.max_cycles;
308            exhausted.rate_limit_continuation_prompt = None;
309            return exhausted;
310        }
311
312        let mut next = self.switch_to_next_agent();
313        next.rate_limit_continuation_prompt = prompt;
314        next
315    }
316
317    /// Clear continuation prompt after successful execution.
318    ///
319    /// Called when an agent successfully completes its task, clearing any
320    /// saved prompt context from previous rate-limited agents.
321    pub fn clear_continuation_prompt(&self) -> Self {
322        let mut new = self.clone();
323        new.rate_limit_continuation_prompt = None;
324        new
325    }
326
327    pub fn reset_for_role(&self, role: AgentRole) -> Self {
328        let mut new = self.clone();
329        new.current_role = role;
330        new.current_agent_index = 0;
331        new.current_model_index = 0;
332        new.retry_cycle = 0;
333        new.rate_limit_continuation_prompt = None;
334        new
335    }
336
337    pub fn reset(&self) -> Self {
338        let mut new = self.clone();
339        new.current_agent_index = 0;
340        new.current_model_index = 0;
341        new.rate_limit_continuation_prompt = None;
342        new
343    }
344
345    pub fn start_retry_cycle(&self) -> Self {
346        let mut new = self.clone();
347        new.current_agent_index = 0;
348        new.current_model_index = 0;
349        new.retry_cycle += 1;
350        new
351    }
352}
353
354/// Rebase operation state.
355///
356/// Tracks rebase progress through the state machine:
357/// NotStarted → InProgress → Conflicted → Completed/Skipped
358#[derive(Clone, Serialize, Deserialize, Debug)]
359pub enum RebaseState {
360    NotStarted,
361    InProgress {
362        original_head: String,
363        target_branch: String,
364    },
365    Conflicted {
366        original_head: String,
367        target_branch: String,
368        files: Vec<PathBuf>,
369        resolution_attempts: u32,
370    },
371    Completed {
372        new_head: String,
373    },
374    Skipped,
375}
376
377impl RebaseState {
378    #[doc(hidden)]
379    #[cfg(any(test, feature = "test-utils"))]
380    pub fn is_terminal(&self) -> bool {
381        matches!(self, RebaseState::Completed { .. } | RebaseState::Skipped)
382    }
383
384    pub fn current_head(&self) -> Option<String> {
385        match self {
386            RebaseState::NotStarted | RebaseState::Skipped => None,
387            RebaseState::InProgress { original_head, .. } => Some(original_head.clone()),
388            RebaseState::Conflicted { .. } => None,
389            RebaseState::Completed { new_head } => Some(new_head.clone()),
390        }
391    }
392
393    #[doc(hidden)]
394    #[cfg(any(test, feature = "test-utils"))]
395    pub fn is_in_progress(&self) -> bool {
396        matches!(
397            self,
398            RebaseState::InProgress { .. } | RebaseState::Conflicted { .. }
399        )
400    }
401}
402
403/// Maximum number of retry attempts when XML/format validation fails.
404///
405/// This applies across the pipeline for:
406/// - Commit message generation validation failures
407/// - Plan generation validation failures  
408/// - Development output validation failures
409/// - Review output validation failures
410///
411/// When an agent produces output that fails XML parsing or format validation,
412/// we retry with corrective prompts up to this many times before giving up.
413pub const MAX_VALIDATION_RETRY_ATTEMPTS: u32 = 100;
414
415/// Maximum number of developer validation retry attempts before giving up.
416///
417/// Specifically for developer iterations - this is for XSD validation failures
418/// (malformed XML). After exhausting these retries, the system will fall back
419/// to a continuation attempt with a fresh prompt rather than failing entirely.
420/// This separates XSD retry (can't parse the response) from continuation
421/// (understood the response but work is incomplete).
422pub const MAX_DEV_VALIDATION_RETRY_ATTEMPTS: u32 = 10;
423
424/// Commit generation state.
425///
426/// Tracks commit message generation progress through retries:
427/// NotStarted → Generating → Generated → Committed/Skipped
428#[derive(Clone, Serialize, Deserialize, Debug)]
429pub enum CommitState {
430    NotStarted,
431    Generating { attempt: u32, max_attempts: u32 },
432    Generated { message: String },
433    Committed { hash: String },
434    Skipped,
435}
436
437impl CommitState {
438    #[doc(hidden)]
439    #[cfg(any(test, feature = "test-utils"))]
440    pub fn is_terminal(&self) -> bool {
441        matches!(self, CommitState::Committed { .. } | CommitState::Skipped)
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    #[test]
450    fn test_pipeline_state_initial() {
451        let state = PipelineState::initial(5, 2);
452        assert_eq!(state.phase, PipelinePhase::Planning);
453        assert_eq!(state.total_iterations, 5);
454        assert_eq!(state.total_reviewer_passes, 2);
455        assert!(!state.is_complete());
456    }
457
458    #[test]
459    fn test_agent_chain_initial() {
460        let chain = AgentChainState::initial();
461        assert!(chain.agents.is_empty());
462        assert_eq!(chain.current_agent_index, 0);
463        assert_eq!(chain.retry_cycle, 0);
464    }
465
466    #[test]
467    fn test_agent_chain_with_agents() {
468        let chain = AgentChainState::initial()
469            .with_agents(
470                vec!["claude".to_string(), "codex".to_string()],
471                vec![vec![], vec![]],
472                AgentRole::Developer,
473            )
474            .with_max_cycles(3);
475
476        assert_eq!(chain.agents.len(), 2);
477        assert_eq!(chain.current_agent(), Some(&"claude".to_string()));
478        assert_eq!(chain.max_cycles, 3);
479    }
480
481    #[test]
482    fn test_agent_chain_advance_to_next_model() {
483        let chain = AgentChainState::initial().with_agents(
484            vec!["claude".to_string()],
485            vec![vec!["model1".to_string(), "model2".to_string()]],
486            AgentRole::Developer,
487        );
488
489        let new_chain = chain.advance_to_next_model();
490        assert_eq!(new_chain.current_model_index, 1);
491        assert_eq!(new_chain.current_model(), Some(&"model2".to_string()));
492    }
493
494    #[test]
495    fn test_agent_chain_switch_to_next_agent() {
496        let chain = AgentChainState::initial().with_agents(
497            vec!["claude".to_string(), "codex".to_string()],
498            vec![vec![], vec![]],
499            AgentRole::Developer,
500        );
501
502        let new_chain = chain.switch_to_next_agent();
503        assert_eq!(new_chain.current_agent_index, 1);
504        assert_eq!(new_chain.current_agent(), Some(&"codex".to_string()));
505        assert_eq!(new_chain.retry_cycle, 0);
506    }
507
508    #[test]
509    fn test_agent_chain_exhausted() {
510        let chain = AgentChainState::initial()
511            .with_agents(
512                vec!["claude".to_string()],
513                vec![vec![]],
514                AgentRole::Developer,
515            )
516            .with_max_cycles(3);
517
518        let chain = chain.start_retry_cycle();
519        let chain = chain.start_retry_cycle();
520        let chain = chain.start_retry_cycle();
521
522        assert!(chain.is_exhausted());
523    }
524
525    #[test]
526    fn test_rebase_state_not_started() {
527        let state = RebaseState::NotStarted;
528        assert!(!state.is_terminal());
529        assert!(state.current_head().is_none());
530        assert!(!state.is_in_progress());
531    }
532
533    #[test]
534    fn test_rebase_state_in_progress() {
535        let state = RebaseState::InProgress {
536            original_head: "abc123".to_string(),
537            target_branch: "main".to_string(),
538        };
539        assert!(!state.is_terminal());
540        assert_eq!(state.current_head(), Some("abc123".to_string()));
541        assert!(state.is_in_progress());
542    }
543
544    #[test]
545    fn test_rebase_state_completed() {
546        let state = RebaseState::Completed {
547            new_head: "def456".to_string(),
548        };
549        assert!(state.is_terminal());
550        assert_eq!(state.current_head(), Some("def456".to_string()));
551        assert!(!state.is_in_progress());
552    }
553
554    #[test]
555    fn test_commit_state_not_started() {
556        let state = CommitState::NotStarted;
557        assert!(!state.is_terminal());
558    }
559
560    #[test]
561    fn test_commit_state_generating() {
562        let state = CommitState::Generating {
563            attempt: 1,
564            max_attempts: 3,
565        };
566        assert!(!state.is_terminal());
567    }
568
569    #[test]
570    fn test_commit_state_committed() {
571        let state = CommitState::Committed {
572            hash: "abc123".to_string(),
573        };
574        assert!(state.is_terminal());
575    }
576
577    #[test]
578    fn test_is_complete_during_finalizing() {
579        // Finalizing phase should NOT be complete - event loop must continue
580        // to execute the RestorePromptPermissions effect
581        let state = PipelineState {
582            phase: PipelinePhase::Finalizing,
583            ..PipelineState::initial(5, 2)
584        };
585        assert!(
586            !state.is_complete(),
587            "Finalizing phase should not be complete - event loop must continue"
588        );
589    }
590
591    #[test]
592    fn test_is_complete_after_finalization() {
593        // Complete phase IS complete
594        let state = PipelineState {
595            phase: PipelinePhase::Complete,
596            ..PipelineState::initial(5, 2)
597        };
598        assert!(state.is_complete(), "Complete phase should be complete");
599    }
600
601    // =========================================================================
602    // Continuation state tests
603    // =========================================================================
604
605    #[test]
606    fn test_continuation_state_initial() {
607        let state = ContinuationState::new();
608        assert!(!state.is_continuation());
609        assert_eq!(state.continuation_attempt, 0);
610        assert!(state.previous_status.is_none());
611        assert!(state.previous_summary.is_none());
612        assert!(state.previous_files_changed.is_none());
613        assert!(state.previous_next_steps.is_none());
614    }
615
616    #[test]
617    fn test_continuation_state_default() {
618        let state = ContinuationState::default();
619        assert!(!state.is_continuation());
620        assert_eq!(state.continuation_attempt, 0);
621    }
622
623    #[test]
624    fn test_continuation_trigger_partial() {
625        let state = ContinuationState::new();
626        let new_state = state.trigger_continuation(
627            DevelopmentStatus::Partial,
628            "Did some work".to_string(),
629            Some(vec!["file1.rs".to_string()]),
630            Some("Continue with tests".to_string()),
631        );
632
633        assert!(new_state.is_continuation());
634        assert_eq!(new_state.continuation_attempt, 1);
635        assert_eq!(new_state.previous_status, Some(DevelopmentStatus::Partial));
636        assert_eq!(
637            new_state.previous_summary,
638            Some("Did some work".to_string())
639        );
640        assert_eq!(
641            new_state.previous_files_changed,
642            Some(vec!["file1.rs".to_string()])
643        );
644        assert_eq!(
645            new_state.previous_next_steps,
646            Some("Continue with tests".to_string())
647        );
648    }
649
650    #[test]
651    fn test_continuation_trigger_failed() {
652        let state = ContinuationState::new();
653        let new_state = state.trigger_continuation(
654            DevelopmentStatus::Failed,
655            "Build failed".to_string(),
656            None,
657            Some("Fix errors".to_string()),
658        );
659
660        assert!(new_state.is_continuation());
661        assert_eq!(new_state.continuation_attempt, 1);
662        assert_eq!(new_state.previous_status, Some(DevelopmentStatus::Failed));
663        assert_eq!(new_state.previous_summary, Some("Build failed".to_string()));
664        assert!(new_state.previous_files_changed.is_none());
665        assert_eq!(
666            new_state.previous_next_steps,
667            Some("Fix errors".to_string())
668        );
669    }
670
671    #[test]
672    fn test_continuation_reset() {
673        let state = ContinuationState::new().trigger_continuation(
674            DevelopmentStatus::Partial,
675            "Work".to_string(),
676            None,
677            None,
678        );
679
680        let reset = state.reset();
681        assert!(!reset.is_continuation());
682        assert_eq!(reset.continuation_attempt, 0);
683        assert!(reset.previous_status.is_none());
684        assert!(reset.previous_summary.is_none());
685    }
686
687    #[test]
688    fn test_multiple_continuations() {
689        let state = ContinuationState::new()
690            .trigger_continuation(
691                DevelopmentStatus::Partial,
692                "First".to_string(),
693                Some(vec!["a.rs".to_string()]),
694                None,
695            )
696            .trigger_continuation(
697                DevelopmentStatus::Partial,
698                "Second".to_string(),
699                Some(vec!["b.rs".to_string()]),
700                Some("Do more".to_string()),
701            );
702
703        assert_eq!(state.continuation_attempt, 2);
704        assert_eq!(state.previous_summary, Some("Second".to_string()));
705        assert_eq!(state.previous_files_changed, Some(vec!["b.rs".to_string()]));
706        assert_eq!(state.previous_next_steps, Some("Do more".to_string()));
707    }
708
709    #[test]
710    fn test_development_status_display() {
711        assert_eq!(format!("{}", DevelopmentStatus::Completed), "completed");
712        assert_eq!(format!("{}", DevelopmentStatus::Partial), "partial");
713        assert_eq!(format!("{}", DevelopmentStatus::Failed), "failed");
714    }
715
716    #[test]
717    fn test_pipeline_state_initial_has_empty_continuation() {
718        let state = PipelineState::initial(5, 2);
719        assert!(!state.continuation.is_continuation());
720        assert_eq!(state.continuation.continuation_attempt, 0);
721    }
722
723    #[test]
724    fn test_agent_chain_reset_clears_rate_limit_continuation_prompt() {
725        let mut chain = AgentChainState::initial().with_agents(
726            vec!["agent1".to_string(), "agent2".to_string()],
727            vec![vec![], vec![]],
728            AgentRole::Developer,
729        );
730        chain.rate_limit_continuation_prompt = Some("saved".to_string());
731
732        let reset = chain.reset();
733        assert!(
734            reset.rate_limit_continuation_prompt.is_none(),
735            "reset() should clear rate_limit_continuation_prompt"
736        );
737    }
738
739    #[test]
740    fn test_agent_chain_reset_for_role_clears_rate_limit_continuation_prompt() {
741        let mut chain = AgentChainState::initial().with_agents(
742            vec!["agent1".to_string(), "agent2".to_string()],
743            vec![vec![], vec![]],
744            AgentRole::Developer,
745        );
746        chain.rate_limit_continuation_prompt = Some("saved".to_string());
747
748        let reset = chain.reset_for_role(AgentRole::Reviewer);
749        assert!(
750            reset.rate_limit_continuation_prompt.is_none(),
751            "reset_for_role() should clear rate_limit_continuation_prompt"
752        );
753    }
754
755    #[test]
756    fn test_switch_to_next_agent_with_prompt_exhausts_when_single_agent() {
757        let chain = AgentChainState::initial().with_agents(
758            vec!["agent1".to_string()],
759            vec![vec![]],
760            AgentRole::Developer,
761        );
762
763        let next = chain.switch_to_next_agent_with_prompt(Some("prompt".to_string()));
764        assert!(
765            next.is_exhausted(),
766            "single-agent rate limit fallback should exhaust the chain"
767        );
768    }
769
770    #[test]
771    fn test_switch_to_next_agent_with_prompt_exhausts_on_wraparound() {
772        let mut chain = AgentChainState::initial().with_agents(
773            vec!["agent1".to_string(), "agent2".to_string()],
774            vec![vec![], vec![]],
775            AgentRole::Developer,
776        );
777        chain.current_agent_index = 1;
778
779        let next = chain.switch_to_next_agent_with_prompt(Some("prompt".to_string()));
780        assert!(
781            next.is_exhausted(),
782            "rate limit fallback should not wrap and retry a previously rate-limited agent"
783        );
784    }
785}