Skip to main content

ralph_workflow/reducer/
state_reduction.rs

1//! Reducer function for state transitions.
2//!
3//! Implements pure state reduction - no side effects, exhaustive pattern matching.
4//!
5//! # Architecture
6//!
7//! The main `reduce` function delegates to category-specific handlers for
8//! better organization and maintainability:
9//!
10//! - `reduce_pipeline_lifecycle` - Pipeline start/stop/completion events
11//! - `reduce_planning_event` - Planning phase events
12//! - `reduce_development_event` - Development iteration events
13//! - `reduce_review_event` - Review pass and fix events
14//! - `reduce_agent_event` - Agent invocation and chain events
15//! - `reduce_rebase_event` - Rebase operation events
16//! - `reduce_commit_event` - Commit generation and creation events
17//!
18//! Each handler is a pure function that takes state and returns new state.
19
20use super::event::PipelineEvent;
21use super::state::{CommitState, ContinuationState, PipelineState, RebaseState};
22
23/// Pure reducer - no side effects, exhaustive match.
24///
25/// Computes new state by applying an event to current state.
26/// This function has zero side effects - all state mutations are explicit.
27///
28/// Delegates to category-specific handlers for better organization.
29pub fn reduce(state: PipelineState, event: PipelineEvent) -> PipelineState {
30    match &event {
31        // Pipeline lifecycle events
32        PipelineEvent::PipelineStarted
33        | PipelineEvent::PipelineResumed { .. }
34        | PipelineEvent::PipelineCompleted
35        | PipelineEvent::PipelineAborted { .. } => reduce_pipeline_lifecycle(state, event),
36
37        // Planning events
38        PipelineEvent::PlanningPhaseStarted
39        | PipelineEvent::PlanningPhaseCompleted
40        | PipelineEvent::PlanGenerationStarted { .. }
41        | PipelineEvent::PlanGenerationCompleted { .. } => reduce_planning_event(state, event),
42
43        // Development events
44        PipelineEvent::DevelopmentPhaseStarted
45        | PipelineEvent::DevelopmentIterationStarted { .. }
46        | PipelineEvent::DevelopmentIterationCompleted { .. }
47        | PipelineEvent::DevelopmentPhaseCompleted
48        | PipelineEvent::DevelopmentIterationContinuationTriggered { .. }
49        | PipelineEvent::DevelopmentIterationContinuationSucceeded { .. } => {
50            reduce_development_event(state, event)
51        }
52
53        // Review events
54        PipelineEvent::ReviewPhaseStarted
55        | PipelineEvent::ReviewPassStarted { .. }
56        | PipelineEvent::ReviewCompleted { .. }
57        | PipelineEvent::FixAttemptStarted { .. }
58        | PipelineEvent::FixAttemptCompleted { .. }
59        | PipelineEvent::ReviewPhaseCompleted { .. } => reduce_review_event(state, event),
60
61        // Agent events
62        PipelineEvent::AgentInvocationStarted { .. }
63        | PipelineEvent::AgentInvocationSucceeded { .. }
64        | PipelineEvent::AgentInvocationFailed { .. }
65        | PipelineEvent::AgentFallbackTriggered { .. }
66        | PipelineEvent::AgentChainExhausted { .. }
67        | PipelineEvent::AgentModelFallbackTriggered { .. }
68        | PipelineEvent::AgentRetryCycleStarted { .. }
69        | PipelineEvent::AgentChainInitialized { .. }
70        | PipelineEvent::AgentRateLimitFallback { .. } => reduce_agent_event(state, event),
71
72        // Rebase events
73        PipelineEvent::RebaseStarted { .. }
74        | PipelineEvent::RebaseConflictDetected { .. }
75        | PipelineEvent::RebaseConflictResolved { .. }
76        | PipelineEvent::RebaseSucceeded { .. }
77        | PipelineEvent::RebaseFailed { .. }
78        | PipelineEvent::RebaseSkipped { .. }
79        | PipelineEvent::RebaseAborted { .. } => reduce_rebase_event(state, event),
80
81        // Commit events
82        PipelineEvent::CommitGenerationStarted
83        | PipelineEvent::CommitMessageGenerated { .. }
84        | PipelineEvent::CommitCreated { .. }
85        | PipelineEvent::CommitGenerationFailed { .. }
86        | PipelineEvent::CommitSkipped { .. }
87        | PipelineEvent::CommitMessageValidationFailed { .. } => reduce_commit_event(state, event),
88
89        // Miscellaneous events
90        PipelineEvent::ContextCleaned => PipelineState {
91            context_cleaned: true,
92            ..state
93        },
94        PipelineEvent::CheckpointSaved { .. } => state,
95        PipelineEvent::FinalizingStarted => PipelineState {
96            phase: super::event::PipelinePhase::Finalizing,
97            ..state
98        },
99        PipelineEvent::PromptPermissionsRestored => PipelineState {
100            phase: super::event::PipelinePhase::Complete,
101            ..state
102        },
103    }
104}
105
106// ============================================================================
107// Category-specific reducers
108// ============================================================================
109
110/// Handle pipeline lifecycle events.
111fn reduce_pipeline_lifecycle(state: PipelineState, event: PipelineEvent) -> PipelineState {
112    match event {
113        PipelineEvent::PipelineStarted => state,
114        PipelineEvent::PipelineResumed { .. } => state,
115        PipelineEvent::PipelineCompleted => PipelineState {
116            phase: super::event::PipelinePhase::Complete,
117            ..state
118        },
119        PipelineEvent::PipelineAborted { .. } => PipelineState {
120            phase: super::event::PipelinePhase::Interrupted,
121            ..state
122        },
123        _ => state,
124    }
125}
126
127/// Handle planning phase events.
128fn reduce_planning_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
129    match event {
130        PipelineEvent::PlanningPhaseStarted => PipelineState {
131            phase: super::event::PipelinePhase::Planning,
132            ..state
133        },
134        PipelineEvent::PlanningPhaseCompleted => PipelineState {
135            phase: super::event::PipelinePhase::Development,
136            ..state
137        },
138        PipelineEvent::PlanGenerationStarted { .. } => state,
139        PipelineEvent::PlanGenerationCompleted { valid, .. } => {
140            if valid {
141                PipelineState {
142                    phase: super::event::PipelinePhase::Development,
143                    ..state
144                }
145            } else {
146                // Do not proceed to Development without a valid plan.
147                PipelineState {
148                    phase: super::event::PipelinePhase::Planning,
149                    ..state
150                }
151            }
152        }
153        _ => state,
154    }
155}
156
157/// Handle development phase events.
158fn reduce_development_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
159    match event {
160        PipelineEvent::DevelopmentPhaseStarted => PipelineState {
161            phase: super::event::PipelinePhase::Development,
162            ..state
163        },
164        PipelineEvent::DevelopmentIterationStarted { iteration } => PipelineState {
165            iteration,
166            agent_chain: state.agent_chain.reset(),
167            // Reset continuation state when starting a new iteration
168            continuation: state.continuation.reset(),
169            ..state
170        },
171        PipelineEvent::DevelopmentIterationCompleted {
172            iteration,
173            output_valid,
174        } => {
175            if output_valid {
176                // After a successful dev iteration, go to CommitMessage phase to create a commit.
177                PipelineState {
178                    phase: super::event::PipelinePhase::CommitMessage,
179                    previous_phase: Some(super::event::PipelinePhase::Development),
180                    iteration,
181                    commit: super::state::CommitState::NotStarted,
182                    context_cleaned: false,
183                    // Reset continuation state on successful completion
184                    continuation: ContinuationState::new(),
185                    ..state
186                }
187            } else {
188                // Output was not valid enough to proceed to commit; stay in Development.
189                PipelineState {
190                    phase: super::event::PipelinePhase::Development,
191                    iteration,
192                    ..state
193                }
194            }
195        }
196        PipelineEvent::DevelopmentPhaseCompleted => PipelineState {
197            phase: super::event::PipelinePhase::Review,
198            // Reset continuation state when phase completes
199            continuation: ContinuationState::new(),
200            ..state
201        },
202        PipelineEvent::DevelopmentIterationContinuationTriggered {
203            iteration,
204            status,
205            summary,
206            files_changed,
207            next_steps,
208        } => {
209            // Trigger continuation with context from the previous attempt
210            PipelineState {
211                iteration,
212                continuation: state.continuation.trigger_continuation(
213                    status,
214                    summary,
215                    files_changed,
216                    next_steps,
217                ),
218                ..state
219            }
220        }
221        PipelineEvent::DevelopmentIterationContinuationSucceeded {
222            iteration,
223            total_continuation_attempts: _,
224        } => {
225            // Continuation succeeded; proceed to CommitMessage and reset continuation state.
226            PipelineState {
227                phase: super::event::PipelinePhase::CommitMessage,
228                previous_phase: Some(super::event::PipelinePhase::Development),
229                iteration,
230                commit: super::state::CommitState::NotStarted,
231                context_cleaned: false,
232                continuation: ContinuationState::new(),
233                ..state
234            }
235        }
236        _ => state,
237    }
238}
239
240/// Handle review phase events.
241fn reduce_review_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
242    match event {
243        PipelineEvent::ReviewPhaseStarted => PipelineState {
244            phase: super::event::PipelinePhase::Review,
245            reviewer_pass: 0,
246            review_issues_found: false,
247            ..state
248        },
249        PipelineEvent::ReviewPassStarted { pass } => PipelineState {
250            reviewer_pass: pass,
251            review_issues_found: false,
252            agent_chain: state.agent_chain.reset(),
253            ..state
254        },
255        PipelineEvent::ReviewCompleted { pass, issues_found } => {
256            let next_pass = if issues_found { pass } else { pass + 1 };
257            let next_phase = if !issues_found && next_pass >= state.total_reviewer_passes {
258                super::event::PipelinePhase::CommitMessage
259            } else {
260                state.phase
261            };
262            PipelineState {
263                phase: next_phase,
264                reviewer_pass: next_pass,
265                review_issues_found: issues_found,
266                ..state
267            }
268        }
269        PipelineEvent::FixAttemptStarted { .. } => PipelineState {
270            agent_chain: state.agent_chain.reset(),
271            ..state
272        },
273        PipelineEvent::FixAttemptCompleted { pass, .. } => PipelineState {
274            phase: super::event::PipelinePhase::CommitMessage,
275            previous_phase: Some(super::event::PipelinePhase::Review),
276            reviewer_pass: pass,
277            review_issues_found: false,
278            commit: super::state::CommitState::NotStarted,
279            ..state
280        },
281        PipelineEvent::ReviewPhaseCompleted { .. } => PipelineState {
282            phase: super::event::PipelinePhase::CommitMessage,
283            ..state
284        },
285        _ => state,
286    }
287}
288
289/// Handle agent-related events.
290fn reduce_agent_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
291    match event {
292        PipelineEvent::AgentInvocationStarted { .. } => state,
293        // Clear continuation prompt on success
294        PipelineEvent::AgentInvocationSucceeded { .. } => PipelineState {
295            agent_chain: state.agent_chain.clear_continuation_prompt(),
296            ..state
297        },
298        // Rate limit (429): immediate agent fallback, preserve prompt context
299        // Unlike other retriable errors, rate limits indicate the provider is
300        // temporarily exhausted, so we switch to the next agent immediately
301        // to continue work without delay.
302        PipelineEvent::AgentRateLimitFallback { prompt_context, .. } => PipelineState {
303            agent_chain: state
304                .agent_chain
305                .switch_to_next_agent_with_prompt(prompt_context),
306            ..state
307        },
308        // Other retriable errors (Network, Timeout): try next model
309        PipelineEvent::AgentInvocationFailed {
310            retriable: true, ..
311        } => PipelineState {
312            agent_chain: state.agent_chain.advance_to_next_model(),
313            ..state
314        },
315        // Non-retriable errors: switch agent
316        PipelineEvent::AgentInvocationFailed {
317            retriable: false, ..
318        } => PipelineState {
319            agent_chain: state.agent_chain.switch_to_next_agent(),
320            ..state
321        },
322        PipelineEvent::AgentFallbackTriggered { .. } => PipelineState {
323            agent_chain: state.agent_chain.switch_to_next_agent(),
324            ..state
325        },
326        PipelineEvent::AgentChainExhausted { .. } => PipelineState {
327            agent_chain: state.agent_chain.start_retry_cycle(),
328            ..state
329        },
330        PipelineEvent::AgentModelFallbackTriggered { .. } => PipelineState {
331            agent_chain: state.agent_chain.advance_to_next_model(),
332            ..state
333        },
334        PipelineEvent::AgentRetryCycleStarted { .. } => state,
335        PipelineEvent::AgentChainInitialized { role, agents } => {
336            let models_per_agent = agents.iter().map(|_| vec![]).collect();
337            PipelineState {
338                agent_chain: state
339                    .agent_chain
340                    .with_agents(agents, models_per_agent, role)
341                    .reset_for_role(role),
342                ..state
343            }
344        }
345        _ => state,
346    }
347}
348
349/// Handle rebase-related events.
350fn reduce_rebase_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
351    match event {
352        PipelineEvent::RebaseStarted {
353            target_branch,
354            phase: _,
355        } => PipelineState {
356            rebase: RebaseState::InProgress {
357                original_head: state.current_head(),
358                target_branch,
359            },
360            ..state
361        },
362        PipelineEvent::RebaseConflictDetected { files } => PipelineState {
363            rebase: match &state.rebase {
364                RebaseState::InProgress {
365                    original_head,
366                    target_branch,
367                } => RebaseState::Conflicted {
368                    original_head: original_head.clone(),
369                    target_branch: target_branch.clone(),
370                    files,
371                    resolution_attempts: 0,
372                },
373                _ => state.rebase.clone(),
374            },
375            ..state
376        },
377        PipelineEvent::RebaseConflictResolved { .. } => PipelineState {
378            rebase: match &state.rebase {
379                RebaseState::Conflicted {
380                    original_head,
381                    target_branch,
382                    ..
383                } => RebaseState::InProgress {
384                    original_head: original_head.clone(),
385                    target_branch: target_branch.clone(),
386                },
387                _ => state.rebase.clone(),
388            },
389            ..state
390        },
391        PipelineEvent::RebaseSucceeded { new_head, .. } => PipelineState {
392            rebase: RebaseState::Completed { new_head },
393            ..state
394        },
395        PipelineEvent::RebaseFailed { .. } => PipelineState {
396            rebase: RebaseState::NotStarted,
397            ..state
398        },
399        PipelineEvent::RebaseSkipped { .. } => PipelineState {
400            rebase: RebaseState::Skipped,
401            ..state
402        },
403        PipelineEvent::RebaseAborted { .. } => state,
404        _ => state,
405    }
406}
407
408/// Handle commit-related events.
409fn reduce_commit_event(state: PipelineState, event: PipelineEvent) -> PipelineState {
410    match event {
411        PipelineEvent::CommitGenerationStarted => PipelineState {
412            commit: CommitState::Generating {
413                attempt: 1,
414                max_attempts: super::state::MAX_VALIDATION_RETRY_ATTEMPTS,
415            },
416            ..state
417        },
418        PipelineEvent::CommitMessageGenerated { message, .. } => PipelineState {
419            commit: CommitState::Generated { message },
420            ..state
421        },
422        PipelineEvent::CommitCreated { hash, .. } => {
423            let (next_phase, next_iter, next_reviewer_pass) =
424                compute_post_commit_transition(&state);
425            PipelineState {
426                commit: CommitState::Committed { hash },
427                phase: next_phase,
428                previous_phase: None,
429                iteration: next_iter,
430                reviewer_pass: next_reviewer_pass,
431                context_cleaned: false,
432                ..state
433            }
434        }
435        PipelineEvent::CommitGenerationFailed { .. } => PipelineState {
436            commit: CommitState::NotStarted,
437            ..state
438        },
439        PipelineEvent::CommitSkipped { .. } => {
440            let (next_phase, next_iter, next_reviewer_pass) =
441                compute_post_commit_transition(&state);
442            PipelineState {
443                commit: CommitState::Skipped,
444                phase: next_phase,
445                previous_phase: None,
446                iteration: next_iter,
447                reviewer_pass: next_reviewer_pass,
448                context_cleaned: false,
449                ..state
450            }
451        }
452        PipelineEvent::CommitMessageValidationFailed { attempt, .. } => {
453            reduce_commit_validation_failed(state, attempt)
454        }
455        _ => state,
456    }
457}
458
459/// Compute phase transition after a commit (used by CommitCreated and CommitSkipped).
460fn compute_post_commit_transition(
461    state: &PipelineState,
462) -> (super::event::PipelinePhase, u32, u32) {
463    match state.previous_phase {
464        Some(super::event::PipelinePhase::Development) => {
465            let next_iter = state.iteration + 1;
466            if next_iter >= state.total_iterations {
467                (
468                    super::event::PipelinePhase::Review,
469                    next_iter,
470                    state.reviewer_pass,
471                )
472            } else {
473                (
474                    super::event::PipelinePhase::Planning,
475                    next_iter,
476                    state.reviewer_pass,
477                )
478            }
479        }
480        Some(super::event::PipelinePhase::Review) => {
481            let next_pass = state.reviewer_pass + 1;
482            if next_pass >= state.total_reviewer_passes {
483                (
484                    super::event::PipelinePhase::FinalValidation,
485                    state.iteration,
486                    next_pass,
487                )
488            } else {
489                (
490                    super::event::PipelinePhase::Review,
491                    state.iteration,
492                    next_pass,
493                )
494            }
495        }
496        _ => (
497            super::event::PipelinePhase::FinalValidation,
498            state.iteration,
499            state.reviewer_pass,
500        ),
501    }
502}
503
504/// Handle commit message validation failure with retry logic.
505fn reduce_commit_validation_failed(state: PipelineState, attempt: u32) -> PipelineState {
506    let next_attempt = attempt + 1;
507    let max_attempts = super::state::MAX_VALIDATION_RETRY_ATTEMPTS;
508
509    if next_attempt <= max_attempts {
510        PipelineState {
511            commit: CommitState::Generating {
512                attempt: next_attempt,
513                max_attempts,
514            },
515            ..state
516        }
517    } else {
518        // Exceeded max attempts with current agent - try next agent
519        let old_agent_index = state.agent_chain.current_agent_index;
520        let old_retry_cycle = state.agent_chain.retry_cycle;
521        let new_agent_chain = state.agent_chain.switch_to_next_agent();
522
523        let wrapped_around = new_agent_chain.retry_cycle > old_retry_cycle;
524        let advanced_to_next =
525            new_agent_chain.current_agent_index != old_agent_index && !wrapped_around;
526
527        if advanced_to_next {
528            PipelineState {
529                agent_chain: new_agent_chain,
530                commit: CommitState::Generating {
531                    attempt: 1,
532                    max_attempts,
533                },
534                ..state
535            }
536        } else {
537            // All agents exhausted - reset so orchestration can handle
538            PipelineState {
539                agent_chain: new_agent_chain,
540                commit: CommitState::NotStarted,
541                ..state
542            }
543        }
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::agents::AgentRole;
551    use crate::reducer::event::AgentErrorKind;
552    use crate::reducer::event::PipelinePhase;
553    use crate::reducer::event::RebasePhase;
554    use crate::reducer::state::AgentChainState;
555
556    fn create_test_state() -> PipelineState {
557        PipelineState {
558            agent_chain: AgentChainState::initial().with_agents(
559                vec!["agent1".to_string(), "agent2".to_string()],
560                vec![vec!["model1".to_string(), "model2".to_string()]],
561                AgentRole::Developer,
562            ),
563            ..PipelineState::initial(5, 2)
564        }
565    }
566
567    #[test]
568    fn test_reduce_pipeline_started() {
569        let state = create_test_state();
570        let new_state = reduce(state, PipelineEvent::PipelineStarted);
571        assert_eq!(new_state.phase, PipelinePhase::Planning);
572    }
573
574    #[test]
575    fn test_reduce_pipeline_completed() {
576        let state = create_test_state();
577        let new_state = reduce(state, PipelineEvent::PipelineCompleted);
578        assert_eq!(new_state.phase, PipelinePhase::Complete);
579    }
580
581    #[test]
582    fn test_reduce_development_iteration_completed() {
583        // DevelopmentIterationCompleted transitions to CommitMessage phase
584        // The iteration counter stays the same; it gets incremented by CommitCreated
585        let state = PipelineState {
586            phase: PipelinePhase::Development,
587            iteration: 2,
588            total_iterations: 5,
589            ..create_test_state()
590        };
591        let new_state = reduce(
592            state,
593            PipelineEvent::DevelopmentIterationCompleted {
594                iteration: 2,
595                output_valid: true,
596            },
597        );
598        // Iteration stays at 2 (incremented by CommitCreated later)
599        assert_eq!(new_state.iteration, 2);
600        // Goes to CommitMessage phase to create a commit
601        assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
602        // Previous phase stored for return after commit
603        assert_eq!(new_state.previous_phase, Some(PipelinePhase::Development));
604    }
605
606    #[test]
607    fn test_reduce_development_iteration_complete_goes_to_commit() {
608        // Even on last iteration, DevelopmentIterationCompleted goes to CommitMessage
609        // The transition to Review happens after CommitCreated
610        let state = PipelineState {
611            phase: PipelinePhase::Development,
612            iteration: 5,
613            total_iterations: 5,
614            ..create_test_state()
615        };
616        let new_state = reduce(
617            state,
618            PipelineEvent::DevelopmentIterationCompleted {
619                iteration: 5,
620                output_valid: true,
621            },
622        );
623        // Iteration stays at 5 (incremented by CommitCreated later)
624        assert_eq!(new_state.iteration, 5);
625        // Goes to CommitMessage phase first
626        assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
627    }
628
629    #[test]
630    fn test_plan_generation_completed_invalid_does_not_transition_to_development() {
631        let state = PipelineState {
632            phase: PipelinePhase::Planning,
633            ..create_test_state()
634        };
635
636        let new_state = reduce(
637            state,
638            PipelineEvent::PlanGenerationCompleted {
639                iteration: 1,
640                valid: false,
641            },
642        );
643
644        assert_eq!(
645            new_state.phase,
646            PipelinePhase::Planning,
647            "Invalid plan should keep pipeline in Planning phase"
648        );
649    }
650
651    #[test]
652    fn test_reduce_agent_fallback_to_next_model() {
653        let state = create_test_state();
654        let initial_agent = state.agent_chain.current_agent().unwrap().clone();
655        let initial_model_index = state.agent_chain.current_model_index;
656
657        let new_state = reduce(
658            state,
659            PipelineEvent::AgentInvocationFailed {
660                role: AgentRole::Developer,
661                agent: initial_agent.clone(),
662                exit_code: 1,
663                error_kind: AgentErrorKind::Network,
664                retriable: true,
665            },
666        );
667
668        assert_ne!(
669            new_state.agent_chain.current_model_index,
670            initial_model_index
671        );
672    }
673
674    #[test]
675    fn test_reduce_rebase_started() {
676        let state = create_test_state();
677        let new_state = reduce(
678            state,
679            PipelineEvent::RebaseStarted {
680                phase: RebasePhase::Initial,
681                target_branch: "main".to_string(),
682            },
683        );
684
685        assert!(matches!(new_state.rebase, RebaseState::InProgress { .. }));
686    }
687
688    #[test]
689    fn test_reduce_rebase_succeeded() {
690        let state = create_test_state();
691        let new_state = reduce(
692            state,
693            PipelineEvent::RebaseSucceeded {
694                phase: RebasePhase::Initial,
695                new_head: "abc123".to_string(),
696            },
697        );
698
699        assert!(matches!(new_state.rebase, RebaseState::Completed { .. }));
700    }
701
702    #[test]
703    fn test_reduce_commit_generation_started() {
704        let state = create_test_state();
705        let new_state = reduce(state, PipelineEvent::CommitGenerationStarted);
706
707        assert!(matches!(new_state.commit, CommitState::Generating { .. }));
708    }
709
710    #[test]
711    fn test_reduce_commit_created() {
712        let state = create_test_state();
713        let new_state = reduce(
714            state,
715            PipelineEvent::CommitCreated {
716                hash: "abc123".to_string(),
717                message: "test commit".to_string(),
718            },
719        );
720
721        assert!(matches!(new_state.commit, CommitState::Committed { .. }));
722        assert_eq!(new_state.phase, PipelinePhase::FinalValidation);
723    }
724
725    #[test]
726    fn test_reduce_all_agent_failure_scenarios() {
727        let state = create_test_state();
728        let initial_agent_index = state.agent_chain.current_agent_index;
729        let initial_model_index = state.agent_chain.current_model_index;
730        let agent_name = state.agent_chain.current_agent().unwrap().clone();
731
732        let network_error_state = reduce(
733            state.clone(),
734            PipelineEvent::AgentInvocationFailed {
735                role: AgentRole::Developer,
736                agent: agent_name.clone(),
737                exit_code: 1,
738                error_kind: AgentErrorKind::Network,
739                retriable: true,
740            },
741        );
742        assert_eq!(
743            network_error_state.agent_chain.current_agent_index,
744            initial_agent_index
745        );
746        assert!(network_error_state.agent_chain.current_model_index > initial_model_index);
747
748        let auth_error_state = reduce(
749            state.clone(),
750            PipelineEvent::AgentInvocationFailed {
751                role: AgentRole::Developer,
752                agent: agent_name.clone(),
753                exit_code: 1,
754                error_kind: AgentErrorKind::Authentication,
755                retriable: false,
756            },
757        );
758        assert!(auth_error_state.agent_chain.current_agent_index > initial_agent_index);
759        assert_eq!(
760            auth_error_state.agent_chain.current_model_index,
761            initial_model_index
762        );
763
764        let internal_error_state = reduce(
765            state,
766            PipelineEvent::AgentInvocationFailed {
767                role: AgentRole::Developer,
768                agent: agent_name,
769                exit_code: 139,
770                error_kind: AgentErrorKind::InternalError,
771                retriable: false,
772            },
773        );
774        assert!(internal_error_state.agent_chain.current_agent_index > initial_agent_index);
775    }
776
777    #[test]
778    fn test_reduce_rebase_full_state_machine() {
779        let mut state = create_test_state();
780
781        state = reduce(
782            state,
783            PipelineEvent::RebaseStarted {
784                phase: RebasePhase::Initial,
785                target_branch: "main".to_string(),
786            },
787        );
788        assert!(matches!(state.rebase, RebaseState::InProgress { .. }));
789
790        state = reduce(
791            state,
792            PipelineEvent::RebaseConflictDetected {
793                files: vec![std::path::PathBuf::from("file1.txt")],
794            },
795        );
796        assert!(matches!(state.rebase, RebaseState::Conflicted { .. }));
797
798        state = reduce(
799            state,
800            PipelineEvent::RebaseConflictResolved {
801                files: vec![std::path::PathBuf::from("file1.txt")],
802            },
803        );
804        assert!(matches!(state.rebase, RebaseState::InProgress { .. }));
805
806        state = reduce(
807            state,
808            PipelineEvent::RebaseSucceeded {
809                phase: RebasePhase::Initial,
810                new_head: "def456".to_string(),
811            },
812        );
813        assert!(matches!(state.rebase, RebaseState::Completed { .. }));
814    }
815
816    #[test]
817    fn test_reduce_commit_full_state_machine() {
818        let mut state = create_test_state();
819
820        state = reduce(state, PipelineEvent::CommitGenerationStarted);
821        assert!(matches!(state.commit, CommitState::Generating { .. }));
822
823        state = reduce(
824            state,
825            PipelineEvent::CommitCreated {
826                hash: "abc123".to_string(),
827                message: "test commit".to_string(),
828            },
829        );
830        assert!(matches!(state.commit, CommitState::Committed { .. }));
831    }
832
833    #[test]
834    fn test_reduce_phase_transitions() {
835        let mut state = create_test_state();
836
837        state = reduce(state, PipelineEvent::PlanningPhaseCompleted);
838        assert_eq!(state.phase, PipelinePhase::Development);
839
840        state = reduce(state, PipelineEvent::DevelopmentPhaseStarted);
841        assert_eq!(state.phase, PipelinePhase::Development);
842
843        state = reduce(state, PipelineEvent::DevelopmentPhaseCompleted);
844        assert_eq!(state.phase, PipelinePhase::Review);
845
846        state = reduce(state, PipelineEvent::ReviewPhaseStarted);
847        assert_eq!(state.phase, PipelinePhase::Review);
848
849        state = reduce(
850            state,
851            PipelineEvent::ReviewPhaseCompleted { early_exit: false },
852        );
853        assert_eq!(state.phase, PipelinePhase::CommitMessage);
854    }
855
856    #[test]
857    fn test_reduce_agent_chain_exhaustion() {
858        let state = PipelineState {
859            agent_chain: AgentChainState::initial()
860                .with_agents(
861                    vec!["agent1".to_string()],
862                    vec![vec!["model1".to_string()]],
863                    AgentRole::Developer,
864                )
865                .with_max_cycles(3),
866            ..create_test_state()
867        };
868
869        let exhausted_state = reduce(
870            state,
871            PipelineEvent::AgentChainExhausted {
872                role: AgentRole::Developer,
873            },
874        );
875
876        assert_eq!(exhausted_state.agent_chain.current_agent_index, 0);
877        assert_eq!(exhausted_state.agent_chain.current_model_index, 0);
878        assert_eq!(exhausted_state.agent_chain.retry_cycle, 1);
879    }
880
881    #[test]
882    fn test_reduce_agent_fallback_triggers_fallback_event() {
883        let state = create_test_state();
884        let agent = state.agent_chain.current_agent().unwrap().clone();
885
886        let new_state = reduce(
887            state,
888            PipelineEvent::AgentInvocationFailed {
889                role: AgentRole::Developer,
890                agent: agent.clone(),
891                exit_code: 1,
892                error_kind: AgentErrorKind::Authentication,
893                retriable: false,
894            },
895        );
896
897        assert!(new_state.agent_chain.current_agent_index > 0);
898    }
899
900    #[test]
901    fn test_reduce_model_fallback_triggers_for_network_error() {
902        let state = create_test_state();
903        let initial_model_index = state.agent_chain.current_model_index;
904        let agent_name = state.agent_chain.current_agent().unwrap().clone();
905
906        let new_state = reduce(
907            state,
908            PipelineEvent::AgentInvocationFailed {
909                role: AgentRole::Developer,
910                agent: agent_name,
911                exit_code: 1,
912                error_kind: AgentErrorKind::Network,
913                retriable: true,
914            },
915        );
916
917        assert!(new_state.agent_chain.current_model_index > initial_model_index);
918    }
919
920    #[test]
921    fn test_rate_limit_fallback_switches_agent() {
922        let state = create_test_state();
923        let initial_agent_index = state.agent_chain.current_agent_index;
924
925        let new_state = reduce(
926            state,
927            PipelineEvent::AgentRateLimitFallback {
928                role: AgentRole::Developer,
929                agent: "agent1".to_string(),
930                prompt_context: Some("test prompt".to_string()),
931            },
932        );
933
934        // Should switch to next agent
935        assert!(
936            new_state.agent_chain.current_agent_index > initial_agent_index,
937            "Rate limit should trigger agent fallback, not model fallback"
938        );
939        // Should preserve prompt
940        assert_eq!(
941            new_state.agent_chain.rate_limit_continuation_prompt,
942            Some("test prompt".to_string())
943        );
944    }
945
946    #[test]
947    fn test_rate_limit_fallback_with_no_prompt_context() {
948        let state = create_test_state();
949        let initial_agent_index = state.agent_chain.current_agent_index;
950
951        let new_state = reduce(
952            state,
953            PipelineEvent::AgentRateLimitFallback {
954                role: AgentRole::Developer,
955                agent: "agent1".to_string(),
956                prompt_context: None,
957            },
958        );
959
960        // Should still switch to next agent
961        assert!(new_state.agent_chain.current_agent_index > initial_agent_index);
962        // Prompt context should be None
963        assert!(new_state
964            .agent_chain
965            .rate_limit_continuation_prompt
966            .is_none());
967    }
968
969    #[test]
970    fn test_success_clears_rate_limit_continuation_prompt() {
971        let mut state = create_test_state();
972        state.agent_chain.rate_limit_continuation_prompt = Some("old prompt".to_string());
973
974        let new_state = reduce(
975            state,
976            PipelineEvent::AgentInvocationSucceeded {
977                role: AgentRole::Developer,
978                agent: "agent1".to_string(),
979            },
980        );
981
982        assert!(
983            new_state
984                .agent_chain
985                .rate_limit_continuation_prompt
986                .is_none(),
987            "Success should clear rate limit continuation prompt"
988        );
989    }
990
991    #[test]
992    fn test_reduce_finalizing_started() {
993        let state = PipelineState {
994            phase: PipelinePhase::FinalValidation,
995            ..create_test_state()
996        };
997        let new_state = reduce(state, PipelineEvent::FinalizingStarted);
998        assert_eq!(new_state.phase, PipelinePhase::Finalizing);
999    }
1000
1001    #[test]
1002    fn test_reduce_prompt_permissions_restored() {
1003        let state = PipelineState {
1004            phase: PipelinePhase::Finalizing,
1005            ..create_test_state()
1006        };
1007        let new_state = reduce(state, PipelineEvent::PromptPermissionsRestored);
1008        assert_eq!(new_state.phase, PipelinePhase::Complete);
1009    }
1010
1011    #[test]
1012    fn test_reduce_finalization_full_flow() {
1013        let mut state = PipelineState {
1014            phase: PipelinePhase::FinalValidation,
1015            ..create_test_state()
1016        };
1017
1018        // FinalValidation -> Finalizing
1019        state = reduce(state, PipelineEvent::FinalizingStarted);
1020        assert_eq!(state.phase, PipelinePhase::Finalizing);
1021
1022        // Finalizing -> Complete
1023        state = reduce(state, PipelineEvent::PromptPermissionsRestored);
1024        assert_eq!(state.phase, PipelinePhase::Complete);
1025    }
1026
1027    /// Test the complete finalization flow from FinalValidation through effects.
1028    ///
1029    /// This tests the orchestration + reduction path:
1030    /// 1. FinalValidation phase -> ValidateFinalState effect
1031    /// 2. ValidateFinalState effect -> FinalizingStarted event
1032    /// 3. FinalizingStarted event -> Finalizing phase
1033    /// 4. Finalizing phase -> RestorePromptPermissions effect
1034    /// 5. RestorePromptPermissions effect -> PromptPermissionsRestored event
1035    /// 6. PromptPermissionsRestored event -> Complete phase
1036    #[test]
1037    fn test_finalization_orchestration_integration() {
1038        use crate::reducer::mock_effect_handler::MockEffectHandler;
1039        use crate::reducer::orchestration::determine_next_effect;
1040
1041        // Start in FinalValidation
1042        let initial_state = PipelineState {
1043            phase: PipelinePhase::FinalValidation,
1044            ..PipelineState::initial(5, 2)
1045        };
1046
1047        let mut handler = MockEffectHandler::new(initial_state.clone());
1048
1049        // Step 1: Determine effect for FinalValidation
1050        let effect1 = determine_next_effect(&initial_state);
1051        assert!(
1052            matches!(effect1, crate::reducer::effect::Effect::ValidateFinalState),
1053            "FinalValidation should emit ValidateFinalState effect"
1054        );
1055
1056        // Step 2: Execute effect, get event
1057        let result1 = handler.execute_mock(effect1);
1058        assert!(
1059            matches!(result1.event, PipelineEvent::FinalizingStarted),
1060            "ValidateFinalState should return FinalizingStarted"
1061        );
1062
1063        // Step 3: Reduce state with event
1064        let state2 = reduce(initial_state, result1.event);
1065        assert_eq!(state2.phase, PipelinePhase::Finalizing);
1066        assert!(!state2.is_complete(), "Finalizing should not be complete");
1067
1068        // Step 4: Determine effect for Finalizing
1069        let effect2 = determine_next_effect(&state2);
1070        assert!(
1071            matches!(
1072                effect2,
1073                crate::reducer::effect::Effect::RestorePromptPermissions
1074            ),
1075            "Finalizing should emit RestorePromptPermissions effect"
1076        );
1077
1078        // Step 5: Execute effect, get event
1079        let result2 = handler.execute_mock(effect2);
1080        assert!(
1081            matches!(result2.event, PipelineEvent::PromptPermissionsRestored),
1082            "RestorePromptPermissions should return PromptPermissionsRestored"
1083        );
1084
1085        // Step 6: Reduce state with event
1086        let final_state = reduce(state2, result2.event);
1087        assert_eq!(final_state.phase, PipelinePhase::Complete);
1088        assert!(final_state.is_complete(), "Complete should be complete");
1089
1090        // Verify effects were captured
1091        let effects = handler.captured_effects();
1092        assert_eq!(effects.len(), 2);
1093        assert!(matches!(
1094            effects[0],
1095            crate::reducer::effect::Effect::ValidateFinalState
1096        ));
1097        assert!(matches!(
1098            effects[1],
1099            crate::reducer::effect::Effect::RestorePromptPermissions
1100        ));
1101    }
1102
1103    // =========================================================================
1104    // Continuation event handling tests
1105    // =========================================================================
1106
1107    #[test]
1108    fn test_continuation_triggered_updates_state() {
1109        use crate::reducer::state::DevelopmentStatus;
1110
1111        let state = create_test_state();
1112        let new_state = reduce(
1113            state,
1114            PipelineEvent::DevelopmentIterationContinuationTriggered {
1115                iteration: 1,
1116                status: DevelopmentStatus::Partial,
1117                summary: "Did work".to_string(),
1118                files_changed: Some(vec!["src/main.rs".to_string()]),
1119                next_steps: Some("Continue".to_string()),
1120            },
1121        );
1122
1123        assert!(new_state.continuation.is_continuation());
1124        assert_eq!(
1125            new_state.continuation.previous_status,
1126            Some(DevelopmentStatus::Partial)
1127        );
1128        assert_eq!(
1129            new_state.continuation.previous_summary,
1130            Some("Did work".to_string())
1131        );
1132        assert_eq!(
1133            new_state.continuation.previous_files_changed,
1134            Some(vec!["src/main.rs".to_string()])
1135        );
1136        assert_eq!(
1137            new_state.continuation.previous_next_steps,
1138            Some("Continue".to_string())
1139        );
1140        assert_eq!(new_state.continuation.continuation_attempt, 1);
1141    }
1142
1143    #[test]
1144    fn test_continuation_triggered_sets_iteration_from_event() {
1145        use crate::reducer::state::DevelopmentStatus;
1146
1147        let state = PipelineState {
1148            iteration: 99,
1149            ..create_test_state()
1150        };
1151
1152        let new_state = reduce(
1153            state,
1154            PipelineEvent::DevelopmentIterationContinuationTriggered {
1155                iteration: 2,
1156                status: DevelopmentStatus::Partial,
1157                summary: "Did work".to_string(),
1158                files_changed: None,
1159                next_steps: None,
1160            },
1161        );
1162
1163        assert_eq!(new_state.iteration, 2);
1164    }
1165
1166    #[test]
1167    fn test_continuation_triggered_with_failed_status() {
1168        use crate::reducer::state::DevelopmentStatus;
1169
1170        let state = create_test_state();
1171        let new_state = reduce(
1172            state,
1173            PipelineEvent::DevelopmentIterationContinuationTriggered {
1174                iteration: 1,
1175                status: DevelopmentStatus::Failed,
1176                summary: "Build failed".to_string(),
1177                files_changed: None,
1178                next_steps: Some("Fix errors".to_string()),
1179            },
1180        );
1181
1182        assert!(new_state.continuation.is_continuation());
1183        assert_eq!(
1184            new_state.continuation.previous_status,
1185            Some(DevelopmentStatus::Failed)
1186        );
1187        assert_eq!(
1188            new_state.continuation.previous_summary,
1189            Some("Build failed".to_string())
1190        );
1191        assert!(new_state.continuation.previous_files_changed.is_none());
1192    }
1193
1194    #[test]
1195    fn test_continuation_succeeded_resets_state() {
1196        use crate::reducer::state::{ContinuationState, DevelopmentStatus};
1197
1198        let mut state = create_test_state();
1199        state.continuation = ContinuationState::new().trigger_continuation(
1200            DevelopmentStatus::Partial,
1201            "Work".to_string(),
1202            None,
1203            None,
1204        );
1205        assert!(state.continuation.is_continuation());
1206
1207        let new_state = reduce(
1208            state,
1209            PipelineEvent::DevelopmentIterationContinuationSucceeded {
1210                iteration: 1,
1211                total_continuation_attempts: 2,
1212            },
1213        );
1214
1215        assert!(!new_state.continuation.is_continuation());
1216        assert_eq!(new_state.continuation.continuation_attempt, 0);
1217        assert!(new_state.continuation.previous_status.is_none());
1218    }
1219
1220    #[test]
1221    fn test_continuation_succeeded_sets_iteration_from_event() {
1222        use crate::reducer::state::{ContinuationState, DevelopmentStatus};
1223
1224        let mut state = PipelineState {
1225            phase: PipelinePhase::Development,
1226            iteration: 99,
1227            ..create_test_state()
1228        };
1229        state.continuation = ContinuationState::new().trigger_continuation(
1230            DevelopmentStatus::Partial,
1231            "Work".to_string(),
1232            None,
1233            None,
1234        );
1235
1236        let new_state = reduce(
1237            state,
1238            PipelineEvent::DevelopmentIterationContinuationSucceeded {
1239                iteration: 1,
1240                total_continuation_attempts: 1,
1241            },
1242        );
1243
1244        assert_eq!(new_state.iteration, 1);
1245    }
1246
1247    #[test]
1248    fn test_iteration_started_resets_continuation() {
1249        use crate::reducer::state::{ContinuationState, DevelopmentStatus};
1250
1251        let mut state = create_test_state();
1252        state.continuation = ContinuationState::new().trigger_continuation(
1253            DevelopmentStatus::Partial,
1254            "Work".to_string(),
1255            None,
1256            None,
1257        );
1258        assert!(state.continuation.is_continuation());
1259
1260        let new_state = reduce(
1261            state,
1262            PipelineEvent::DevelopmentIterationStarted { iteration: 2 },
1263        );
1264
1265        assert!(!new_state.continuation.is_continuation());
1266        assert_eq!(new_state.iteration, 2);
1267    }
1268
1269    #[test]
1270    fn test_iteration_completed_resets_continuation() {
1271        use crate::reducer::state::{ContinuationState, DevelopmentStatus};
1272
1273        let mut state = create_test_state();
1274        state.phase = PipelinePhase::Development;
1275        state.continuation = ContinuationState::new().trigger_continuation(
1276            DevelopmentStatus::Partial,
1277            "Work".to_string(),
1278            None,
1279            None,
1280        );
1281
1282        let new_state = reduce(
1283            state,
1284            PipelineEvent::DevelopmentIterationCompleted {
1285                iteration: 1,
1286                output_valid: true,
1287            },
1288        );
1289
1290        assert!(!new_state.continuation.is_continuation());
1291        assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
1292    }
1293
1294    #[test]
1295    fn test_development_phase_completed_resets_continuation() {
1296        use crate::reducer::state::{ContinuationState, DevelopmentStatus};
1297
1298        let mut state = create_test_state();
1299        state.phase = PipelinePhase::Development;
1300        state.continuation = ContinuationState::new().trigger_continuation(
1301            DevelopmentStatus::Partial,
1302            "Work".to_string(),
1303            None,
1304            None,
1305        );
1306
1307        let new_state = reduce(state, PipelineEvent::DevelopmentPhaseCompleted);
1308
1309        assert!(!new_state.continuation.is_continuation());
1310        assert_eq!(new_state.phase, PipelinePhase::Review);
1311    }
1312
1313    #[test]
1314    fn test_multiple_continuation_triggers_accumulate() {
1315        use crate::reducer::state::DevelopmentStatus;
1316
1317        let state = create_test_state();
1318
1319        // First continuation
1320        let state = reduce(
1321            state,
1322            PipelineEvent::DevelopmentIterationContinuationTriggered {
1323                iteration: 1,
1324                status: DevelopmentStatus::Partial,
1325                summary: "First attempt".to_string(),
1326                files_changed: None,
1327                next_steps: None,
1328            },
1329        );
1330        assert_eq!(state.continuation.continuation_attempt, 1);
1331
1332        // Second continuation
1333        let state = reduce(
1334            state,
1335            PipelineEvent::DevelopmentIterationContinuationTriggered {
1336                iteration: 1,
1337                status: DevelopmentStatus::Partial,
1338                summary: "Second attempt".to_string(),
1339                files_changed: None,
1340                next_steps: None,
1341            },
1342        );
1343        assert_eq!(state.continuation.continuation_attempt, 2);
1344        assert_eq!(
1345            state.continuation.previous_summary,
1346            Some("Second attempt".to_string())
1347        );
1348    }
1349}