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
5use super::event::PipelineEvent;
6use super::state::{CommitState, PipelineState, RebaseState};
7
8/// Pure reducer - no side effects, exhaustive match.
9///
10/// Computes new state by applying an event to current state.
11/// This function has zero side effects - all state mutations are explicit.
12pub fn reduce(state: PipelineState, event: PipelineEvent) -> PipelineState {
13    match event {
14        PipelineEvent::PipelineStarted => state,
15
16        PipelineEvent::PipelineResumed { .. } => state,
17
18        PipelineEvent::PipelineCompleted => PipelineState {
19            phase: super::event::PipelinePhase::Complete,
20            ..state
21        },
22
23        PipelineEvent::PipelineAborted { .. } => PipelineState {
24            phase: super::event::PipelinePhase::Interrupted,
25            ..state
26        },
27
28        PipelineEvent::PlanningPhaseStarted => PipelineState {
29            phase: super::event::PipelinePhase::Planning,
30            ..state
31        },
32
33        PipelineEvent::PlanningPhaseCompleted => PipelineState {
34            phase: super::event::PipelinePhase::Development,
35            ..state
36        },
37
38        PipelineEvent::DevelopmentPhaseStarted => PipelineState {
39            phase: super::event::PipelinePhase::Development,
40            ..state
41        },
42
43        PipelineEvent::DevelopmentIterationStarted { iteration } => PipelineState {
44            iteration,
45            agent_chain: state.agent_chain.reset(),
46            ..state
47        },
48
49        PipelineEvent::PlanGenerationStarted { .. } => state,
50
51        PipelineEvent::PlanGenerationCompleted { .. } => PipelineState {
52            phase: super::event::PipelinePhase::Development,
53            ..state
54        },
55
56        PipelineEvent::DevelopmentIterationCompleted {
57            iteration,
58            output_valid: _output_valid,
59        } => {
60            // After dev iteration, go to CommitMessage phase to create a commit
61            // Store current phase so we can return after commit
62            PipelineState {
63                phase: super::event::PipelinePhase::CommitMessage,
64                previous_phase: Some(super::event::PipelinePhase::Development),
65                iteration,
66                commit: super::state::CommitState::NotStarted,
67                context_cleaned: false,
68                ..state
69            }
70        }
71
72        PipelineEvent::DevelopmentPhaseCompleted => PipelineState {
73            phase: super::event::PipelinePhase::Review,
74            ..state
75        },
76
77        PipelineEvent::ReviewPhaseStarted => PipelineState {
78            phase: super::event::PipelinePhase::Review,
79            reviewer_pass: 0,
80            review_issues_found: false,
81            ..state
82        },
83
84        PipelineEvent::ReviewPassStarted { pass } => PipelineState {
85            reviewer_pass: pass,
86            review_issues_found: false, // Reset at start of new review pass
87            agent_chain: state.agent_chain.reset(),
88            ..state
89        },
90
91        PipelineEvent::ReviewCompleted { pass, issues_found } => {
92            // If no issues found, increment to next pass
93            // If issues found, stay on same pass for fix attempt
94            let next_pass = if issues_found { pass } else { pass + 1 };
95
96            // If this was the last review pass and no issues, transition to CommitMessage
97            let next_phase = if !issues_found && next_pass >= state.total_reviewer_passes {
98                super::event::PipelinePhase::CommitMessage
99            } else {
100                state.phase
101            };
102
103            PipelineState {
104                phase: next_phase,
105                reviewer_pass: next_pass,
106                review_issues_found: issues_found,
107                ..state
108            }
109        }
110
111        PipelineEvent::FixAttemptStarted { .. } => PipelineState {
112            agent_chain: state.agent_chain.reset(),
113            ..state
114        },
115
116        PipelineEvent::FixAttemptCompleted { pass, .. } => {
117            // After fix attempt, go to CommitMessage phase to create a commit
118            // Store current phase so we can return after commit
119            PipelineState {
120                phase: super::event::PipelinePhase::CommitMessage,
121                previous_phase: Some(super::event::PipelinePhase::Review),
122                reviewer_pass: pass,
123                review_issues_found: false, // Reset flag after fix attempt
124                commit: super::state::CommitState::NotStarted,
125                ..state
126            }
127        }
128
129        PipelineEvent::ReviewPhaseCompleted { .. } => PipelineState {
130            phase: super::event::PipelinePhase::CommitMessage,
131            ..state
132        },
133
134        PipelineEvent::AgentInvocationFailed {
135            retriable: true, ..
136        } => PipelineState {
137            agent_chain: state.agent_chain.advance_to_next_model(),
138            ..state
139        },
140
141        PipelineEvent::AgentFallbackTriggered { to_agent: _, .. } => PipelineState {
142            agent_chain: state.agent_chain.switch_to_next_agent(),
143            ..state
144        },
145
146        PipelineEvent::AgentChainExhausted { .. } => PipelineState {
147            agent_chain: state.agent_chain.start_retry_cycle(),
148            ..state
149        },
150
151        PipelineEvent::RebaseStarted {
152            target_branch,
153            phase: _,
154        } => PipelineState {
155            rebase: RebaseState::InProgress {
156                original_head: state.current_head(),
157                target_branch,
158            },
159            ..state
160        },
161
162        PipelineEvent::RebaseConflictDetected { files } => PipelineState {
163            rebase: match &state.rebase {
164                RebaseState::InProgress {
165                    original_head,
166                    target_branch,
167                } => RebaseState::Conflicted {
168                    original_head: original_head.clone(),
169                    target_branch: target_branch.clone(),
170                    files,
171                    resolution_attempts: 0,
172                },
173                _ => state.rebase.clone(),
174            },
175            ..state
176        },
177
178        PipelineEvent::RebaseConflictResolved { files: _ } => PipelineState {
179            rebase: match &state.rebase {
180                RebaseState::Conflicted {
181                    original_head,
182                    target_branch,
183                    ..
184                } => RebaseState::InProgress {
185                    original_head: original_head.clone(),
186                    target_branch: target_branch.clone(),
187                },
188                _ => state.rebase.clone(),
189            },
190            ..state
191        },
192
193        PipelineEvent::RebaseSucceeded { new_head, phase: _ } => PipelineState {
194            rebase: RebaseState::Completed { new_head },
195            ..state
196        },
197
198        PipelineEvent::RebaseFailed { phase: _, .. } => PipelineState {
199            rebase: RebaseState::NotStarted,
200            ..state
201        },
202
203        PipelineEvent::RebaseSkipped { phase: _, .. } => PipelineState {
204            rebase: RebaseState::Skipped,
205            ..state
206        },
207
208        PipelineEvent::CommitGenerationStarted => PipelineState {
209            commit: CommitState::Generating {
210                attempt: 1,
211                max_attempts: super::state::MAX_VALIDATION_RETRY_ATTEMPTS,
212            },
213            ..state
214        },
215
216        PipelineEvent::CommitMessageGenerated { message, .. } => PipelineState {
217            commit: CommitState::Generated { message },
218            ..state
219        },
220
221        PipelineEvent::CommitCreated { hash, .. } => {
222            // After commit, return to Planning (next iteration) or Review, or FinalValidation
223            let (next_phase, next_iter, next_reviewer_pass) = match state.previous_phase {
224                Some(super::event::PipelinePhase::Development) => {
225                    let next_iter = state.iteration + 1;
226                    if next_iter >= state.total_iterations {
227                        // All dev iterations done, go to Review
228                        (
229                            super::event::PipelinePhase::Review,
230                            next_iter,
231                            state.reviewer_pass,
232                        )
233                    } else {
234                        // More iterations, go back to Planning for next iteration
235                        (
236                            super::event::PipelinePhase::Planning,
237                            next_iter,
238                            state.reviewer_pass,
239                        )
240                    }
241                }
242                Some(super::event::PipelinePhase::Review) => {
243                    let next_pass = state.reviewer_pass + 1;
244                    if next_pass >= state.total_reviewer_passes {
245                        // All review passes done, go to FinalValidation
246                        (
247                            super::event::PipelinePhase::FinalValidation,
248                            state.iteration,
249                            next_pass,
250                        )
251                    } else {
252                        // More review passes, stay in Review
253                        (
254                            super::event::PipelinePhase::Review,
255                            state.iteration,
256                            next_pass,
257                        )
258                    }
259                }
260                _ => {
261                    // Final commit (no previous phase), go to FinalValidation
262                    (
263                        super::event::PipelinePhase::FinalValidation,
264                        state.iteration,
265                        state.reviewer_pass,
266                    )
267                }
268            };
269
270            PipelineState {
271                commit: CommitState::Committed { hash },
272                phase: next_phase,
273                previous_phase: None,
274                iteration: next_iter,
275                reviewer_pass: next_reviewer_pass,
276                context_cleaned: false, // Reset so cleanup runs before next Planning/Review phase
277                ..state
278            }
279        }
280
281        PipelineEvent::CommitGenerationFailed { .. } => PipelineState {
282            commit: CommitState::NotStarted,
283            ..state
284        },
285
286        PipelineEvent::CommitSkipped { .. } => {
287            // Same logic as CommitCreated - respect previous_phase for proper flow
288            let (next_phase, next_iter, next_reviewer_pass) = match state.previous_phase {
289                Some(super::event::PipelinePhase::Development) => {
290                    let next_iter = state.iteration + 1;
291                    if next_iter >= state.total_iterations {
292                        // All dev iterations done, go to Review
293                        (
294                            super::event::PipelinePhase::Review,
295                            next_iter,
296                            state.reviewer_pass,
297                        )
298                    } else {
299                        // More iterations, go back to Planning for next iteration
300                        (
301                            super::event::PipelinePhase::Planning,
302                            next_iter,
303                            state.reviewer_pass,
304                        )
305                    }
306                }
307                Some(super::event::PipelinePhase::Review) => {
308                    let next_pass = state.reviewer_pass + 1;
309                    if next_pass >= state.total_reviewer_passes {
310                        // All review passes done, go to FinalValidation
311                        (
312                            super::event::PipelinePhase::FinalValidation,
313                            state.iteration,
314                            next_pass,
315                        )
316                    } else {
317                        // More review passes, stay in Review
318                        (
319                            super::event::PipelinePhase::Review,
320                            state.iteration,
321                            next_pass,
322                        )
323                    }
324                }
325                _ => {
326                    // Final commit (no previous phase), go to FinalValidation
327                    (
328                        super::event::PipelinePhase::FinalValidation,
329                        state.iteration,
330                        state.reviewer_pass,
331                    )
332                }
333            };
334
335            PipelineState {
336                commit: CommitState::Skipped,
337                phase: next_phase,
338                previous_phase: None,
339                iteration: next_iter,
340                reviewer_pass: next_reviewer_pass,
341                context_cleaned: false, // Reset so cleanup runs before next phase
342                ..state
343            }
344        }
345
346        PipelineEvent::ContextCleaned => PipelineState {
347            context_cleaned: true,
348            ..state
349        },
350
351        PipelineEvent::CheckpointSaved { .. } => state,
352
353        PipelineEvent::AgentInvocationStarted { .. } => state,
354        PipelineEvent::AgentInvocationSucceeded { .. } => state,
355        PipelineEvent::AgentInvocationFailed {
356            retriable: false, ..
357        } => PipelineState {
358            agent_chain: state.agent_chain.switch_to_next_agent(),
359            ..state
360        },
361        PipelineEvent::AgentModelFallbackTriggered { .. } => PipelineState {
362            agent_chain: state.agent_chain.advance_to_next_model(),
363            ..state
364        },
365        PipelineEvent::AgentRetryCycleStarted { .. } => state,
366        PipelineEvent::AgentChainInitialized { role, agents } => {
367            let models_per_agent = agents.iter().map(|_| vec![]).collect();
368
369            PipelineState {
370                agent_chain: state
371                    .agent_chain
372                    .with_agents(agents, models_per_agent, role)
373                    .reset_for_role(role),
374                ..state
375            }
376        }
377        PipelineEvent::RebaseAborted { .. } => state,
378
379        PipelineEvent::CommitMessageValidationFailed { attempt, .. } => {
380            // If we haven't exceeded max attempts, retry with same agent
381            let next_attempt = attempt + 1;
382            let max_attempts = super::state::MAX_VALIDATION_RETRY_ATTEMPTS;
383
384            if next_attempt <= max_attempts {
385                PipelineState {
386                    commit: CommitState::Generating {
387                        attempt: next_attempt,
388                        max_attempts,
389                    },
390                    ..state
391                }
392            } else {
393                // Exceeded max attempts with current agent - try next agent
394                let old_agent_index = state.agent_chain.current_agent_index;
395                let old_retry_cycle = state.agent_chain.retry_cycle;
396                let new_agent_chain = state.agent_chain.switch_to_next_agent();
397
398                // Check if we wrapped around (retry_cycle incremented = all agents exhausted)
399                let wrapped_around = new_agent_chain.retry_cycle > old_retry_cycle;
400
401                // Check if we're on a different agent (advanced successfully)
402                let advanced_to_next =
403                    new_agent_chain.current_agent_index != old_agent_index && !wrapped_around;
404
405                if advanced_to_next {
406                    // Reset to attempt 1 with next agent
407                    PipelineState {
408                        agent_chain: new_agent_chain,
409                        commit: CommitState::Generating {
410                            attempt: 1,
411                            max_attempts,
412                        },
413                        ..state
414                    }
415                } else {
416                    // All agents exhausted (wrapped around) - give up
417                    // Reset to NotStarted so orchestration can handle agent chain exhaustion
418                    PipelineState {
419                        agent_chain: new_agent_chain,
420                        commit: CommitState::NotStarted,
421                        ..state
422                    }
423                }
424            }
425        }
426
427        PipelineEvent::FinalizingStarted => PipelineState {
428            phase: super::event::PipelinePhase::Finalizing,
429            ..state
430        },
431
432        PipelineEvent::PromptPermissionsRestored => PipelineState {
433            phase: super::event::PipelinePhase::Complete,
434            ..state
435        },
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::agents::AgentRole;
443    use crate::reducer::event::AgentErrorKind;
444    use crate::reducer::event::PipelinePhase;
445    use crate::reducer::event::RebasePhase;
446    use crate::reducer::state::AgentChainState;
447
448    fn create_test_state() -> PipelineState {
449        PipelineState {
450            agent_chain: AgentChainState::initial().with_agents(
451                vec!["agent1".to_string(), "agent2".to_string()],
452                vec![vec!["model1".to_string(), "model2".to_string()]],
453                AgentRole::Developer,
454            ),
455            ..PipelineState::initial(5, 2)
456        }
457    }
458
459    #[test]
460    fn test_reduce_pipeline_started() {
461        let state = create_test_state();
462        let new_state = reduce(state, PipelineEvent::PipelineStarted);
463        assert_eq!(new_state.phase, PipelinePhase::Planning);
464    }
465
466    #[test]
467    fn test_reduce_pipeline_completed() {
468        let state = create_test_state();
469        let new_state = reduce(state, PipelineEvent::PipelineCompleted);
470        assert_eq!(new_state.phase, PipelinePhase::Complete);
471    }
472
473    #[test]
474    fn test_reduce_development_iteration_completed() {
475        // DevelopmentIterationCompleted transitions to CommitMessage phase
476        // The iteration counter stays the same; it gets incremented by CommitCreated
477        let state = PipelineState {
478            phase: PipelinePhase::Development,
479            iteration: 2,
480            total_iterations: 5,
481            ..create_test_state()
482        };
483        let new_state = reduce(
484            state,
485            PipelineEvent::DevelopmentIterationCompleted {
486                iteration: 2,
487                output_valid: true,
488            },
489        );
490        // Iteration stays at 2 (incremented by CommitCreated later)
491        assert_eq!(new_state.iteration, 2);
492        // Goes to CommitMessage phase to create a commit
493        assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
494        // Previous phase stored for return after commit
495        assert_eq!(new_state.previous_phase, Some(PipelinePhase::Development));
496    }
497
498    #[test]
499    fn test_reduce_development_iteration_complete_goes_to_commit() {
500        // Even on last iteration, DevelopmentIterationCompleted goes to CommitMessage
501        // The transition to Review happens after CommitCreated
502        let state = PipelineState {
503            phase: PipelinePhase::Development,
504            iteration: 5,
505            total_iterations: 5,
506            ..create_test_state()
507        };
508        let new_state = reduce(
509            state,
510            PipelineEvent::DevelopmentIterationCompleted {
511                iteration: 5,
512                output_valid: true,
513            },
514        );
515        // Iteration stays at 5 (incremented by CommitCreated later)
516        assert_eq!(new_state.iteration, 5);
517        // Goes to CommitMessage phase first
518        assert_eq!(new_state.phase, PipelinePhase::CommitMessage);
519    }
520
521    #[test]
522    fn test_reduce_agent_fallback_to_next_model() {
523        let state = create_test_state();
524        let initial_agent = state.agent_chain.current_agent().unwrap().clone();
525        let initial_model_index = state.agent_chain.current_model_index;
526
527        let new_state = reduce(
528            state,
529            PipelineEvent::AgentInvocationFailed {
530                role: AgentRole::Developer,
531                agent: initial_agent.clone(),
532                exit_code: 1,
533                error_kind: AgentErrorKind::Network,
534                retriable: true,
535            },
536        );
537
538        assert_ne!(
539            new_state.agent_chain.current_model_index,
540            initial_model_index
541        );
542    }
543
544    #[test]
545    fn test_reduce_rebase_started() {
546        let state = create_test_state();
547        let new_state = reduce(
548            state,
549            PipelineEvent::RebaseStarted {
550                phase: RebasePhase::Initial,
551                target_branch: "main".to_string(),
552            },
553        );
554
555        assert!(matches!(new_state.rebase, RebaseState::InProgress { .. }));
556    }
557
558    #[test]
559    fn test_reduce_rebase_succeeded() {
560        let state = create_test_state();
561        let new_state = reduce(
562            state,
563            PipelineEvent::RebaseSucceeded {
564                phase: RebasePhase::Initial,
565                new_head: "abc123".to_string(),
566            },
567        );
568
569        assert!(matches!(new_state.rebase, RebaseState::Completed { .. }));
570    }
571
572    #[test]
573    fn test_reduce_commit_generation_started() {
574        let state = create_test_state();
575        let new_state = reduce(state, PipelineEvent::CommitGenerationStarted);
576
577        assert!(matches!(new_state.commit, CommitState::Generating { .. }));
578    }
579
580    #[test]
581    fn test_reduce_commit_created() {
582        let state = create_test_state();
583        let new_state = reduce(
584            state,
585            PipelineEvent::CommitCreated {
586                hash: "abc123".to_string(),
587                message: "test commit".to_string(),
588            },
589        );
590
591        assert!(matches!(new_state.commit, CommitState::Committed { .. }));
592        assert_eq!(new_state.phase, PipelinePhase::FinalValidation);
593    }
594
595    #[test]
596    fn test_reduce_all_agent_failure_scenarios() {
597        let state = create_test_state();
598        let initial_agent_index = state.agent_chain.current_agent_index;
599        let initial_model_index = state.agent_chain.current_model_index;
600        let agent_name = state.agent_chain.current_agent().unwrap().clone();
601
602        let network_error_state = reduce(
603            state.clone(),
604            PipelineEvent::AgentInvocationFailed {
605                role: AgentRole::Developer,
606                agent: agent_name.clone(),
607                exit_code: 1,
608                error_kind: AgentErrorKind::Network,
609                retriable: true,
610            },
611        );
612        assert_eq!(
613            network_error_state.agent_chain.current_agent_index,
614            initial_agent_index
615        );
616        assert!(network_error_state.agent_chain.current_model_index > initial_model_index);
617
618        let auth_error_state = reduce(
619            state.clone(),
620            PipelineEvent::AgentInvocationFailed {
621                role: AgentRole::Developer,
622                agent: agent_name.clone(),
623                exit_code: 1,
624                error_kind: AgentErrorKind::Authentication,
625                retriable: false,
626            },
627        );
628        assert!(auth_error_state.agent_chain.current_agent_index > initial_agent_index);
629        assert_eq!(
630            auth_error_state.agent_chain.current_model_index,
631            initial_model_index
632        );
633
634        let internal_error_state = reduce(
635            state,
636            PipelineEvent::AgentInvocationFailed {
637                role: AgentRole::Developer,
638                agent: agent_name,
639                exit_code: 139,
640                error_kind: AgentErrorKind::InternalError,
641                retriable: false,
642            },
643        );
644        assert!(internal_error_state.agent_chain.current_agent_index > initial_agent_index);
645    }
646
647    #[test]
648    fn test_reduce_rebase_full_state_machine() {
649        let mut state = create_test_state();
650
651        state = reduce(
652            state,
653            PipelineEvent::RebaseStarted {
654                phase: RebasePhase::Initial,
655                target_branch: "main".to_string(),
656            },
657        );
658        assert!(matches!(state.rebase, RebaseState::InProgress { .. }));
659
660        state = reduce(
661            state,
662            PipelineEvent::RebaseConflictDetected {
663                files: vec![std::path::PathBuf::from("file1.txt")],
664            },
665        );
666        assert!(matches!(state.rebase, RebaseState::Conflicted { .. }));
667
668        state = reduce(
669            state,
670            PipelineEvent::RebaseConflictResolved {
671                files: vec![std::path::PathBuf::from("file1.txt")],
672            },
673        );
674        assert!(matches!(state.rebase, RebaseState::InProgress { .. }));
675
676        state = reduce(
677            state,
678            PipelineEvent::RebaseSucceeded {
679                phase: RebasePhase::Initial,
680                new_head: "def456".to_string(),
681            },
682        );
683        assert!(matches!(state.rebase, RebaseState::Completed { .. }));
684    }
685
686    #[test]
687    fn test_reduce_commit_full_state_machine() {
688        let mut state = create_test_state();
689
690        state = reduce(state, PipelineEvent::CommitGenerationStarted);
691        assert!(matches!(state.commit, CommitState::Generating { .. }));
692
693        state = reduce(
694            state,
695            PipelineEvent::CommitCreated {
696                hash: "abc123".to_string(),
697                message: "test commit".to_string(),
698            },
699        );
700        assert!(matches!(state.commit, CommitState::Committed { .. }));
701    }
702
703    #[test]
704    fn test_reduce_phase_transitions() {
705        let mut state = create_test_state();
706
707        state = reduce(state, PipelineEvent::PlanningPhaseCompleted);
708        assert_eq!(state.phase, PipelinePhase::Development);
709
710        state = reduce(state, PipelineEvent::DevelopmentPhaseStarted);
711        assert_eq!(state.phase, PipelinePhase::Development);
712
713        state = reduce(state, PipelineEvent::DevelopmentPhaseCompleted);
714        assert_eq!(state.phase, PipelinePhase::Review);
715
716        state = reduce(state, PipelineEvent::ReviewPhaseStarted);
717        assert_eq!(state.phase, PipelinePhase::Review);
718
719        state = reduce(
720            state,
721            PipelineEvent::ReviewPhaseCompleted { early_exit: false },
722        );
723        assert_eq!(state.phase, PipelinePhase::CommitMessage);
724    }
725
726    #[test]
727    fn test_reduce_agent_chain_exhaustion() {
728        let state = PipelineState {
729            agent_chain: AgentChainState::initial()
730                .with_agents(
731                    vec!["agent1".to_string()],
732                    vec![vec!["model1".to_string()]],
733                    AgentRole::Developer,
734                )
735                .with_max_cycles(3),
736            ..create_test_state()
737        };
738
739        let exhausted_state = reduce(
740            state,
741            PipelineEvent::AgentChainExhausted {
742                role: AgentRole::Developer,
743            },
744        );
745
746        assert_eq!(exhausted_state.agent_chain.current_agent_index, 0);
747        assert_eq!(exhausted_state.agent_chain.current_model_index, 0);
748        assert_eq!(exhausted_state.agent_chain.retry_cycle, 1);
749    }
750
751    #[test]
752    fn test_reduce_agent_fallback_triggers_fallback_event() {
753        let state = create_test_state();
754        let agent = state.agent_chain.current_agent().unwrap().clone();
755
756        let new_state = reduce(
757            state,
758            PipelineEvent::AgentInvocationFailed {
759                role: AgentRole::Developer,
760                agent: agent.clone(),
761                exit_code: 1,
762                error_kind: AgentErrorKind::Authentication,
763                retriable: false,
764            },
765        );
766
767        assert!(new_state.agent_chain.current_agent_index > 0);
768    }
769
770    #[test]
771    fn test_reduce_model_fallback_triggers_fallback_event() {
772        let state = create_test_state();
773        let initial_model_index = state.agent_chain.current_model_index;
774        let agent_name = state.agent_chain.current_agent().unwrap().clone();
775
776        let new_state = reduce(
777            state,
778            PipelineEvent::AgentInvocationFailed {
779                role: AgentRole::Developer,
780                agent: agent_name,
781                exit_code: 1,
782                error_kind: AgentErrorKind::RateLimit,
783                retriable: true,
784            },
785        );
786
787        assert!(new_state.agent_chain.current_model_index > initial_model_index);
788    }
789
790    #[test]
791    fn test_reduce_finalizing_started() {
792        let state = PipelineState {
793            phase: PipelinePhase::FinalValidation,
794            ..create_test_state()
795        };
796        let new_state = reduce(state, PipelineEvent::FinalizingStarted);
797        assert_eq!(new_state.phase, PipelinePhase::Finalizing);
798    }
799
800    #[test]
801    fn test_reduce_prompt_permissions_restored() {
802        let state = PipelineState {
803            phase: PipelinePhase::Finalizing,
804            ..create_test_state()
805        };
806        let new_state = reduce(state, PipelineEvent::PromptPermissionsRestored);
807        assert_eq!(new_state.phase, PipelinePhase::Complete);
808    }
809
810    #[test]
811    fn test_reduce_finalization_full_flow() {
812        let mut state = PipelineState {
813            phase: PipelinePhase::FinalValidation,
814            ..create_test_state()
815        };
816
817        // FinalValidation -> Finalizing
818        state = reduce(state, PipelineEvent::FinalizingStarted);
819        assert_eq!(state.phase, PipelinePhase::Finalizing);
820
821        // Finalizing -> Complete
822        state = reduce(state, PipelineEvent::PromptPermissionsRestored);
823        assert_eq!(state.phase, PipelinePhase::Complete);
824    }
825
826    /// Test the complete finalization flow from FinalValidation through effects.
827    ///
828    /// This tests the orchestration + reduction path:
829    /// 1. FinalValidation phase -> ValidateFinalState effect
830    /// 2. ValidateFinalState effect -> FinalizingStarted event
831    /// 3. FinalizingStarted event -> Finalizing phase
832    /// 4. Finalizing phase -> RestorePromptPermissions effect
833    /// 5. RestorePromptPermissions effect -> PromptPermissionsRestored event
834    /// 6. PromptPermissionsRestored event -> Complete phase
835    #[test]
836    fn test_finalization_orchestration_integration() {
837        use crate::reducer::mock_effect_handler::MockEffectHandler;
838        use crate::reducer::orchestration::determine_next_effect;
839
840        // Start in FinalValidation
841        let initial_state = PipelineState {
842            phase: PipelinePhase::FinalValidation,
843            ..PipelineState::initial(5, 2)
844        };
845
846        let mut handler = MockEffectHandler::new(initial_state.clone());
847
848        // Step 1: Determine effect for FinalValidation
849        let effect1 = determine_next_effect(&initial_state);
850        assert!(
851            matches!(effect1, crate::reducer::effect::Effect::ValidateFinalState),
852            "FinalValidation should emit ValidateFinalState effect"
853        );
854
855        // Step 2: Execute effect, get event
856        let event1 = handler.execute_mock(effect1);
857        assert!(
858            matches!(event1, PipelineEvent::FinalizingStarted),
859            "ValidateFinalState should return FinalizingStarted"
860        );
861
862        // Step 3: Reduce state with event
863        let state2 = reduce(initial_state, event1);
864        assert_eq!(state2.phase, PipelinePhase::Finalizing);
865        assert!(!state2.is_complete(), "Finalizing should not be complete");
866
867        // Step 4: Determine effect for Finalizing
868        let effect2 = determine_next_effect(&state2);
869        assert!(
870            matches!(
871                effect2,
872                crate::reducer::effect::Effect::RestorePromptPermissions
873            ),
874            "Finalizing should emit RestorePromptPermissions effect"
875        );
876
877        // Step 5: Execute effect, get event
878        let event2 = handler.execute_mock(effect2);
879        assert!(
880            matches!(event2, PipelineEvent::PromptPermissionsRestored),
881            "RestorePromptPermissions should return PromptPermissionsRestored"
882        );
883
884        // Step 6: Reduce state with event
885        let final_state = reduce(state2, event2);
886        assert_eq!(final_state.phase, PipelinePhase::Complete);
887        assert!(final_state.is_complete(), "Complete should be complete");
888
889        // Verify effects were captured
890        let effects = handler.captured_effects();
891        assert_eq!(effects.len(), 2);
892        assert!(matches!(
893            effects[0],
894            crate::reducer::effect::Effect::ValidateFinalState
895        ));
896        assert!(matches!(
897            effects[1],
898            crate::reducer::effect::Effect::RestorePromptPermissions
899        ));
900    }
901}