1use crate::agents::AgentRole;
8use crate::checkpoint::{
9 save_checkpoint_with_workspace, CheckpointBuilder, PipelinePhase as CheckpointPhase,
10};
11use crate::files::llm_output_extraction::file_based_extraction::paths as xml_paths;
12use crate::files::llm_output_extraction::validate_issues_xml;
13use crate::phases::{commit, development, get_primary_commit_agent, review, PhaseContext};
14use crate::pipeline::PipelineRuntime;
15use crate::prompts::ContextLevel;
16use crate::reducer::effect::{Effect, EffectHandler, EffectResult};
17use crate::reducer::event::{
18 CheckpointTrigger, ConflictStrategy, PipelineEvent, PipelinePhase, RebasePhase,
19};
20use crate::reducer::fault_tolerant_executor::{
21 execute_agent_fault_tolerantly, AgentExecutionConfig,
22};
23use crate::reducer::state::PipelineState;
24use crate::reducer::ui_event::{UIEvent, XmlCodeSnippet, XmlOutputContext, XmlOutputType};
25use crate::workspace::Workspace;
26use anyhow::Result;
27use regex::Regex;
28use std::path::{Path, PathBuf};
29
30pub struct MainEffectHandler {
35 pub state: PipelineState,
37 pub event_log: Vec<PipelineEvent>,
39}
40
41impl MainEffectHandler {
42 pub fn new(state: PipelineState) -> Self {
44 Self {
45 state,
46 event_log: Vec::new(),
47 }
48 }
49}
50
51impl<'ctx> EffectHandler<'ctx> for MainEffectHandler {
52 fn execute(&mut self, effect: Effect, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
53 let result = self.execute_effect(effect, ctx)?;
54 self.event_log.push(result.event.clone());
55 Ok(result)
56 }
57}
58
59impl crate::app::event_loop::StatefulHandler for MainEffectHandler {
60 fn update_state(&mut self, state: PipelineState) {
61 self.state = state;
62 }
63}
64
65impl MainEffectHandler {
66 fn phase_transition_ui(&self, to: PipelinePhase) -> UIEvent {
68 UIEvent::PhaseTransition {
69 from: Some(self.state.phase),
70 to,
71 }
72 }
73
74 fn execute_effect(
75 &mut self,
76 effect: Effect,
77 ctx: &mut PhaseContext<'_>,
78 ) -> Result<EffectResult> {
79 match effect {
80 Effect::AgentInvocation {
81 role,
82 agent,
83 model,
84 prompt,
85 } => self.invoke_agent(ctx, role, agent, model, prompt),
86
87 Effect::InitializeAgentChain { role } => self.initialize_agent_chain(ctx, role),
88
89 Effect::GeneratePlan { iteration } => self.generate_plan(ctx, iteration),
90
91 Effect::RunDevelopmentIteration { iteration } => {
92 self.run_development_iteration(ctx, iteration)
93 }
94
95 Effect::RunReviewPass { pass } => self.run_review_pass(ctx, pass),
96
97 Effect::RunFixAttempt { pass } => self.run_fix_attempt(ctx, pass),
98
99 Effect::RunRebase {
100 phase,
101 target_branch,
102 } => self.run_rebase(ctx, phase, target_branch),
103
104 Effect::ResolveRebaseConflicts { strategy } => {
105 self.resolve_rebase_conflicts(ctx, strategy)
106 }
107
108 Effect::GenerateCommitMessage => self.generate_commit_message(ctx),
109
110 Effect::CreateCommit { message } => self.create_commit(ctx, message),
111
112 Effect::SkipCommit { reason } => self.skip_commit(ctx, reason),
113
114 Effect::ValidateFinalState => self.validate_final_state(ctx),
115
116 Effect::SaveCheckpoint { trigger } => self.save_checkpoint(ctx, trigger),
117
118 Effect::CleanupContext => self.cleanup_context(ctx),
119
120 Effect::RestorePromptPermissions => self.restore_prompt_permissions(ctx),
121 }
122 }
123
124 fn invoke_agent(
125 &mut self,
126 ctx: &mut PhaseContext<'_>,
127 role: AgentRole,
128 agent: String,
129 model: Option<String>,
130 prompt: String,
131 ) -> Result<EffectResult> {
132 let effective_agent = self
134 .state
135 .agent_chain
136 .current_agent()
137 .unwrap_or(&agent)
138 .clone();
139
140 let model_name = self.state.agent_chain.current_model();
141
142 let effective_prompt = match self
149 .state
150 .agent_chain
151 .rate_limit_continuation_prompt
152 .as_ref()
153 {
154 Some(saved) if saved == &prompt => saved.clone(),
155 _ => prompt,
156 };
157
158 ctx.logger.info(&format!(
159 "Executing with agent: {}, model: {:?}",
160 effective_agent, model_name
161 ));
162
163 let agent_config = ctx
165 .registry
166 .resolve_config(&effective_agent)
167 .ok_or_else(|| anyhow::anyhow!("Agent not found: {}", effective_agent))?;
168
169 let safe_agent_name =
171 crate::pipeline::logfile::sanitize_agent_name(&effective_agent.to_lowercase());
172 let logfile = format!(".agent/logs/{}.log", safe_agent_name);
173
174 let model_override = model_name
178 .map(std::string::String::as_str)
179 .or(model.as_deref());
180 let cmd_str = agent_config.build_cmd_with_model(true, true, true, model_override);
181
182 let mut runtime = PipelineRuntime {
184 timer: ctx.timer,
185 logger: ctx.logger,
186 colors: ctx.colors,
187 config: ctx.config,
188 executor: ctx.executor,
189 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
190 workspace: ctx.workspace,
191 };
192
193 let config = AgentExecutionConfig {
195 role,
196 agent_name: &effective_agent,
197 cmd_str: &cmd_str,
198 parser_type: agent_config.json_parser,
199 env_vars: &agent_config.env_vars,
200 prompt: &effective_prompt,
201 display_name: &effective_agent,
202 logfile: &logfile,
203 };
204
205 let event = execute_agent_fault_tolerantly(config, &mut runtime)?;
206
207 let ui_event = UIEvent::AgentActivity {
209 agent: effective_agent.clone(),
210 message: format!("Completed {} task", role),
211 };
212
213 Ok(EffectResult::with_ui(event, vec![ui_event]))
214 }
215
216 fn generate_plan(
217 &mut self,
218 ctx: &mut PhaseContext<'_>,
219 iteration: u32,
220 ) -> Result<EffectResult> {
221 match development::run_planning_step(ctx, iteration) {
222 Ok(_) => {
223 let plan_path = Path::new(".agent/PLAN.md");
225 let plan_exists = ctx.workspace.exists(plan_path);
226 let plan_content = if plan_exists {
227 ctx.workspace.read(plan_path).ok().unwrap_or_default()
228 } else {
229 String::new()
230 };
231
232 let is_valid = plan_exists && !plan_content.trim().is_empty();
233
234 let event = PipelineEvent::PlanGenerationCompleted {
235 iteration,
236 valid: is_valid,
237 };
238
239 let mut ui_events = vec![];
241
242 if is_valid {
244 ui_events.push(self.phase_transition_ui(PipelinePhase::Development));
245
246 let plan_xml_path = Path::new(".agent/tmp/plan.xml");
248 let processed_path = Path::new(".agent/tmp/plan.xml.processed");
249 if let Some(xml_content) = ctx
250 .workspace
251 .read(plan_xml_path)
252 .ok()
253 .or_else(|| ctx.workspace.read(processed_path).ok())
254 {
255 ui_events.push(UIEvent::XmlOutput {
256 xml_type: XmlOutputType::DevelopmentPlan,
257 content: xml_content,
258 context: Some(XmlOutputContext {
259 iteration: Some(iteration),
260 pass: None,
261 snippets: Vec::new(),
262 }),
263 });
264 }
265 }
266
267 Ok(EffectResult::with_ui(event, ui_events))
268 }
269 Err(_) => Ok(EffectResult::event(
270 PipelineEvent::PlanGenerationCompleted {
271 iteration,
272 valid: false,
273 },
274 )),
275 }
276 }
277
278 fn run_development_iteration(
279 &mut self,
280 ctx: &mut PhaseContext<'_>,
281 iteration: u32,
282 ) -> Result<EffectResult> {
283 use crate::checkpoint::restore::ResumeContext;
284 let developer_context = ContextLevel::from(ctx.config.developer_context);
285
286 let dev_agent = self.state.agent_chain.current_agent().cloned();
288
289 let continuation_state = &self.state.continuation;
291 let max_continuations = ctx.config.max_dev_continuations.unwrap_or(2);
295
296 if continuation_state.continuation_attempt > max_continuations {
299 return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
300 reason: format!(
301 "Development continuation attempts exhausted (continuation_attempt={}, max_continuations={})",
302 continuation_state.continuation_attempt, max_continuations
303 ),
304 }));
305 }
306
307 if continuation_state.continuation_attempt == 0 {
309 let _ = cleanup_continuation_context_file(ctx);
310 }
311
312 const MAX_INVALID_OUTPUT_RERUNS: u32 = 2;
316
317 let mut invalid_reruns: u32 = 0;
318 let attempt = loop {
319 let attempt = development::run_development_attempt_with_xml_retry(
321 ctx,
322 iteration,
323 developer_context,
324 false,
325 None::<&ResumeContext>,
326 dev_agent.as_deref(),
327 continuation_state,
328 );
329
330 let attempt = match attempt {
331 Ok(a) => a,
332 Err(err) => {
333 return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
334 reason: format!("Development attempt failed: {err}"),
335 }));
336 }
337 };
338
339 match decide_dev_iteration_next_step(
340 continuation_state.continuation_attempt,
341 max_continuations,
342 &attempt,
343 ) {
344 DevIterationNextStep::RetryInvalidOutput
345 if invalid_reruns < MAX_INVALID_OUTPUT_RERUNS =>
346 {
347 invalid_reruns += 1;
348 ctx.logger.info(&format!(
349 "Development output invalid after XSD retries; rerunning attempt without consuming continuation budget ({}/{})",
350 invalid_reruns, MAX_INVALID_OUTPUT_RERUNS
351 ));
352 continue;
353 }
354 DevIterationNextStep::RetryInvalidOutput => {
355 return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
356 reason: format!(
357 "Development output remained invalid after XSD retries and {} reruns. Last summary={}",
358 MAX_INVALID_OUTPUT_RERUNS,
359 attempt.summary
360 ),
361 }));
362 }
363 _ => break attempt,
364 }
365 };
366
367 if matches!(
369 decide_dev_iteration_next_step(
370 continuation_state.continuation_attempt,
371 max_continuations,
372 &attempt
373 ),
374 DevIterationNextStep::Completed
375 ) {
376 let _ = cleanup_continuation_context_file(ctx);
377
378 let event = if continuation_state.is_continuation() {
379 PipelineEvent::DevelopmentIterationContinuationSucceeded {
380 iteration,
381 total_continuation_attempts: continuation_state.continuation_attempt,
382 }
383 } else {
384 PipelineEvent::DevelopmentIterationCompleted {
385 iteration,
386 output_valid: true,
387 }
388 };
389
390 let ui_event = UIEvent::IterationProgress {
391 current: iteration,
392 total: self.state.total_iterations,
393 };
394
395 let mut ui_events = vec![ui_event];
396
397 let dev_xml_path = Path::new(".agent/tmp/development_result.xml");
399 let processed_path = Path::new(".agent/tmp/development_result.xml.processed");
400 if let Some(xml_content) = ctx
401 .workspace
402 .read(dev_xml_path)
403 .ok()
404 .or_else(|| ctx.workspace.read(processed_path).ok())
405 {
406 ui_events.push(UIEvent::XmlOutput {
407 xml_type: XmlOutputType::DevelopmentResult,
408 content: xml_content,
409 context: Some(XmlOutputContext {
410 iteration: Some(iteration),
411 pass: None,
412 snippets: Vec::new(),
413 }),
414 });
415 }
416
417 return Ok(EffectResult::with_ui(event, ui_events));
418 }
419
420 let next_attempt = match decide_dev_iteration_next_step(
422 continuation_state.continuation_attempt,
423 max_continuations,
424 &attempt,
425 ) {
426 DevIterationNextStep::Continue {
427 next_continuation_attempt,
428 } => next_continuation_attempt,
429 DevIterationNextStep::Abort { .. } => {
430 let _ = cleanup_continuation_context_file(ctx);
431 let total_valid_attempts = 1 + max_continuations;
432 return Ok(EffectResult::event(PipelineEvent::PipelineAborted {
433 reason: format!(
434 "Development did not reach status='completed' after {} total valid attempts. Last status={:?}. Last summary={}",
435 total_valid_attempts,
436 attempt.status,
437 attempt.summary
438 ),
439 }));
440 }
441 DevIterationNextStep::RetryInvalidOutput | DevIterationNextStep::Completed => {
442 unreachable!("Unexpected dev iteration next step after invalid-output handling")
444 }
445 };
446
447 ctx.logger.info(&format!(
448 "Triggering development continuation attempt {}/{} (previous status={})",
449 next_attempt, max_continuations, attempt.status
450 ));
451
452 write_continuation_context_file(ctx, iteration, next_attempt, &attempt)?;
454 ctx.logger
455 .info("Continuation context written to .agent/tmp/continuation_context.md");
456
457 let event = PipelineEvent::DevelopmentIterationContinuationTriggered {
458 iteration,
459 status: attempt.status,
460 summary: attempt.summary,
461 files_changed: attempt.files_changed,
462 next_steps: attempt.next_steps,
463 };
464
465 let mut ui_events = vec![UIEvent::IterationProgress {
466 current: iteration,
467 total: self.state.total_iterations,
468 }];
469
470 let dev_xml_path = Path::new(".agent/tmp/development_result.xml");
472 let processed_path = Path::new(".agent/tmp/development_result.xml.processed");
473 if let Some(xml_content) = ctx
474 .workspace
475 .read(dev_xml_path)
476 .ok()
477 .or_else(|| ctx.workspace.read(processed_path).ok())
478 {
479 ui_events.push(UIEvent::XmlOutput {
480 xml_type: XmlOutputType::DevelopmentResult,
481 content: xml_content,
482 context: Some(XmlOutputContext {
483 iteration: Some(iteration),
484 pass: None,
485 snippets: Vec::new(),
486 }),
487 });
488 }
489
490 Ok(EffectResult::with_ui(event, ui_events))
491 }
492
493 fn run_review_pass(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<EffectResult> {
494 let review_label = format!("review_{}", pass);
495
496 let review_agent = self.state.agent_chain.current_agent().cloned();
498
499 match review::run_review_pass(ctx, pass, &review_label, "", review_agent.as_deref()) {
500 Ok(result) => {
501 let event = PipelineEvent::ReviewCompleted {
502 pass,
503 issues_found: !result.early_exit,
504 };
505
506 let mut ui_events = vec![
508 UIEvent::ReviewProgress {
510 pass,
511 total: self.state.total_reviewer_passes,
512 },
513 ];
514
515 let issues_xml_path = Path::new(".agent/tmp/issues.xml");
517 let processed_path = Path::new(".agent/tmp/issues.xml.processed");
518 if let Some(xml_content) = ctx
519 .workspace
520 .read(issues_xml_path)
521 .ok()
522 .or_else(|| ctx.workspace.read(processed_path).ok())
523 {
524 let snippets = collect_review_issue_snippets(ctx.workspace, &xml_content);
525 ui_events.push(UIEvent::XmlOutput {
526 xml_type: XmlOutputType::ReviewIssues,
527 content: xml_content,
528 context: Some(XmlOutputContext {
529 iteration: None,
530 pass: Some(pass),
531 snippets,
532 }),
533 });
534 }
535
536 Ok(EffectResult::with_ui(event, ui_events))
537 }
538 Err(_) => Ok(EffectResult::event(PipelineEvent::ReviewCompleted {
539 pass,
540 issues_found: false,
541 })),
542 }
543 }
544
545 fn run_fix_attempt(&mut self, ctx: &mut PhaseContext<'_>, pass: u32) -> Result<EffectResult> {
546 use crate::checkpoint::restore::ResumeContext;
547 let reviewer_context = ContextLevel::from(ctx.config.reviewer_context);
548
549 let fix_agent = self.state.agent_chain.current_agent().cloned();
551
552 match review::run_fix_pass(
553 ctx,
554 pass,
555 reviewer_context,
556 None::<&ResumeContext>,
557 fix_agent.as_deref(),
558 ) {
559 Ok(_) => {
560 let event = PipelineEvent::FixAttemptCompleted {
561 pass,
562 changes_made: true,
563 };
564
565 let mut ui_events = vec![];
567 let fix_xml_path = Path::new(".agent/tmp/fix_result.xml");
568 let processed_path = Path::new(".agent/tmp/fix_result.xml.processed");
569 if let Some(xml_content) = ctx
570 .workspace
571 .read(fix_xml_path)
572 .ok()
573 .or_else(|| ctx.workspace.read(processed_path).ok())
574 {
575 ui_events.push(UIEvent::XmlOutput {
576 xml_type: XmlOutputType::FixResult,
577 content: xml_content,
578 context: Some(XmlOutputContext {
579 iteration: None,
580 pass: Some(pass),
581 snippets: Vec::new(),
582 }),
583 });
584 }
585
586 Ok(EffectResult::with_ui(event, ui_events))
587 }
588 Err(_) => Ok(EffectResult::event(PipelineEvent::FixAttemptCompleted {
589 pass,
590 changes_made: false,
591 })),
592 }
593 }
594
595 fn run_rebase(
596 &mut self,
597 _ctx: &mut PhaseContext<'_>,
598 phase: RebasePhase,
599 target_branch: String,
600 ) -> Result<EffectResult> {
601 use crate::git_helpers::{get_conflicted_files, rebase_onto};
602
603 match rebase_onto(&target_branch, _ctx.executor) {
604 Ok(_) => {
605 let conflicted_files = get_conflicted_files().unwrap_or_default();
607
608 if !conflicted_files.is_empty() {
609 let files = conflicted_files.into_iter().map(|s| s.into()).collect();
610
611 Ok(EffectResult::event(PipelineEvent::RebaseConflictDetected {
612 files,
613 }))
614 } else {
615 let new_head = match git2::Repository::open(".") {
617 Ok(repo) => {
618 match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
619 Some(commit) => commit.id().to_string(),
620 None => "unknown".to_string(),
621 }
622 }
623 Err(_) => "unknown".to_string(),
624 };
625
626 Ok(EffectResult::event(PipelineEvent::RebaseSucceeded {
627 phase,
628 new_head,
629 }))
630 }
631 }
632 Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
633 phase,
634 reason: e.to_string(),
635 })),
636 }
637 }
638
639 fn resolve_rebase_conflicts(
640 &mut self,
641 _ctx: &mut PhaseContext<'_>,
642 strategy: ConflictStrategy,
643 ) -> Result<EffectResult> {
644 use crate::git_helpers::{abort_rebase, continue_rebase, get_conflicted_files};
645
646 match strategy {
647 ConflictStrategy::Continue => match continue_rebase(_ctx.executor) {
648 Ok(_) => {
649 let files = get_conflicted_files()
650 .unwrap_or_default()
651 .into_iter()
652 .map(|s| s.into())
653 .collect();
654
655 Ok(EffectResult::event(PipelineEvent::RebaseConflictResolved {
656 files,
657 }))
658 }
659 Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
660 phase: RebasePhase::PostReview,
661 reason: e.to_string(),
662 })),
663 },
664 ConflictStrategy::Abort => match abort_rebase(_ctx.executor) {
665 Ok(_) => {
666 let restored_to = match git2::Repository::open(".") {
667 Ok(repo) => {
668 match repo.head().ok().and_then(|head| head.peel_to_commit().ok()) {
669 Some(commit) => commit.id().to_string(),
670 None => "HEAD".to_string(),
671 }
672 }
673 Err(_) => "HEAD".to_string(),
674 };
675
676 Ok(EffectResult::event(PipelineEvent::RebaseAborted {
677 phase: RebasePhase::PostReview,
678 restored_to,
679 }))
680 }
681 Err(e) => Ok(EffectResult::event(PipelineEvent::RebaseFailed {
682 phase: RebasePhase::PostReview,
683 reason: e.to_string(),
684 })),
685 },
686 ConflictStrategy::Skip => {
687 Ok(EffectResult::event(PipelineEvent::RebaseConflictResolved {
688 files: Vec::new(),
689 }))
690 }
691 }
692 }
693
694 fn generate_commit_message(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
695 let attempt = match &self.state.commit {
696 crate::reducer::state::CommitState::Generating { attempt, .. } => *attempt,
697 _ => 1,
698 };
699
700 let diff = crate::git_helpers::git_diff().unwrap_or_default();
702
703 if diff.trim().is_empty() {
706 ctx.logger
707 .info("No changes to commit (empty diff), skipping commit");
708 return Ok(EffectResult::event(PipelineEvent::CommitSkipped {
709 reason: "No changes to commit (empty diff)".to_string(),
710 }));
711 }
712
713 let commit_agent = get_primary_commit_agent(ctx).unwrap_or_else(|| "commit".to_string());
715
716 let mut runtime = PipelineRuntime {
717 timer: ctx.timer,
718 logger: ctx.logger,
719 colors: ctx.colors,
720 config: ctx.config,
721 executor: ctx.executor,
722 executor_arc: std::sync::Arc::clone(&ctx.executor_arc),
723 workspace: ctx.workspace,
724 };
725
726 match commit::generate_commit_message(
727 &diff,
728 ctx.registry,
729 &mut runtime,
730 &commit_agent,
731 ctx.template_context,
732 ctx.workspace,
733 &ctx.prompt_history,
734 ) {
735 Ok(result) => {
736 let event = PipelineEvent::CommitMessageGenerated {
737 message: result.message.clone(),
738 attempt,
739 };
740
741 let mut ui_events = vec![
743 self.phase_transition_ui(PipelinePhase::CommitMessage),
745 ];
746
747 if let Some(xml_content) = read_commit_message_xml(ctx.workspace) {
749 ui_events.push(UIEvent::XmlOutput {
750 xml_type: XmlOutputType::CommitMessage,
751 content: xml_content,
752 context: None,
753 });
754 }
755
756 Ok(EffectResult::with_ui(event, ui_events))
757 }
758 Err(_) => Ok(EffectResult::event(PipelineEvent::CommitMessageGenerated {
759 message: "chore: automated commit".to_string(),
760 attempt,
761 })),
762 }
763 }
764
765 fn create_commit(
766 &mut self,
767 ctx: &mut PhaseContext<'_>,
768 message: String,
769 ) -> Result<EffectResult> {
770 use crate::git_helpers::{git_add_all, git_commit};
771
772 git_add_all()?;
774
775 match git_commit(&message, None, None, Some(ctx.executor)) {
777 Ok(Some(hash)) => Ok(EffectResult::event(PipelineEvent::CommitCreated {
778 hash: hash.to_string(),
779 message,
780 })),
781 Ok(None) => {
782 Ok(EffectResult::event(PipelineEvent::CommitSkipped {
785 reason: "No changes to commit".to_string(),
786 }))
787 }
788 Err(e) => Ok(EffectResult::event(PipelineEvent::CommitGenerationFailed {
789 reason: e.to_string(),
790 })),
791 }
792 }
793
794 fn skip_commit(&mut self, _ctx: &mut PhaseContext<'_>, reason: String) -> Result<EffectResult> {
795 Ok(EffectResult::event(PipelineEvent::CommitSkipped { reason }))
796 }
797
798 fn validate_final_state(&mut self, _ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
799 let event = PipelineEvent::FinalizingStarted;
802
803 let ui_event = self.phase_transition_ui(PipelinePhase::Finalizing);
805
806 Ok(EffectResult::with_ui(event, vec![ui_event]))
807 }
808
809 fn save_checkpoint(
810 &mut self,
811 ctx: &mut PhaseContext<'_>,
812 trigger: CheckpointTrigger,
813 ) -> Result<EffectResult> {
814 if ctx.config.features.checkpoint_enabled {
815 let _ = save_checkpoint_from_state(&self.state, ctx);
816 }
817
818 Ok(EffectResult::event(PipelineEvent::CheckpointSaved {
819 trigger,
820 }))
821 }
822
823 fn initialize_agent_chain(
824 &mut self,
825 ctx: &mut PhaseContext<'_>,
826 role: AgentRole,
827 ) -> Result<EffectResult> {
828 let agents = match role {
829 AgentRole::Developer => vec![ctx.developer_agent.to_string()],
830 AgentRole::Reviewer => vec![ctx.reviewer_agent.to_string()],
831 AgentRole::Commit => {
832 if let Some(commit_agent) = get_primary_commit_agent(ctx) {
833 vec![commit_agent]
834 } else {
835 vec![]
836 }
837 }
838 };
839
840 let _models_per_agent: Vec<Vec<String>> = agents.iter().map(|_| vec![]).collect();
841
842 let max_cycles = self.state.agent_chain.max_cycles;
843
844 ctx.logger.info(&format!(
845 "Initializing agent chain with {} cycles",
846 max_cycles
847 ));
848
849 let event = PipelineEvent::AgentChainInitialized { role, agents };
850
851 let ui_events = match role {
853 AgentRole::Developer if self.state.phase == PipelinePhase::Planning => {
854 vec![UIEvent::PhaseTransition {
855 from: None,
856 to: PipelinePhase::Planning,
857 }]
858 }
859 AgentRole::Reviewer if self.state.phase == PipelinePhase::Review => {
860 vec![self.phase_transition_ui(PipelinePhase::Review)]
861 }
862 _ => vec![],
863 };
864
865 Ok(EffectResult::with_ui(event, ui_events))
866 }
867
868 fn cleanup_context(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
869 use std::path::Path;
870
871 ctx.logger
872 .info("Cleaning up context files to prevent pollution...");
873
874 let mut cleaned_count = 0;
875 let mut failed_count = 0;
876
877 let plan_path = Path::new(".agent/PLAN.md");
879 if ctx.workspace.exists(plan_path) {
880 if let Err(err) = ctx.workspace.remove(plan_path) {
881 ctx.logger.warn(&format!("Failed to delete PLAN.md: {err}"));
882 failed_count += 1;
883 } else {
884 cleaned_count += 1;
885 }
886 }
887
888 let issues_path = Path::new(".agent/ISSUES.md");
890 if ctx.workspace.exists(issues_path) {
891 if let Err(err) = ctx.workspace.remove(issues_path) {
892 ctx.logger
893 .warn(&format!("Failed to delete ISSUES.md: {err}"));
894 failed_count += 1;
895 } else {
896 cleaned_count += 1;
897 }
898 }
899
900 let tmp_dir = Path::new(".agent/tmp");
902 if ctx.workspace.exists(tmp_dir) {
903 if let Ok(entries) = ctx.workspace.read_dir(tmp_dir) {
904 for entry in entries {
905 let path = entry.path();
906 if path.extension().and_then(|s| s.to_str()) == Some("xml") {
907 if let Err(err) = ctx.workspace.remove(path) {
908 ctx.logger.warn(&format!(
909 "Failed to delete {}: {}",
910 path.display(),
911 err
912 ));
913 failed_count += 1;
914 } else {
915 cleaned_count += 1;
916 }
917 }
918 }
919 }
920 }
921
922 let _ = cleanup_continuation_context_file(ctx);
924
925 if cleaned_count > 0 {
926 ctx.logger.success(&format!(
927 "Context cleanup complete: {} files deleted{}",
928 cleaned_count,
929 if failed_count > 0 {
930 format!(", {} failures", failed_count)
931 } else {
932 String::new()
933 }
934 ));
935 } else {
936 ctx.logger.info("No context files to clean up");
937 }
938
939 Ok(EffectResult::event(PipelineEvent::ContextCleaned))
940 }
941
942 fn restore_prompt_permissions(&mut self, ctx: &mut PhaseContext<'_>) -> Result<EffectResult> {
943 use crate::files::make_prompt_writable_with_workspace;
944
945 ctx.logger.info("Restoring PROMPT.md write permissions...");
946
947 if let Some(warning) = make_prompt_writable_with_workspace(ctx.workspace) {
949 ctx.logger.warn(&warning);
950 }
951
952 let event = PipelineEvent::PromptPermissionsRestored;
953
954 let ui_event = self.phase_transition_ui(PipelinePhase::Complete);
956
957 Ok(EffectResult::with_ui(event, vec![ui_event]))
958 }
959}
960
961fn collect_review_issue_snippets(
962 workspace: &dyn Workspace,
963 issues_xml: &str,
964) -> Vec<XmlCodeSnippet> {
965 let validated = match validate_issues_xml(issues_xml) {
966 Ok(v) => v,
967 Err(_) => return Vec::new(),
968 };
969
970 let mut snippets = Vec::new();
971 let mut seen = std::collections::BTreeSet::new();
972
973 for issue in validated.issues {
974 if let Some((file, issue_start, issue_end)) = parse_issue_location(&issue) {
975 if let Some(snippet) = read_snippet_for_issue(workspace, &file, issue_start, issue_end)
976 {
977 let key = (
978 snippet.file.clone(),
979 snippet.line_start,
980 snippet.line_end,
981 snippet.content.clone(),
982 );
983 if seen.insert(key) {
984 snippets.push(snippet);
985 }
986 }
987 }
988 }
989
990 snippets
991}
992
993fn read_commit_message_xml(workspace: &dyn Workspace) -> Option<String> {
994 let primary_path = Path::new(xml_paths::COMMIT_MESSAGE_XML);
995 let primary_processed_path =
996 PathBuf::from(format!("{}.processed", xml_paths::COMMIT_MESSAGE_XML));
997 let legacy_path = Path::new(".agent/tmp/commit.xml");
998 let legacy_processed_path = Path::new(".agent/tmp/commit.xml.processed");
999
1000 workspace
1001 .read(primary_path)
1002 .ok()
1003 .or_else(|| workspace.read(&primary_processed_path).ok())
1004 .or_else(|| workspace.read(legacy_path).ok())
1005 .or_else(|| workspace.read(legacy_processed_path).ok())
1006}
1007
1008fn parse_issue_location(issue: &str) -> Option<(String, u32, u32)> {
1009 let location_re = Regex::new(
1010 r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+):(?P<start>\d+)(?:[-–—](?P<end>\d+))?(?::(?P<col>\d+))?",
1011 )
1012 .ok()?;
1013 let gh_location_re = Regex::new(
1014 r"(?m)(?P<file>[-_./A-Za-z0-9]+\.[A-Za-z0-9]+)#L(?P<start>\d+)(?:-L(?P<end>\d+))?",
1015 )
1016 .ok()?;
1017
1018 if let Some(cap) = location_re.captures(issue) {
1019 let file = cap.name("file")?.as_str().to_string();
1020 let start = cap.name("start")?.as_str().parse::<u32>().ok()?;
1021 let end = cap
1022 .name("end")
1023 .and_then(|m| m.as_str().parse::<u32>().ok())
1024 .unwrap_or(start);
1025 return Some((file, start, end));
1026 }
1027
1028 if let Some(cap) = gh_location_re.captures(issue) {
1029 let file = cap.name("file")?.as_str().to_string();
1030 let start = cap.name("start")?.as_str().parse::<u32>().ok()?;
1031 let end = cap
1032 .name("end")
1033 .and_then(|m| m.as_str().parse::<u32>().ok())
1034 .unwrap_or(start);
1035 return Some((file, start, end));
1036 }
1037
1038 None
1039}
1040
1041fn read_snippet_for_issue(
1042 workspace: &dyn Workspace,
1043 file: &str,
1044 issue_start: u32,
1045 issue_end: u32,
1046) -> Option<XmlCodeSnippet> {
1047 let issue_start = issue_start.max(1);
1048 let issue_end = issue_end.max(issue_start);
1049
1050 let context_lines: u32 = 2;
1051 let start = issue_start.saturating_sub(context_lines).max(1);
1052 let end = issue_end.saturating_add(context_lines);
1053
1054 let content = workspace.read(Path::new(file)).ok()?;
1055 let lines: Vec<&str> = content.lines().collect();
1056 if lines.is_empty() {
1057 return None;
1058 }
1059
1060 let max_line = u32::try_from(lines.len()).ok()?;
1061 let end = end.min(max_line);
1062 if start > end {
1063 return None;
1064 }
1065
1066 let mut snippet = String::new();
1067 for line_no in start..=end {
1068 let idx = usize::try_from(line_no.saturating_sub(1)).ok()?;
1069 let line = lines.get(idx).copied().unwrap_or_default();
1070 snippet.push_str(&format!("{:>4} | {}\n", line_no, line));
1071 }
1072
1073 Some(XmlCodeSnippet {
1074 file: file.to_string(),
1075 line_start: start,
1076 line_end: end,
1077 content: snippet,
1078 })
1079}
1080
1081fn cleanup_continuation_context_file(ctx: &mut PhaseContext<'_>) -> anyhow::Result<()> {
1082 let path = Path::new(".agent/tmp/continuation_context.md");
1083 if ctx.workspace.exists(path) {
1084 ctx.workspace.remove(path)?;
1085 }
1086 Ok(())
1087}
1088
1089fn write_continuation_context_file(
1090 ctx: &mut PhaseContext<'_>,
1091 iteration: u32,
1092 continuation_attempt: u32,
1093 attempt: &development::DevAttemptResult,
1094) -> anyhow::Result<()> {
1095 let tmp_dir = Path::new(".agent/tmp");
1096 if !ctx.workspace.exists(tmp_dir) {
1097 ctx.workspace.create_dir_all(tmp_dir)?;
1098 }
1099
1100 let mut content = String::new();
1101 content.push_str("# Development Continuation Context\n\n");
1102 content.push_str(&format!("- Iteration: {iteration}\n"));
1103 content.push_str(&format!("- Continuation attempt: {continuation_attempt}\n"));
1104 content.push_str(&format!("- Previous status: {}\n\n", attempt.status));
1105
1106 content.push_str("## Previous summary\n\n");
1107 content.push_str(&attempt.summary);
1108 content.push('\n');
1109
1110 if let Some(ref files) = attempt.files_changed {
1111 content.push_str("\n## Files changed\n\n");
1112 for file in files {
1113 content.push_str("- ");
1114 content.push_str(file);
1115 content.push('\n');
1116 }
1117 }
1118
1119 if let Some(ref next_steps) = attempt.next_steps {
1120 content.push_str("\n## Recommended next steps\n\n");
1121 content.push_str(next_steps);
1122 content.push('\n');
1123 }
1124
1125 content.push_str("\n## Reference files (do not modify)\n\n");
1126 content.push_str("- PROMPT.md\n");
1127 content.push_str("- .agent/PLAN.md\n");
1128
1129 ctx.workspace
1130 .write(Path::new(".agent/tmp/continuation_context.md"), &content)?;
1131
1132 Ok(())
1133}
1134
1135fn save_checkpoint_from_state(
1137 state: &PipelineState,
1138 ctx: &mut PhaseContext<'_>,
1139) -> anyhow::Result<()> {
1140 let builder = CheckpointBuilder::new()
1141 .phase(
1142 map_to_checkpoint_phase(state.phase),
1143 state.iteration,
1144 state.total_iterations,
1145 )
1146 .reviewer_pass(state.reviewer_pass, state.total_reviewer_passes)
1147 .capture_from_context(
1148 ctx.config,
1149 ctx.registry,
1150 ctx.developer_agent,
1151 ctx.reviewer_agent,
1152 ctx.logger,
1153 &ctx.run_context,
1154 )
1155 .with_executor_from_context(std::sync::Arc::clone(&ctx.executor_arc))
1156 .with_execution_history(ctx.execution_history.clone())
1157 .with_prompt_history(ctx.clone_prompt_history());
1158
1159 if let Some(checkpoint) = builder.build() {
1160 let _ = save_checkpoint_with_workspace(ctx.workspace, &checkpoint);
1161 }
1162
1163 Ok(())
1164}
1165
1166fn map_to_checkpoint_phase(phase: crate::reducer::event::PipelinePhase) -> CheckpointPhase {
1168 match phase {
1169 crate::reducer::event::PipelinePhase::Planning => CheckpointPhase::Planning,
1170 crate::reducer::event::PipelinePhase::Development => CheckpointPhase::Development,
1171 crate::reducer::event::PipelinePhase::Review => CheckpointPhase::Review,
1172 crate::reducer::event::PipelinePhase::CommitMessage => CheckpointPhase::CommitMessage,
1173 crate::reducer::event::PipelinePhase::FinalValidation => CheckpointPhase::FinalValidation,
1174 crate::reducer::event::PipelinePhase::Finalizing => CheckpointPhase::FinalValidation,
1175 crate::reducer::event::PipelinePhase::Complete => CheckpointPhase::Complete,
1176 crate::reducer::event::PipelinePhase::Interrupted => CheckpointPhase::Interrupted,
1177 }
1178}
1179
1180#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1181enum DevIterationNextStep {
1182 Completed,
1183 RetryInvalidOutput,
1184 Continue { next_continuation_attempt: u32 },
1185 Abort { next_continuation_attempt: u32 },
1186}
1187
1188fn decide_dev_iteration_next_step(
1189 continuation_attempt: u32,
1190 max_continuations: u32,
1191 attempt: &crate::phases::development::DevAttemptResult,
1192) -> DevIterationNextStep {
1193 if !attempt.output_valid {
1194 return DevIterationNextStep::RetryInvalidOutput;
1195 }
1196
1197 if attempt.output_valid
1198 && matches!(
1199 attempt.status,
1200 crate::reducer::state::DevelopmentStatus::Completed
1201 )
1202 {
1203 return DevIterationNextStep::Completed;
1204 }
1205
1206 let next_attempt = continuation_attempt + 1;
1207 if next_attempt > max_continuations {
1211 DevIterationNextStep::Abort {
1212 next_continuation_attempt: next_attempt,
1213 }
1214 } else {
1215 DevIterationNextStep::Continue {
1216 next_continuation_attempt: next_attempt,
1217 }
1218 }
1219}
1220
1221#[cfg(test)]
1222mod tests {
1223 use super::*;
1224
1225 #[test]
1230 fn test_mock_handler_restore_prompt_permissions() {
1231 use crate::reducer::mock_effect_handler::MockEffectHandler;
1232
1233 let state = PipelineState::initial(1, 0);
1234 let mut handler = MockEffectHandler::new(state);
1235
1236 let result = handler.execute_mock(Effect::RestorePromptPermissions);
1237
1238 assert!(
1239 matches!(result.event, PipelineEvent::PromptPermissionsRestored),
1240 "RestorePromptPermissions effect should return PromptPermissionsRestored event"
1241 );
1242
1243 assert!(
1244 handler.was_effect_executed(|e| matches!(e, Effect::RestorePromptPermissions)),
1245 "Effect should be captured"
1246 );
1247 }
1248
1249 #[test]
1254 fn test_mock_handler_validate_final_state_goes_to_finalizing() {
1255 use crate::reducer::mock_effect_handler::MockEffectHandler;
1256
1257 let state = PipelineState::initial(1, 0);
1258 let mut handler = MockEffectHandler::new(state);
1259
1260 let result = handler.execute_mock(Effect::ValidateFinalState);
1261
1262 assert!(
1263 matches!(result.event, PipelineEvent::FinalizingStarted),
1264 "ValidateFinalState should return FinalizingStarted to trigger finalization phase, got: {:?}",
1265 result.event
1266 );
1267 }
1268
1269 #[test]
1270 fn test_map_to_checkpoint_phase_interrupted_maps_to_interrupted() {
1271 use crate::reducer::event::PipelinePhase;
1272
1273 assert_eq!(
1274 map_to_checkpoint_phase(PipelinePhase::Interrupted),
1275 CheckpointPhase::Interrupted
1276 );
1277 }
1278
1279 #[test]
1286 fn test_cleanup_context_uses_workspace() {
1287 use crate::agents::AgentRegistry;
1288 use crate::checkpoint::{ExecutionHistory, RunContext};
1289 use crate::config::Config;
1290 use crate::executor::MockProcessExecutor;
1291 use crate::logger::{Colors, Logger};
1292 use crate::phases::context::PhaseContext;
1293 use crate::pipeline::{Stats, Timer};
1294 use crate::prompts::template_context::TemplateContext;
1295 use crate::workspace::{MemoryWorkspace, Workspace};
1296 use std::path::{Path, PathBuf};
1297
1298 let workspace = MemoryWorkspace::new_test()
1300 .with_file(".agent/PLAN.md", "# Plan")
1301 .with_file(".agent/ISSUES.md", "# Issues")
1302 .with_dir(".agent/tmp")
1303 .with_file(".agent/tmp/issues.xml", "<issues/>")
1304 .with_file(".agent/tmp/development_result.xml", "<result/>")
1305 .with_file(".agent/tmp/keep.txt", "not xml");
1306
1307 let config = Config::default();
1309 let registry = AgentRegistry::new().unwrap();
1310 let colors = Colors { enabled: false };
1311 let logger = Logger::new(colors);
1312 let mut timer = Timer::new();
1313 let mut stats = Stats::default();
1314 let template_context = TemplateContext::default();
1315 let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
1316 as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1317 let repo_root = PathBuf::from("/test/repo");
1318
1319 let mut ctx = PhaseContext {
1320 config: &config,
1321 registry: ®istry,
1322 logger: &logger,
1323 colors: &colors,
1324 timer: &mut timer,
1325 stats: &mut stats,
1326 developer_agent: "test-dev",
1327 reviewer_agent: "test-reviewer",
1328 review_guidelines: None,
1329 template_context: &template_context,
1330 run_context: RunContext::new(),
1331 execution_history: ExecutionHistory::new(),
1332 prompt_history: std::collections::HashMap::new(),
1333 executor: &*executor_arc,
1334 executor_arc: std::sync::Arc::clone(&executor_arc),
1335 repo_root: &repo_root,
1336 workspace: &workspace,
1337 };
1338
1339 let state = PipelineState::initial(1, 0);
1341 let mut handler = super::MainEffectHandler::new(state);
1342 let result = handler.cleanup_context(&mut ctx);
1343
1344 assert!(result.is_ok(), "cleanup_context should succeed");
1345
1346 assert!(
1348 !workspace.exists(Path::new(".agent/PLAN.md")),
1349 "PLAN.md should be deleted via workspace"
1350 );
1351 assert!(
1352 !workspace.exists(Path::new(".agent/ISSUES.md")),
1353 "ISSUES.md should be deleted via workspace"
1354 );
1355 assert!(
1356 !workspace.exists(Path::new(".agent/tmp/issues.xml")),
1357 "issues.xml should be deleted via workspace"
1358 );
1359 assert!(
1360 !workspace.exists(Path::new(".agent/tmp/development_result.xml")),
1361 "development_result.xml should be deleted via workspace"
1362 );
1363 assert!(
1365 workspace.exists(Path::new(".agent/tmp/keep.txt")),
1366 "non-xml file should not be deleted"
1367 );
1368 }
1369
1370 #[test]
1375 fn test_save_checkpoint_uses_workspace() {
1376 use crate::agents::AgentRegistry;
1377 use crate::checkpoint::{ExecutionHistory, RunContext};
1378 use crate::config::Config;
1379 use crate::executor::MockProcessExecutor;
1380 use crate::logger::{Colors, Logger};
1381 use crate::phases::context::PhaseContext;
1382 use crate::pipeline::{Stats, Timer};
1383 use crate::prompts::template_context::TemplateContext;
1384 use crate::workspace::{MemoryWorkspace, Workspace};
1385 use std::path::{Path, PathBuf};
1386
1387 let workspace = MemoryWorkspace::new_test();
1389
1390 assert!(
1392 !workspace.exists(Path::new(".agent/checkpoint.json")),
1393 "checkpoint should not exist initially"
1394 );
1395
1396 let config = Config::default();
1398 let registry = AgentRegistry::new().unwrap();
1399 let colors = Colors { enabled: false };
1400 let logger = Logger::new(colors);
1401 let mut timer = Timer::new();
1402 let mut stats = Stats::default();
1403 let template_context = TemplateContext::default();
1404 let executor_arc = std::sync::Arc::new(MockProcessExecutor::new())
1405 as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1406 let repo_root = PathBuf::from("/test/repo");
1407
1408 let developer_agent = "claude";
1410 let reviewer_agent = "claude";
1411
1412 let mut ctx = PhaseContext {
1413 config: &config,
1414 registry: ®istry,
1415 logger: &logger,
1416 colors: &colors,
1417 timer: &mut timer,
1418 stats: &mut stats,
1419 developer_agent,
1420 reviewer_agent,
1421 review_guidelines: None,
1422 template_context: &template_context,
1423 run_context: RunContext::new(),
1424 execution_history: ExecutionHistory::new(),
1425 prompt_history: std::collections::HashMap::new(),
1426 executor: &*executor_arc,
1427 executor_arc: std::sync::Arc::clone(&executor_arc),
1428 repo_root: &repo_root,
1429 workspace: &workspace,
1430 };
1431
1432 let state = PipelineState::initial(1, 0);
1434 let mut handler = super::MainEffectHandler::new(state);
1435
1436 let result = handler.save_checkpoint(&mut ctx, CheckpointTrigger::PhaseTransition);
1438
1439 assert!(result.is_ok(), "save_checkpoint should succeed");
1440
1441 assert!(
1443 workspace.exists(Path::new(".agent/checkpoint.json")),
1444 "checkpoint should be written via workspace"
1445 );
1446
1447 let content = workspace.read(Path::new(".agent/checkpoint.json")).unwrap();
1449 assert!(
1450 content.contains("\"phase\""),
1451 "checkpoint should contain phase field"
1452 );
1453 assert!(
1454 content.contains("\"version\""),
1455 "checkpoint should contain version field"
1456 );
1457 }
1458
1459 #[test]
1460 fn test_read_commit_message_xml_falls_back_to_legacy_commit_xml() {
1461 use crate::workspace::MemoryWorkspace;
1462
1463 let workspace = MemoryWorkspace::new_test()
1464 .with_dir(".agent/tmp")
1465 .with_file(".agent/tmp/commit.xml", "<legacy/>");
1466
1467 let xml = read_commit_message_xml(&workspace).expect("expected xml");
1468 assert_eq!(xml, "<legacy/>");
1469 }
1470
1471 #[test]
1472 fn test_read_commit_message_xml_prefers_commit_message_xml() {
1473 use crate::workspace::MemoryWorkspace;
1474
1475 let workspace = MemoryWorkspace::new_test()
1476 .with_dir(".agent/tmp")
1477 .with_file(".agent/tmp/commit.xml", "<legacy/>")
1478 .with_file(".agent/tmp/commit_message.xml", "<preferred/>");
1479
1480 let xml = read_commit_message_xml(&workspace).expect("expected xml");
1481 assert_eq!(xml, "<preferred/>");
1482 }
1483
1484 #[test]
1485 fn test_invoke_agent_sanitizes_logfile_name() {
1486 use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1487 use crate::checkpoint::{ExecutionHistory, RunContext};
1488 use crate::config::Config;
1489 use crate::executor::MockProcessExecutor;
1490 use crate::logger::{Colors, Logger};
1491 use crate::phases::context::PhaseContext;
1492 use crate::pipeline::{Stats, Timer};
1493 use crate::prompts::template_context::TemplateContext;
1494 use crate::reducer::state::AgentChainState;
1495 use crate::workspace::MemoryWorkspace;
1496 use std::path::PathBuf;
1497
1498 let mut registry = AgentRegistry::new().unwrap();
1499 registry.register(
1500 "ccs/glm",
1501 AgentConfig {
1502 cmd: "mock-glm-agent".to_string(),
1503 output_flag: String::new(),
1504 yolo_flag: String::new(),
1505 verbose_flag: String::new(),
1506 can_commit: true,
1507 json_parser: JsonParserType::Generic,
1508 model_flag: None,
1509 print_flag: String::new(),
1510 streaming_flag: String::new(),
1511 session_flag: String::new(),
1512 env_vars: std::collections::HashMap::new(),
1513 display_name: Some("mock".to_string()),
1514 },
1515 );
1516
1517 let colors = Colors { enabled: false };
1518 let logger = Logger::new(colors);
1519 let mut timer = Timer::new();
1520 let mut stats = Stats::default();
1521 let template_context = TemplateContext::default();
1522
1523 let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1524 "mock-glm-agent",
1525 Ok(crate::executor::AgentCommandResult::success()),
1526 ));
1527 let executor_arc =
1528 mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1529
1530 let workspace = MemoryWorkspace::new_test();
1531 let repo_root = PathBuf::from("/test/repo");
1532 let config = Config::default();
1533
1534 let mut ctx = PhaseContext {
1535 config: &config,
1536 registry: ®istry,
1537 logger: &logger,
1538 colors: &colors,
1539 timer: &mut timer,
1540 stats: &mut stats,
1541 developer_agent: "ccs/glm",
1542 reviewer_agent: "ccs/glm",
1543 review_guidelines: None,
1544 template_context: &template_context,
1545 run_context: RunContext::new(),
1546 execution_history: ExecutionHistory::new(),
1547 prompt_history: std::collections::HashMap::new(),
1548 executor: executor_arc.as_ref(),
1549 executor_arc: executor_arc.clone(),
1550 repo_root: &repo_root,
1551 workspace: &workspace,
1552 };
1553
1554 let state = PipelineState {
1555 agent_chain: AgentChainState::initial().with_agents(
1556 vec!["ccs/glm".to_string()],
1557 vec![vec![]],
1558 AgentRole::Developer,
1559 ),
1560 ..PipelineState::initial(1, 0)
1561 };
1562
1563 let mut handler = super::MainEffectHandler::new(state);
1564 let _ = handler
1565 .invoke_agent(
1566 &mut ctx,
1567 AgentRole::Developer,
1568 "ccs/glm".to_string(),
1569 None,
1570 "prompt".to_string(),
1571 )
1572 .unwrap();
1573
1574 let calls = mock_executor.agent_calls_for("mock-glm-agent");
1575 assert_eq!(calls.len(), 1, "expected one agent spawn call");
1576 let logfile = &calls[0].logfile;
1577 assert!(
1578 !logfile.contains("ccs/glm"),
1579 "logfile should not contain raw agent name with slashes: {logfile}"
1580 );
1581 assert!(
1582 logfile.contains("ccs-glm"),
1583 "logfile should use sanitized agent name: {logfile}"
1584 );
1585 }
1586
1587 #[test]
1588 fn test_invoke_agent_applies_selected_model_to_command() {
1589 use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1590 use crate::checkpoint::{ExecutionHistory, RunContext};
1591 use crate::config::Config;
1592 use crate::executor::MockProcessExecutor;
1593 use crate::logger::{Colors, Logger};
1594 use crate::phases::context::PhaseContext;
1595 use crate::pipeline::{Stats, Timer};
1596 use crate::prompts::template_context::TemplateContext;
1597 use crate::reducer::state::AgentChainState;
1598 use crate::workspace::MemoryWorkspace;
1599 use std::path::PathBuf;
1600
1601 let mut registry = AgentRegistry::new().unwrap();
1602 registry.register(
1603 "mock-agent",
1604 AgentConfig {
1605 cmd: "mock-agent-bin".to_string(),
1606 output_flag: String::new(),
1607 yolo_flag: String::new(),
1608 verbose_flag: String::new(),
1609 can_commit: true,
1610 json_parser: JsonParserType::Generic,
1611 model_flag: None,
1612 print_flag: String::new(),
1613 streaming_flag: String::new(),
1614 session_flag: String::new(),
1615 env_vars: std::collections::HashMap::new(),
1616 display_name: Some("mock".to_string()),
1617 },
1618 );
1619
1620 let colors = Colors { enabled: false };
1621 let logger = Logger::new(colors);
1622 let mut timer = Timer::new();
1623 let mut stats = Stats::default();
1624 let template_context = TemplateContext::default();
1625
1626 let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1627 "mock-agent-bin",
1628 Ok(crate::executor::AgentCommandResult::success()),
1629 ));
1630 let executor_arc =
1631 mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1632
1633 let workspace = MemoryWorkspace::new_test();
1634 let repo_root = PathBuf::from("/test/repo");
1635 let config = Config::default();
1636
1637 let mut ctx = PhaseContext {
1638 config: &config,
1639 registry: ®istry,
1640 logger: &logger,
1641 colors: &colors,
1642 timer: &mut timer,
1643 stats: &mut stats,
1644 developer_agent: "mock-agent",
1645 reviewer_agent: "mock-agent",
1646 review_guidelines: None,
1647 template_context: &template_context,
1648 run_context: RunContext::new(),
1649 execution_history: ExecutionHistory::new(),
1650 prompt_history: std::collections::HashMap::new(),
1651 executor: executor_arc.as_ref(),
1652 executor_arc: executor_arc.clone(),
1653 repo_root: &repo_root,
1654 workspace: &workspace,
1655 };
1656
1657 let selected_model = "-m openai/gpt-5.2".to_string();
1658 let state = PipelineState {
1659 agent_chain: AgentChainState::initial().with_agents(
1660 vec!["mock-agent".to_string()],
1661 vec![vec![selected_model.clone()]],
1662 AgentRole::Developer,
1663 ),
1664 ..PipelineState::initial(1, 0)
1665 };
1666
1667 let mut handler = super::MainEffectHandler::new(state);
1668 let _ = handler
1669 .invoke_agent(
1670 &mut ctx,
1671 AgentRole::Developer,
1672 "mock-agent".to_string(),
1673 None,
1674 "prompt".to_string(),
1675 )
1676 .unwrap();
1677
1678 let calls = mock_executor.agent_calls_for("mock-agent-bin");
1679 assert_eq!(calls.len(), 1, "expected one agent spawn call");
1680 assert!(
1681 calls[0].args.iter().any(|a| a == "-m"
1682 || a == "-m=openai/gpt-5.2"
1683 || a.contains("openai/gpt-5.2")
1684 || a == &selected_model),
1685 "expected selected model to be threaded into agent command args; args={:?}",
1686 calls[0].args
1687 );
1688 }
1689
1690 #[test]
1691 fn test_invoke_agent_does_not_override_new_prompt_with_stale_rate_limit_prompt() {
1692 use crate::agents::{AgentConfig, AgentRegistry, JsonParserType};
1693 use crate::checkpoint::{ExecutionHistory, RunContext};
1694 use crate::config::Config;
1695 use crate::executor::MockProcessExecutor;
1696 use crate::logger::{Colors, Logger};
1697 use crate::phases::context::PhaseContext;
1698 use crate::pipeline::{Stats, Timer};
1699 use crate::prompts::template_context::TemplateContext;
1700 use crate::reducer::state::AgentChainState;
1701 use crate::workspace::MemoryWorkspace;
1702 use std::path::PathBuf;
1703
1704 let mut registry = AgentRegistry::new().unwrap();
1705 registry.register(
1706 "mock-agent",
1707 AgentConfig {
1708 cmd: "mock-agent-bin".to_string(),
1709 output_flag: String::new(),
1710 yolo_flag: String::new(),
1711 verbose_flag: String::new(),
1712 can_commit: true,
1713 json_parser: JsonParserType::Generic,
1714 model_flag: None,
1715 print_flag: String::new(),
1716 streaming_flag: String::new(),
1717 session_flag: String::new(),
1718 env_vars: std::collections::HashMap::new(),
1719 display_name: Some("mock".to_string()),
1720 },
1721 );
1722
1723 let colors = Colors { enabled: false };
1724 let logger = Logger::new(colors);
1725 let mut timer = Timer::new();
1726 let mut stats = Stats::default();
1727 let template_context = TemplateContext::default();
1728
1729 let mock_executor = std::sync::Arc::new(MockProcessExecutor::new().with_agent_result(
1730 "mock-agent-bin",
1731 Ok(crate::executor::AgentCommandResult::success()),
1732 ));
1733 let executor_arc =
1734 mock_executor.clone() as std::sync::Arc<dyn crate::executor::ProcessExecutor>;
1735
1736 let workspace = MemoryWorkspace::new_test();
1737 let repo_root = PathBuf::from("/test/repo");
1738 let config = Config::default();
1739
1740 let mut ctx = PhaseContext {
1741 config: &config,
1742 registry: ®istry,
1743 logger: &logger,
1744 colors: &colors,
1745 timer: &mut timer,
1746 stats: &mut stats,
1747 developer_agent: "mock-agent",
1748 reviewer_agent: "mock-agent",
1749 review_guidelines: None,
1750 template_context: &template_context,
1751 run_context: RunContext::new(),
1752 execution_history: ExecutionHistory::new(),
1753 prompt_history: std::collections::HashMap::new(),
1754 executor: executor_arc.as_ref(),
1755 executor_arc: executor_arc.clone(),
1756 repo_root: &repo_root,
1757 workspace: &workspace,
1758 };
1759
1760 let mut state = PipelineState {
1761 agent_chain: AgentChainState::initial().with_agents(
1762 vec!["mock-agent".to_string()],
1763 vec![vec![]],
1764 AgentRole::Developer,
1765 ),
1766 ..PipelineState::initial(1, 0)
1767 };
1768 state.agent_chain.rate_limit_continuation_prompt = Some("stale".to_string());
1769
1770 let mut handler = super::MainEffectHandler::new(state);
1771 let _ = handler
1772 .invoke_agent(
1773 &mut ctx,
1774 AgentRole::Developer,
1775 "mock-agent".to_string(),
1776 None,
1777 "fresh".to_string(),
1778 )
1779 .unwrap();
1780
1781 let calls = mock_executor.agent_calls_for("mock-agent-bin");
1782 assert_eq!(calls.len(), 1, "expected one agent spawn call");
1783 assert_eq!(
1784 calls[0].prompt,
1785 "fresh",
1786 "invoke_agent should not override a new prompt with a stale rate_limit_continuation_prompt"
1787 );
1788 }
1789
1790 #[test]
1791 fn test_decide_dev_iteration_next_step_invalid_output_does_not_consume_continuation_budget() {
1792 use crate::phases::development::DevAttemptResult;
1793 use crate::reducer::state::DevelopmentStatus;
1794
1795 let attempt = DevAttemptResult {
1796 had_error: true,
1797 output_valid: false,
1798 status: DevelopmentStatus::Failed,
1799 summary: "invalid xml".to_string(),
1800 files_changed: None,
1801 next_steps: None,
1802 };
1803
1804 let next = decide_dev_iteration_next_step(0, 2, &attempt);
1805
1806 assert_eq!(next, DevIterationNextStep::RetryInvalidOutput);
1807 }
1808
1809 #[test]
1810 fn test_decide_dev_iteration_next_step_partial_consumes_continuation_budget() {
1811 use crate::phases::development::DevAttemptResult;
1812 use crate::reducer::state::DevelopmentStatus;
1813
1814 let attempt = DevAttemptResult {
1815 had_error: false,
1816 output_valid: true,
1817 status: DevelopmentStatus::Partial,
1818 summary: "partial".to_string(),
1819 files_changed: None,
1820 next_steps: None,
1821 };
1822
1823 let next = decide_dev_iteration_next_step(0, 2, &attempt);
1824
1825 assert_eq!(
1826 next,
1827 DevIterationNextStep::Continue {
1828 next_continuation_attempt: 1
1829 }
1830 );
1831 }
1832
1833 #[test]
1834 fn test_decide_dev_iteration_next_step_partial_allows_max_continuations() {
1835 use crate::phases::development::DevAttemptResult;
1836 use crate::reducer::state::DevelopmentStatus;
1837
1838 let attempt = DevAttemptResult {
1839 had_error: false,
1840 output_valid: true,
1841 status: DevelopmentStatus::Partial,
1842 summary: "partial".to_string(),
1843 files_changed: None,
1844 next_steps: None,
1845 };
1846
1847 let next = decide_dev_iteration_next_step(1, 2, &attempt);
1848
1849 assert_eq!(
1850 next,
1851 DevIterationNextStep::Continue {
1852 next_continuation_attempt: 2
1853 }
1854 );
1855 }
1856
1857 #[test]
1858 fn test_decide_dev_iteration_next_step_partial_aborts_when_next_exceeds_max_continuations() {
1859 use crate::phases::development::DevAttemptResult;
1860 use crate::reducer::state::DevelopmentStatus;
1861
1862 let attempt = DevAttemptResult {
1863 had_error: false,
1864 output_valid: true,
1865 status: DevelopmentStatus::Partial,
1866 summary: "partial".to_string(),
1867 files_changed: None,
1868 next_steps: None,
1869 };
1870
1871 let next = decide_dev_iteration_next_step(2, 2, &attempt);
1872
1873 assert_eq!(
1874 next,
1875 DevIterationNextStep::Abort {
1876 next_continuation_attempt: 3
1877 }
1878 );
1879 }
1880}