Skip to main content

ralph_workflow/reducer/
handler.rs

1//! Main effect handler implementation.
2//!
3//! This module implements the EffectHandler trait to execute pipeline side effects
4//! through the reducer architecture. Effect handlers perform actual work (agent
5//! invocation, git operations, file I/O) and emit events.
6
7use crate::agents::AgentRole;
8use crate::checkpoint::{
9    save_checkpoint_with_workspace, CheckpointBuilder, PipelinePhase as CheckpointPhase,
10};
11use crate::phases::{commit, development, get_primary_commit_agent, review, PhaseContext};
12use crate::pipeline::PipelineRuntime;
13use crate::prompts::ContextLevel;
14use crate::reducer::effect::{Effect, EffectHandler};
15use crate::reducer::event::{CheckpointTrigger, ConflictStrategy, PipelineEvent, RebasePhase};
16use crate::reducer::fault_tolerant_executor::{
17    execute_agent_fault_tolerantly, AgentExecutionConfig,
18};
19use crate::reducer::state::PipelineState;
20use anyhow::Result;
21use std::path::Path;
22
23/// Main effect handler implementation.
24///
25/// This handler executes effects by calling existing pipeline functions,
26/// maintaining compatibility while migrating to reducer architecture.
27pub struct MainEffectHandler {
28    /// Current pipeline state
29    pub state: PipelineState,
30    /// Event log for replay/debugging
31    pub event_log: Vec<PipelineEvent>,
32}
33
34impl MainEffectHandler {
35    /// Create a new effect handler.
36    pub fn new(state: PipelineState) -> Self {
37        Self {
38            state,
39            event_log: Vec::new(),
40        }
41    }
42}
43
44impl<'ctx> EffectHandler<'ctx> for MainEffectHandler {
45    fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
46        let event = self.execute_effect(effect, ctx)?;
47        self.event_log.push(event.clone());
48        Ok(event)
49    }
50}
51
52impl crate::app::event_loop::StatefulHandler for MainEffectHandler {
53    fn update_state(&mut self, state: PipelineState) {
54        self.state = state;
55    }
56}
57
58impl MainEffectHandler {
59    fn execute_effect(
60        &mut self,
61        effect: Effect,
62        ctx: &mut PhaseContext<'_>,
63    ) -> Result<PipelineEvent> {
64        match effect {
65            Effect::AgentInvocation {
66                role,
67                agent,
68                model,
69                prompt,
70            } => self.invoke_agent(ctx, role, agent, model, prompt),
71
72            Effect::InitializeAgentChain { role } => self.initialize_agent_chain(ctx, role),
73
74            Effect::GeneratePlan { iteration } => self.generate_plan(ctx, iteration),
75
76            Effect::RunDevelopmentIteration { iteration } => {
77                self.run_development_iteration(ctx, iteration)
78            }
79
80            Effect::RunReviewPass { pass } => self.run_review_pass(ctx, pass),
81
82            Effect::RunFixAttempt { pass } => self.run_fix_attempt(ctx, pass),
83
84            Effect::RunRebase {
85                phase,
86                target_branch,
87            } => self.run_rebase(ctx, phase, target_branch),
88
89            Effect::ResolveRebaseConflicts { strategy } => {
90                self.resolve_rebase_conflicts(ctx, strategy)
91            }
92
93            Effect::GenerateCommitMessage => self.generate_commit_message(ctx),
94
95            Effect::CreateCommit { message } => self.create_commit(ctx, message),
96
97            Effect::SkipCommit { reason } => self.skip_commit(ctx, reason),
98
99            Effect::ValidateFinalState => self.validate_final_state(ctx),
100
101            Effect::SaveCheckpoint { trigger } => self.save_checkpoint(ctx, trigger),
102
103            Effect::CleanupContext => self.cleanup_context(ctx),
104
105            Effect::RestorePromptPermissions => self.restore_prompt_permissions(ctx),
106        }
107    }
108
109    fn invoke_agent(
110        &mut self,
111        ctx: &mut PhaseContext<'_>,
112        role: AgentRole,
113        agent: String,
114        _model: Option<String>,
115        prompt: String,
116    ) -> Result<PipelineEvent> {
117        // Use agent from state.agent_chain if available
118        let effective_agent = self
119            .state
120            .agent_chain
121            .current_agent()
122            .unwrap_or(&agent)
123            .clone();
124
125        let model_name = self.state.agent_chain.current_model();
126
127        ctx.logger.info(&format!(
128            "Executing with agent: {}, model: {:?}",
129            effective_agent, model_name
130        ));
131
132        // Get agent configuration from registry
133        let agent_config = ctx
134            .registry
135            .resolve_config(&effective_agent)
136            .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", effective_agent))?;
137
138        // Determine log file path
139        let logfile = format!(".agent/logs/{}.log", effective_agent.to_lowercase());
140
141        // Build pipeline runtime
142        let mut runtime = PipelineRuntime {
143            timer: ctx.timer,
144            logger: ctx.logger,
145            colors: ctx.colors,
146            config: ctx.config,
147            executor: ctx.executor,
148            executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
149            workspace: ctx.workspace,
150        };
151
152        // Execute agent with fault-tolerant wrapper
153        let config = AgentExecutionConfig {
154            role,
155            agent_name: &effective_agent,
156            cmd_str: &agent_config.cmd,
157            parser_type: agent_config.json_parser,
158            env_vars: &agent_config.env_vars,
159            prompt: &prompt,
160            display_name: &effective_agent,
161            logfile: &logfile,
162        };
163
164        execute_agent_fault_tolerantly(config, &mut runtime)
165    }
166
167    fn generate_plan(
168        &mut self,
169        ctx: &mut PhaseContext<'_>,
170        iteration: u32,
171    ) -> Result<PipelineEvent> {
172        match development::run_planning_step(ctx, iteration) {
173            Ok(_) => {
174                // Validate plan was created
175                let plan_path = Path::new(".agent/PLAN.md");
176                let plan_exists = ctx.workspace.exists(plan_path);
177                let plan_content = if plan_exists {
178                    ctx.workspace.read(plan_path).ok().unwrap_or_default()
179                } else {
180                    String::new()
181                };
182
183                let is_valid = plan_exists && !plan_content.trim().is_empty();
184
185                Ok(PipelineEvent::PlanGenerationCompleted {
186                    iteration,
187                    valid: is_valid,
188                })
189            }
190            Err(_) => Ok(PipelineEvent::PlanGenerationCompleted {
191                iteration,
192                valid: false,
193            }),
194        }
195    }
196
197    fn run_development_iteration(
198        &mut self,
199        ctx: &mut PhaseContext<'_>,
200        iteration: u32,
201    ) -> Result<PipelineEvent> {
202        use crate::checkpoint::restore::ResumeContext;
203        let developer_context = ContextLevel::from(ctx.config.developer_context);
204
205        // Get current agent from agent chain
206        let dev_agent = self.state.agent_chain.current_agent().cloned();
207
208        // Run development iteration
209        let result = development::run_development_iteration_with_xml_retry(
210            ctx,
211            iteration,
212            developer_context,
213            false,
214            None::<&ResumeContext>,
215            dev_agent.as_deref(),
216        );
217
218        match result {
219            Ok(_dev_result) => Ok(PipelineEvent::DevelopmentIterationCompleted {
220                iteration,
221                output_valid: true,
222            }),
223            Err(_) => Ok(PipelineEvent::DevelopmentIterationCompleted {
224                iteration,
225                output_valid: false,
226            }),
227        }
228    }
229
230    fn run_review_pass(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<PipelineEvent> {
231        let review_label = format!("review_{}", pass);
232
233        // Get current reviewer agent from agent chain
234        let review_agent = self.state.agent_chain.current_agent().cloned();
235
236        match review::run_review_pass(ctx, pass, &review_label, "", review_agent.as_deref()) {
237            Ok(result) => Ok(PipelineEvent::ReviewCompleted {
238                pass,
239                issues_found: !result.early_exit,
240            }),
241            Err(_) => Ok(PipelineEvent::ReviewCompleted {
242                pass,
243                issues_found: false,
244            }),
245        }
246    }
247
248    fn run_fix_attempt(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<PipelineEvent> {
249        use crate::checkpoint::restore::ResumeContext;
250        let reviewer_context = ContextLevel::from(ctx.config.reviewer_context);
251
252        // Get current reviewer agent from agent chain
253        let fix_agent = self.state.agent_chain.current_agent().cloned();
254
255        match review::run_fix_pass(
256            ctx,
257            pass,
258            reviewer_context,
259            None::<&ResumeContext>,
260            fix_agent.as_deref(),
261        ) {
262            Ok(_) => Ok(PipelineEvent::FixAttemptCompleted {
263                pass,
264                changes_made: true,
265            }),
266            Err(_) => Ok(PipelineEvent::FixAttemptCompleted {
267                pass,
268                changes_made: false,
269            }),
270        }
271    }
272
273    fn run_rebase(
274        &mut self,
275        _ctx: &mut PhaseContext<'_>,
276        phase: RebasePhase,
277        target_branch: String,
278    ) -> Result<PipelineEvent> {
279        use crate::git_helpers::{get_conflicted_files, rebase_onto};
280
281        match rebase_onto(&target_branch, _ctx.executor) {
282            Ok(_) => {
283                // Check for conflicts
284                let conflicted_files = get_conflicted_files().unwrap_or_default();
285
286                if !conflicted_files.is_empty() {
287                    let files = conflicted_files.into_iter().map(|s| s.into()).collect();
288
289                    Ok(PipelineEvent::RebaseConflictDetected { files })
290                } else {
291                    // Get current head for success case
292                    let new_head = match git2::Repository::open(".") {
293                        Ok(repo) => {
294                            match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
295                                Some(commit) => commit.id().to_string(),
296                                None => "unknown".to_string(),
297                            }
298                        }
299                        Err(_) => "unknown".to_string(),
300                    };
301
302                    Ok(PipelineEvent::RebaseSucceeded { phase, new_head })
303                }
304            }
305            Err(e) => Ok(PipelineEvent::RebaseFailed {
306                phase,
307                reason: e.to_string(),
308            }),
309        }
310    }
311
312    fn resolve_rebase_conflicts(
313        &mut self,
314        _ctx: &mut PhaseContext<'_>,
315        strategy: ConflictStrategy,
316    ) -> Result<PipelineEvent> {
317        use crate::git_helpers::{abort_rebase, continue_rebase, get_conflicted_files};
318
319        match strategy {
320            ConflictStrategy::Continue => match continue_rebase(_ctx.executor) {
321                Ok(_) => {
322                    let files = get_conflicted_files()
323                        .unwrap_or_default()
324                        .into_iter()
325                        .map(|s| s.into())
326                        .collect();
327
328                    Ok(PipelineEvent::RebaseConflictResolved { files })
329                }
330                Err(e) => Ok(PipelineEvent::RebaseFailed {
331                    phase: RebasePhase::PostReview,
332                    reason: e.to_string(),
333                }),
334            },
335            ConflictStrategy::Abort => match abort_rebase(_ctx.executor) {
336                Ok(_) => {
337                    let restored_to = match git2::Repository::open(".") {
338                        Ok(repo) => {
339                            match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
340                                Some(commit) => commit.id().to_string(),
341                                None => "HEAD".to_string(),
342                            }
343                        }
344                        Err(_) => "HEAD".to_string(),
345                    };
346
347                    Ok(PipelineEvent::RebaseAborted {
348                        phase: RebasePhase::PostReview,
349                        restored_to,
350                    })
351                }
352                Err(e) => Ok(PipelineEvent::RebaseFailed {
353                    phase: RebasePhase::PostReview,
354                    reason: e.to_string(),
355                }),
356            },
357            ConflictStrategy::Skip => {
358                Ok(PipelineEvent::RebaseConflictResolved { files: Vec::new() })
359            }
360        }
361    }
362
363    fn generate_commit_message(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
364        let attempt = match &self.state.commit {
365            crate::reducer::state::CommitState::Generating { attempt, .. } => *attempt,
366            _ => 1,
367        };
368
369        // Get git diff for commit message generation
370        let diff = crate::git_helpers::git_diff().unwrap_or_default();
371
372        // Check if diff is empty BEFORE attempting to generate commit message
373        // This prevents the "Empty diff provided to generate_commit_message" warning
374        if diff.trim().is_empty() {
375            ctx.logger
376                .info("No changes to commit (empty diff), skipping commit");
377            return Ok(PipelineEvent::CommitSkipped {
378                reason: "No changes to commit (empty diff)".to_string(),
379            });
380        }
381
382        // Get commit agent first to avoid borrow conflicts
383        let commit_agent = get_primary_commit_agent(ctx).unwrap_or_else(|| "commit".to_string());
384
385        let mut runtime = PipelineRuntime {
386            timer: ctx.timer,
387            logger: ctx.logger,
388            colors: ctx.colors,
389            config: ctx.config,
390            executor: ctx.executor,
391            executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
392            workspace: ctx.workspace,
393        };
394
395        match commit::generate_commit_message(
396            &diff,
397            ctx.registry,
398            &mut runtime,
399            &commit_agent,
400            ctx.template_context,
401            ctx.workspace,
402            &ctx.prompt_history,
403        ) {
404            Ok(result) => Ok(PipelineEvent::CommitMessageGenerated {
405                message: result.message.clone(),
406                attempt,
407            }),
408            Err(_) => Ok(PipelineEvent::CommitMessageGenerated {
409                message: "chore: automated commit".to_string(),
410                attempt,
411            }),
412        }
413    }
414
415    fn create_commit(
416        &mut self,
417        ctx: &mut PhaseContext<'_>,
418        message: String,
419    ) -> Result<PipelineEvent> {
420        use crate::git_helpers::{git_add_all, git_commit};
421
422        // Stage all changes
423        git_add_all()?;
424
425        // Create commit
426        match git_commit(&message, None, None, Some(ctx.executor)) {
427            Ok(Some(hash)) => Ok(PipelineEvent::CommitCreated {
428                hash: hash.to_string(),
429                message,
430            }),
431            Ok(None) => {
432                // No changes to commit - skip to FinalValidation instead of failing
433                // This prevents infinite loop when there are no changes
434                Ok(PipelineEvent::CommitSkipped {
435                    reason: "No changes to commit".to_string(),
436                })
437            }
438            Err(e) => Ok(PipelineEvent::CommitGenerationFailed {
439                reason: e.to_string(),
440            }),
441        }
442    }
443
444    fn skip_commit(
445        &mut self,
446        _ctx: &mut PhaseContext<'_>,
447        reason: String,
448    ) -> Result<PipelineEvent> {
449        Ok(PipelineEvent::CommitSkipped { reason })
450    }
451
452    fn validate_final_state(&mut self, _ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
453        // Transition to Finalizing phase to restore PROMPT.md permissions
454        // via the effect system before marking the pipeline complete
455        Ok(PipelineEvent::FinalizingStarted)
456    }
457
458    fn save_checkpoint(
459        &mut self,
460        ctx: &mut PhaseContext<'_>,
461        trigger: CheckpointTrigger,
462    ) -> Result<PipelineEvent> {
463        if ctx.config.features.checkpoint_enabled {
464            let _ = save_checkpoint_from_state(&self.state, ctx);
465        }
466
467        Ok(PipelineEvent::CheckpointSaved { trigger })
468    }
469
470    fn initialize_agent_chain(
471        &mut self,
472        ctx: &mut PhaseContext<'_>,
473        role: AgentRole,
474    ) -> Result<PipelineEvent> {
475        let agents = match role {
476            AgentRole::Developer => vec![ctx.developer_agent.to_string()],
477            AgentRole::Reviewer => vec![ctx.reviewer_agent.to_string()],
478            AgentRole::Commit => {
479                if let Some(commit_agent) = get_primary_commit_agent(ctx) {
480                    vec![commit_agent]
481                } else {
482                    vec![]
483                }
484            }
485        };
486
487        let _models_per_agent: Vec<Vec<String>> = agents.iter().map(|_| vec![]).collect();
488
489        let max_cycles = self.state.agent_chain.max_cycles;
490
491        ctx.logger.info(&format!(
492            "Initializing agent chain with {} cycles",
493            max_cycles
494        ));
495
496        Ok(PipelineEvent::AgentChainInitialized { role, agents })
497    }
498
499    fn cleanup_context(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
500        use std::path::Path;
501
502        ctx.logger
503            .info("Cleaning up context files to prevent pollution...");
504
505        let mut cleaned_count = 0;
506        let mut failed_count = 0;
507
508        // Delete PLAN.md via workspace
509        let plan_path = Path::new(".agent/PLAN.md");
510        if ctx.workspace.exists(plan_path) {
511            if let Err(err) = ctx.workspace.remove(plan_path) {
512                ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
513                failed_count += 1;
514            } else {
515                cleaned_count += 1;
516            }
517        }
518
519        // Delete ISSUES.md (may not exist if in isolation mode) via workspace
520        let issues_path = Path::new(".agent/ISSUES.md");
521        if ctx.workspace.exists(issues_path) {
522            if let Err(err) = ctx.workspace.remove(issues_path) {
523                ctx.logger
524                    .warn(&format!("Failed to delete ISSUES.md: {err}"));
525                failed_count += 1;
526            } else {
527                cleaned_count += 1;
528            }
529        }
530
531        // Delete ALL .xml files in .agent/tmp/ to prevent context pollution via workspace
532        let tmp_dir = Path::new(".agent/tmp");
533        if ctx.workspace.exists(tmp_dir) {
534            if let Ok(entries) = ctx.workspace.read_dir(tmp_dir) {
535                for entry in entries {
536                    let path = entry.path();
537                    if path.extension().and_then(|s| s.to_str()) == Some("xml") {
538                        if let Err(err) = ctx.workspace.remove(path) {
539                            ctx.logger.warn(&format!(
540                                "Failed to delete {}: {}",
541                                path.display(),
542                                err
543                            ));
544                            failed_count += 1;
545                        } else {
546                            cleaned_count += 1;
547                        }
548                    }
549                }
550            }
551        }
552
553        if cleaned_count > 0 {
554            ctx.logger.success(&format!(
555                "Context cleanup complete: {} files deleted{}",
556                cleaned_count,
557                if failed_count > 0 {
558                    format!(", {} failures", failed_count)
559                } else {
560                    String::new()
561                }
562            ));
563        } else {
564            ctx.logger.info("No context files to clean up");
565        }
566
567        Ok(PipelineEvent::ContextCleaned)
568    }
569
570    fn restore_prompt_permissions(&mut self, ctx: &mut PhaseContext<'_>) -> Result<PipelineEvent> {
571        use crate::files::make_prompt_writable_with_workspace;
572
573        ctx.logger.info("Restoring PROMPT.md write permissions...");
574
575        // Use workspace-based function for testability
576        if let Some(warning) = make_prompt_writable_with_workspace(ctx.workspace) {
577            ctx.logger.warn(&warning);
578        }
579
580        Ok(PipelineEvent::PromptPermissionsRestored)
581    }
582}
583
584/// Save checkpoint from current pipeline state.
585fn save_checkpoint_from_state(
586    state: &PipelineState,
587    ctx: &mut PhaseContext<'_>,
588) -> anyhow::Result<()> {
589    let builder = CheckpointBuilder::new()
590        .phase(
591            map_to_checkpoint_phase(state.phase),
592            state.iteration,
593            state.total_iterations,
594        )
595        .reviewer_pass(state.reviewer_pass, state.total_reviewer_passes)
596        .capture_from_context(
597            ctx.config,
598            ctx.registry,
599            ctx.developer_agent,
600            ctx.reviewer_agent,
601            ctx.logger,
602            &ctx.run_context,
603        )
604        .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
605        .with_execution_history(ctx.execution_history.clone())
606        .with_prompt_history(ctx.clone_prompt_history());
607
608    if let Some(checkpoint) = builder.build() {
609        let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
610    }
611
612    Ok(())
613}
614
615/// Map reducer phase to checkpoint phase.
616fn map_to_checkpoint_phase(phase: crate::reducer::event::PipelinePhase) -> CheckpointPhase {
617    match phase {
618        crate::reducer::event::PipelinePhase::Planning => CheckpointPhase::Planning,
619        crate::reducer::event::PipelinePhase::Development => CheckpointPhase::Development,
620        crate::reducer::event::PipelinePhase::Review => CheckpointPhase::Review,
621        crate::reducer::event::PipelinePhase::CommitMessage => CheckpointPhase::CommitMessage,
622        crate::reducer::event::PipelinePhase::FinalValidation => CheckpointPhase::FinalValidation,
623        crate::reducer::event::PipelinePhase::Finalizing => CheckpointPhase::FinalValidation,
624        crate::reducer::event::PipelinePhase::Complete => CheckpointPhase::Complete,
625        crate::reducer::event::PipelinePhase::Interrupted => CheckpointPhase::Complete,
626    }
627}
628
629#[cfg(test)]
630mod tests {
631    use super::*;
632
633    /// Test that RestorePromptPermissions effect returns the correct event.
634    ///
635    /// The actual workspace interaction is tested via integration tests.
636    /// This unit test verifies the mock handler returns the expected event.
637    #[test]
638    fn test_mock_handler_restore_prompt_permissions() {
639        use crate::reducer::mock_effect_handler::MockEffectHandler;
640
641        let state = PipelineState::initial(1, 0);
642        let mut handler = MockEffectHandler::new(state);
643
644        let event = handler.execute_mock(Effect::RestorePromptPermissions);
645
646        assert!(
647            matches!(event, PipelineEvent::PromptPermissionsRestored),
648            "RestorePromptPermissions effect should return PromptPermissionsRestored event"
649        );
650
651        assert!(
652            handler.was_effect_executed(|e| matches!(e, Effect::RestorePromptPermissions)),
653            "Effect should be captured"
654        );
655    }
656
657    /// Test that ValidateFinalState transitions to Finalizing phase, not Complete.
658    ///
659    /// This ensures that the reducer goes through Finalizing phase to restore
660    /// PROMPT.md permissions before marking the pipeline complete.
661    #[test]
662    fn test_mock_handler_validate_final_state_goes_to_finalizing() {
663        use crate::reducer::mock_effect_handler::MockEffectHandler;
664
665        let state = PipelineState::initial(1, 0);
666        let mut handler = MockEffectHandler::new(state);
667
668        let event = handler.execute_mock(Effect::ValidateFinalState);
669
670        assert!(
671            matches!(event, PipelineEvent::FinalizingStarted),
672            "ValidateFinalState should return FinalizingStarted to trigger finalization phase, got: {:?}",
673            event
674        );
675    }
676
677    /// Test that cleanup_context uses workspace for file operations.
678    ///
679    /// This verifies that cleanup_context:
680    /// 1. Deletes PLAN.md via workspace
681    /// 2. Deletes ISSUES.md via workspace  
682    /// 3. Deletes .xml files in .agent/tmp/ via workspace
683    #[test]
684    fn test_cleanup_context_uses_workspace() {
685        use crate::agents::AgentRegistry;
686        use crate::checkpoint::{ExecutionHistory, RunContext};
687        use crate::config::Config;
688        use crate::executor::MockProcessExecutor;
689        use crate::logger::{Colors, Logger};
690        use crate::phases::context::PhaseContext;
691        use crate::pipeline::{Stats, Timer};
692        use crate::prompts::template_context::TemplateContext;
693        use crate::workspace::{MemoryWorkspace, Workspace};
694        use std::path::{Path, PathBuf};
695
696        // Create workspace with files that should be cleaned
697        let workspace = MemoryWorkspace::new_test()
698            .with_file(".agent/PLAN.md", "# Plan")
699            .with_file(".agent/ISSUES.md", "# Issues")
700            .with_dir(".agent/tmp")
701            .with_file(".agent/tmp/issues.xml", "<issues/>")
702            .with_file(".agent/tmp/development_result.xml", "<result/>")
703            .with_file(".agent/tmp/keep.txt", "not xml");
704
705        // Set up all the context fields
706        let config = Config::default();
707        let registry = AgentRegistry::new().unwrap();
708        let colors = Colors { enabled: false };
709        let logger = Logger::new(colors);
710        let mut timer = Timer::new();
711        let mut stats = Stats::default();
712        let template_context = TemplateContext::default();
713        let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
714            as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
715        let repo_root = PathBuf::from("/test/repo");
716
717        let mut ctx = PhaseContext {
718            config: &config,
719            registry: &registry,
720            logger: &logger,
721            colors: &colors,
722            timer: &mut timer,
723            stats: &mut stats,
724            developer_agent: "test-dev",
725            reviewer_agent: "test-reviewer",
726            review_guidelines: None,
727            template_context: &template_context,
728            run_context: RunContext::new(),
729            execution_history: ExecutionHistory::new(),
730            prompt_history: std::collections::HashMap::new(),
731            executor: &*executor_arc,
732            executor_arc: std::sync::Arc::clone(&executor_arc),
733            repo_root: &repo_root,
734            workspace: &workspace,
735        };
736
737        // Create a real handler and call cleanup_context
738        let state = PipelineState::initial(1, 0);
739        let mut handler = super::MainEffectHandler::new(state);
740        let result = handler.cleanup_context(&mut ctx);
741
742        assert!(result.is_ok(), "cleanup_context should succeed");
743
744        // Verify files were deleted via workspace
745        assert!(
746            !workspace.exists(Path::new(".agent/PLAN.md")),
747            "PLAN.md should be deleted via workspace"
748        );
749        assert!(
750            !workspace.exists(Path::new(".agent/ISSUES.md")),
751            "ISSUES.md should be deleted via workspace"
752        );
753        assert!(
754            !workspace.exists(Path::new(".agent/tmp/issues.xml")),
755            "issues.xml should be deleted via workspace"
756        );
757        assert!(
758            !workspace.exists(Path::new(".agent/tmp/development_result.xml")),
759            "development_result.xml should be deleted via workspace"
760        );
761        // Non-xml files should remain
762        assert!(
763            workspace.exists(Path::new(".agent/tmp/keep.txt")),
764            "non-xml file should not be deleted"
765        );
766    }
767
768    /// Test that save_checkpoint uses workspace for file operations.
769    ///
770    /// This verifies that save_checkpoint writes to .agent/checkpoint.json
771    /// via the workspace abstraction, not std::fs.
772    #[test]
773    fn test_save_checkpoint_uses_workspace() {
774        use crate::agents::AgentRegistry;
775        use crate::checkpoint::{ExecutionHistory, RunContext};
776        use crate::config::Config;
777        use crate::executor::MockProcessExecutor;
778        use crate::logger::{Colors, Logger};
779        use crate::phases::context::PhaseContext;
780        use crate::pipeline::{Stats, Timer};
781        use crate::prompts::template_context::TemplateContext;
782        use crate::workspace::{MemoryWorkspace, Workspace};
783        use std::path::{Path, PathBuf};
784
785        // Create an empty workspace - no checkpoint should exist initially
786        let workspace = MemoryWorkspace::new_test();
787
788        // Verify no checkpoint exists before
789        assert!(
790            !workspace.exists(Path::new(".agent/checkpoint.json")),
791            "checkpoint should not exist initially"
792        );
793
794        // Set up all the context fields - use real agent names from the registry
795        let config = Config::default();
796        let registry = AgentRegistry::new().unwrap();
797        let colors = Colors { enabled: false };
798        let logger = Logger::new(colors);
799        let mut timer = Timer::new();
800        let mut stats = Stats::default();
801        let template_context = TemplateContext::default();
802        let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
803            as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
804        let repo_root = PathBuf::from("/test/repo");
805
806        // Use "claude" which should exist in the default registry
807        let developer_agent = "claude";
808        let reviewer_agent = "claude";
809
810        let mut ctx = PhaseContext {
811            config: &config,
812            registry: &registry,
813            logger: &logger,
814            colors: &colors,
815            timer: &mut timer,
816            stats: &mut stats,
817            developer_agent,
818            reviewer_agent,
819            review_guidelines: None,
820            template_context: &template_context,
821            run_context: RunContext::new(),
822            execution_history: ExecutionHistory::new(),
823            prompt_history: std::collections::HashMap::new(),
824            executor: &*executor_arc,
825            executor_arc: std::sync::Arc::clone(&executor_arc),
826            repo_root: &repo_root,
827            workspace: &workspace,
828        };
829
830        // Create state and handler
831        let state = PipelineState::initial(1, 0);
832        let mut handler = super::MainEffectHandler::new(state);
833
834        // Execute save checkpoint effect
835        let result = handler.save_checkpoint(&mut ctx, CheckpointTrigger::PhaseTransition);
836
837        assert!(result.is_ok(), "save_checkpoint should succeed");
838
839        // Verify checkpoint was written via workspace
840        assert!(
841            workspace.exists(Path::new(".agent/checkpoint.json")),
842            "checkpoint should be written via workspace"
843        );
844
845        // Verify the content is valid JSON
846        let content = workspace.read(Path::new(".agent/checkpoint.json")).unwrap();
847        assert!(
848            content.contains("\"phase\""),
849            "checkpoint should contain phase field"
850        );
851        assert!(
852            content.contains("\"version\""),
853            "checkpoint should contain version field"
854        );
855    }
856}