Skip to main content

ralph/agent/
resolve.rs

1//! Agent override resolution from CLI arguments.
2//!
3//! Responsibilities:
4//! - Resolve agent overrides from CLI arguments for run commands (with phases).
5//! - Resolve agent overrides from CLI arguments for scan/task commands (simpler version).
6//! - Define AgentOverrides struct holding all resolved override values.
7//!
8//! Not handled here:
9//! - CLI argument struct definitions (see `super::args`).
10//! - Parsing functions (see `super::parse`).
11//! - RepoPrompt flag resolution (see `super::repoprompt`).
12//!
13//! Invariants/assumptions:
14//! - Override resolution validates runner/model compatibility.
15//! - Phase-specific overrides are only populated when at least one phase flag is set.
16//! - The --quick flag overrides --phases to set phases=1.
17
18use crate::config;
19use crate::contracts::{
20    GitRevertMode, Model, PhaseOverrideConfig, PhaseOverrides, ReasoningEffort, Runner,
21    RunnerCliOptionsPatch,
22};
23use crate::runner;
24use anyhow::Result;
25
26use super::args::{AgentArgs, RunAgentArgs};
27use super::parse::{parse_git_revert_mode, parse_runner, parse_runner_cli_patch};
28use super::repoprompt::{
29    RepopromptFlags, repoprompt_flags_from_mode, resolve_repoprompt_flags_from_agent_config,
30};
31
32/// Helper macro to resolve a boolean CLI flag with enable/disable variants.
33///
34/// Takes the enable flag expression and disable flag expression, returns
35/// `Some(true)` if enabled, `Some(false)` if disabled, or `None` if neither.
36macro_rules! resolve_bool_flag {
37    ($enable:expr, $disable:expr) => {
38        if $enable {
39            Some(true)
40        } else if $disable {
41            Some(false)
42        } else {
43            None
44        }
45    };
46}
47
48/// Helper macro to resolve a simple optional boolean flag.
49///
50/// Returns `Some(true)` if the flag is set, `None` otherwise.
51macro_rules! resolve_simple_flag {
52    ($flag:expr) => {
53        if $flag { Some(true) } else { None }
54    };
55}
56
57/// Agent overrides from CLI arguments.
58///
59/// These overrides take precedence over task.agent and config defaults.
60#[derive(Debug, Clone, Default)]
61pub struct AgentOverrides {
62    /// Named configuration profile to apply.
63    pub profile: Option<String>,
64    pub runner: Option<Runner>,
65    pub model: Option<Model>,
66    pub reasoning_effort: Option<ReasoningEffort>,
67    pub runner_cli: RunnerCliOptionsPatch,
68    /// Execution shape override:
69    /// - 1 => single-pass execution
70    /// - 2 => two-pass execution (plan then implement)
71    /// - 3 => three-pass execution (plan, implement+CI, review+complete)
72    pub phases: Option<u8>,
73    pub repoprompt_plan_required: Option<bool>,
74    pub repoprompt_tool_injection: Option<bool>,
75    pub git_revert_mode: Option<GitRevertMode>,
76    pub git_commit_push_enabled: Option<bool>,
77    pub include_draft: Option<bool>,
78    /// Enable/disable desktop notification on task completion.
79    pub notify_on_complete: Option<bool>,
80    /// Enable/disable desktop notification on task failure.
81    pub notify_on_fail: Option<bool>,
82    /// Enable/disable desktop notification when loop completes.
83    pub notify_on_loop_complete: Option<bool>,
84    /// Enable sound alert with notification.
85    pub notify_sound: Option<bool>,
86    /// Enable strict LFS validation before commit.
87    pub lfs_check: Option<bool>,
88    /// Disable progress indicators and celebrations.
89    pub no_progress: Option<bool>,
90    /// Per-phase overrides from CLI (phase1, phase2, phase3).
91    pub phase_overrides: Option<PhaseOverrides>,
92}
93
94/// Resolve agent overrides from CLI arguments for run commands.
95///
96/// This parses the CLI arguments and validates runner/model compatibility.
97pub fn resolve_run_agent_overrides(args: &RunAgentArgs) -> Result<AgentOverrides> {
98    use crate::runner;
99
100    let profile = args.profile.clone();
101
102    let runner = match args.runner.as_deref() {
103        Some(value) => Some(parse_runner(value)?),
104        None => None,
105    };
106
107    let model = match args.model.as_deref() {
108        Some(value) => Some(runner::parse_model(value)?),
109        None => None,
110    };
111
112    let reasoning_effort = match args.effort.as_deref() {
113        Some(value) => Some(runner::parse_reasoning_effort(value)?),
114        None => None,
115    };
116    let runner_cli = parse_runner_cli_patch(&args.runner_cli)?;
117
118    if let (Some(runner_kind), Some(model)) = (runner.as_ref(), model.as_ref()) {
119        runner::validate_model_for_runner(runner_kind, model)?;
120    }
121
122    let repoprompt_override = args.repo_prompt.map(repoprompt_flags_from_mode);
123
124    let git_revert_mode = match args.git_revert_mode.as_deref() {
125        Some(value) => Some(parse_git_revert_mode(value)?),
126        None => None,
127    };
128
129    let git_commit_push_enabled =
130        resolve_bool_flag!(args.git_commit_push_on, args.git_commit_push_off);
131    let include_draft = resolve_simple_flag!(args.include_draft);
132
133    // Handle --quick flag: when set, override phases to 1 (single-pass execution)
134    let phases = if args.quick { Some(1) } else { args.phases };
135
136    // Handle notification flags
137    let notify_on_complete = resolve_bool_flag!(args.notify, args.no_notify);
138    let notify_on_fail = resolve_bool_flag!(args.notify_fail, args.no_notify_fail);
139    let notify_sound = resolve_simple_flag!(args.notify_sound);
140    let lfs_check = resolve_simple_flag!(args.lfs_check);
141    let no_progress = resolve_simple_flag!(args.no_progress);
142
143    // Parse phase-specific overrides using helper to avoid duplication
144    let phase_overrides = resolve_phase_overrides(args)?;
145
146    Ok(AgentOverrides {
147        profile,
148        runner,
149        model,
150        reasoning_effort,
151        runner_cli,
152        phases,
153        repoprompt_plan_required: repoprompt_override.map(|flags| flags.plan_required),
154        repoprompt_tool_injection: repoprompt_override.map(|flags| flags.tool_injection),
155        git_revert_mode,
156        git_commit_push_enabled,
157        include_draft,
158        notify_on_complete,
159        notify_on_fail,
160        notify_on_loop_complete: None,
161        notify_sound,
162        lfs_check,
163        no_progress,
164        phase_overrides,
165    })
166}
167
168/// Resolve agent overrides from CLI arguments for scan/task commands.
169///
170/// This is a simpler version that doesn't include phases.
171pub fn resolve_agent_overrides(args: &AgentArgs) -> Result<AgentOverrides> {
172    use crate::runner;
173
174    let runner = match args.runner.as_deref() {
175        Some(value) => Some(parse_runner(value)?),
176        None => None,
177    };
178
179    let model = match args.model.as_deref() {
180        Some(value) => Some(runner::parse_model(value)?),
181        None => None,
182    };
183
184    let reasoning_effort = match args.effort.as_deref() {
185        Some(value) => Some(runner::parse_reasoning_effort(value)?),
186        None => None,
187    };
188
189    if let (Some(runner_kind), Some(model)) = (runner.as_ref(), model.as_ref()) {
190        runner::validate_model_for_runner(runner_kind, model)?;
191    }
192
193    let repoprompt_override = args.repo_prompt.map(repoprompt_flags_from_mode);
194    let runner_cli = parse_runner_cli_patch(&args.runner_cli)?;
195
196    Ok(AgentOverrides {
197        profile: None,
198        runner,
199        model,
200        reasoning_effort,
201        runner_cli,
202        phases: None,
203        repoprompt_plan_required: repoprompt_override.map(|flags| flags.plan_required),
204        repoprompt_tool_injection: repoprompt_override.map(|flags| flags.tool_injection),
205        git_revert_mode: None,
206        git_commit_push_enabled: None,
207        include_draft: None,
208        notify_on_complete: None,
209        notify_on_fail: None,
210        notify_on_loop_complete: None,
211        notify_sound: None,
212        lfs_check: None,
213        no_progress: None,
214        phase_overrides: None,
215    })
216}
217
218/// Helper to resolve phase overrides for a single phase.
219///
220/// Takes optional runner, model, and effort strings and returns a PhaseOverrideConfig
221/// if any are provided. This eliminates DRY violations in the main resolution function.
222fn resolve_single_phase_override(
223    runner: Option<&str>,
224    model: Option<&str>,
225    effort: Option<&str>,
226) -> Result<Option<PhaseOverrideConfig>> {
227    if runner.is_none() && model.is_none() && effort.is_none() {
228        return Ok(None);
229    }
230
231    Ok(Some(PhaseOverrideConfig {
232        runner: runner.map(parse_runner).transpose()?,
233        model: model.map(runner::parse_model).transpose()?,
234        reasoning_effort: effort.map(runner::parse_reasoning_effort).transpose()?,
235    }))
236}
237
238/// Resolve phase-specific overrides from CLI arguments.
239///
240/// This centralizes the phase override resolution to eliminate the DRY violation
241/// of having nearly identical code blocks for each phase.
242fn resolve_phase_overrides(args: &RunAgentArgs) -> Result<Option<PhaseOverrides>> {
243    let phase1 = resolve_single_phase_override(
244        args.runner_phase1.as_deref(),
245        args.model_phase1.as_deref(),
246        args.effort_phase1.as_deref(),
247    )?;
248    let phase2 = resolve_single_phase_override(
249        args.runner_phase2.as_deref(),
250        args.model_phase2.as_deref(),
251        args.effort_phase2.as_deref(),
252    )?;
253    let phase3 = resolve_single_phase_override(
254        args.runner_phase3.as_deref(),
255        args.model_phase3.as_deref(),
256        args.effort_phase3.as_deref(),
257    )?;
258
259    if phase1.is_none() && phase2.is_none() && phase3.is_none() {
260        Ok(None)
261    } else {
262        Ok(Some(PhaseOverrides {
263            phase1,
264            phase2,
265            phase3,
266        }))
267    }
268}
269
270/// Resolve RepoPrompt flags from overrides, falling back to config.
271pub fn resolve_repoprompt_flags_from_overrides(
272    overrides: &AgentOverrides,
273    resolved: &config::Resolved,
274) -> RepopromptFlags {
275    let config_flags = resolve_repoprompt_flags_from_agent_config(&resolved.config.agent);
276    let plan_required = overrides
277        .repoprompt_plan_required
278        .unwrap_or(config_flags.plan_required);
279    let tool_injection = overrides
280        .repoprompt_tool_injection
281        .unwrap_or(config_flags.tool_injection);
282    RepopromptFlags {
283        plan_required,
284        tool_injection,
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::agent::args::RunnerCliArgs;
292    use crate::contracts::{
293        GitRevertMode, Model, ReasoningEffort, Runner, RunnerApprovalMode, RunnerPlanMode,
294        RunnerSandboxMode,
295    };
296
297    #[test]
298    fn resolve_agent_overrides_parses_valid_args() {
299        let args = AgentArgs {
300            runner: Some("opencode".to_string()),
301            model: Some("gpt-5.2".to_string()),
302            effort: None,
303            repo_prompt: None,
304            runner_cli: RunnerCliArgs::default(),
305        };
306
307        let overrides = resolve_agent_overrides(&args).unwrap();
308        assert_eq!(overrides.runner, Some(Runner::Opencode));
309        assert_eq!(overrides.model, Some(Model::Gpt52));
310        assert_eq!(overrides.reasoning_effort, None);
311        assert_eq!(overrides.repoprompt_plan_required, None);
312        assert_eq!(overrides.repoprompt_tool_injection, None);
313        assert_eq!(overrides.git_revert_mode, None);
314        assert_eq!(overrides.git_commit_push_enabled, None);
315        assert_eq!(overrides.include_draft, None);
316    }
317
318    #[test]
319    fn resolve_agent_overrides_sets_rp_flags() {
320        use crate::agent::repoprompt::RepoPromptMode;
321        let args = AgentArgs {
322            runner: None,
323            model: None,
324            effort: None,
325            repo_prompt: Some(RepoPromptMode::Plan),
326            runner_cli: RunnerCliArgs::default(),
327        };
328
329        let overrides = resolve_agent_overrides(&args).unwrap();
330        assert_eq!(overrides.repoprompt_plan_required, Some(true));
331        assert_eq!(overrides.repoprompt_tool_injection, Some(true));
332        assert_eq!(overrides.git_revert_mode, None);
333        assert_eq!(overrides.git_commit_push_enabled, None);
334        assert_eq!(overrides.include_draft, None);
335    }
336
337    #[test]
338    fn resolve_agent_overrides_maps_repo_prompt_modes() {
339        use crate::agent::repoprompt::RepoPromptMode;
340        let tools_args = AgentArgs {
341            runner: None,
342            model: None,
343            effort: None,
344            repo_prompt: Some(RepoPromptMode::Tools),
345            runner_cli: RunnerCliArgs::default(),
346        };
347        let tools_overrides = resolve_agent_overrides(&tools_args).unwrap();
348        assert_eq!(tools_overrides.repoprompt_plan_required, Some(false));
349        assert_eq!(tools_overrides.repoprompt_tool_injection, Some(true));
350
351        let off_args = AgentArgs {
352            runner: None,
353            model: None,
354            effort: None,
355            repo_prompt: Some(RepoPromptMode::Off),
356            runner_cli: RunnerCliArgs::default(),
357        };
358        let off_overrides = resolve_agent_overrides(&off_args).unwrap();
359        assert_eq!(off_overrides.repoprompt_plan_required, Some(false));
360        assert_eq!(off_overrides.repoprompt_tool_injection, Some(false));
361    }
362
363    #[test]
364    fn resolve_agent_overrides_parses_runner_cli_args() {
365        let args = AgentArgs {
366            runner: None,
367            model: None,
368            effort: None,
369            repo_prompt: None,
370            runner_cli: RunnerCliArgs {
371                approval_mode: Some("auto-edits".to_string()),
372                sandbox: Some("disabled".to_string()),
373                ..RunnerCliArgs::default()
374            },
375        };
376
377        let overrides = resolve_agent_overrides(&args).unwrap();
378        assert_eq!(
379            overrides.runner_cli.approval_mode,
380            Some(RunnerApprovalMode::AutoEdits)
381        );
382        assert_eq!(
383            overrides.runner_cli.sandbox,
384            Some(RunnerSandboxMode::Disabled)
385        );
386    }
387
388    #[test]
389    fn resolve_run_agent_overrides_includes_phases() {
390        let args = RunAgentArgs {
391            profile: None,
392            runner: Some("codex".to_string()),
393            model: Some("gpt-5.2-codex".to_string()),
394            effort: Some("high".to_string()),
395            runner_cli: RunnerCliArgs::default(),
396            phases: Some(2),
397            quick: false,
398            repo_prompt: None,
399            git_revert_mode: Some("enabled".to_string()),
400            git_commit_push_on: false,
401            git_commit_push_off: true,
402            include_draft: true,
403            notify: false,
404            no_notify: false,
405            notify_fail: false,
406            no_notify_fail: false,
407            notify_sound: false,
408            lfs_check: false,
409            no_progress: false,
410            runner_phase1: None,
411            model_phase1: None,
412            effort_phase1: None,
413            runner_phase2: None,
414            model_phase2: None,
415            effort_phase2: None,
416            runner_phase3: None,
417            model_phase3: None,
418            effort_phase3: None,
419        };
420
421        let overrides = resolve_run_agent_overrides(&args).unwrap();
422        assert_eq!(overrides.runner, Some(Runner::Codex));
423        assert_eq!(overrides.model, Some(Model::Gpt52Codex));
424        assert_eq!(overrides.reasoning_effort, Some(ReasoningEffort::High));
425        assert_eq!(overrides.phases, Some(2));
426        assert_eq!(overrides.git_revert_mode, Some(GitRevertMode::Enabled));
427        assert_eq!(overrides.git_commit_push_enabled, Some(false));
428        assert_eq!(overrides.include_draft, Some(true));
429    }
430
431    #[test]
432    fn resolve_run_agent_overrides_parses_runner_cli_args() {
433        let args = RunAgentArgs {
434            profile: None,
435            runner: None,
436            model: None,
437            effort: None,
438            runner_cli: RunnerCliArgs {
439                approval_mode: Some("yolo".to_string()),
440                plan_mode: Some("enabled".to_string()),
441                ..RunnerCliArgs::default()
442            },
443            phases: None,
444            quick: false,
445            repo_prompt: None,
446            git_revert_mode: None,
447            git_commit_push_on: false,
448            git_commit_push_off: false,
449            include_draft: false,
450            notify: false,
451            no_notify: false,
452            notify_fail: false,
453            no_notify_fail: false,
454            notify_sound: false,
455            lfs_check: false,
456            no_progress: false,
457            runner_phase1: None,
458            model_phase1: None,
459            effort_phase1: None,
460            runner_phase2: None,
461            model_phase2: None,
462            effort_phase2: None,
463            runner_phase3: None,
464            model_phase3: None,
465            effort_phase3: None,
466        };
467
468        let overrides = resolve_run_agent_overrides(&args).unwrap();
469        assert_eq!(
470            overrides.runner_cli.approval_mode,
471            Some(RunnerApprovalMode::Yolo)
472        );
473        assert_eq!(
474            overrides.runner_cli.plan_mode,
475            Some(RunnerPlanMode::Enabled)
476        );
477    }
478
479    #[test]
480    fn resolve_run_agent_overrides_quick_flag_sets_phases_to_one() {
481        let args = RunAgentArgs {
482            profile: None,
483            runner: None,
484            model: None,
485            effort: None,
486            runner_cli: RunnerCliArgs::default(),
487            phases: None,
488            quick: true,
489            repo_prompt: None,
490            git_revert_mode: None,
491            git_commit_push_on: false,
492            git_commit_push_off: false,
493            include_draft: false,
494            notify: false,
495            no_notify: false,
496            notify_fail: false,
497            no_notify_fail: false,
498            notify_sound: false,
499            lfs_check: false,
500            no_progress: false,
501            runner_phase1: None,
502            model_phase1: None,
503            effort_phase1: None,
504            runner_phase2: None,
505            model_phase2: None,
506            effort_phase2: None,
507            runner_phase3: None,
508            model_phase3: None,
509            effort_phase3: None,
510        };
511
512        let overrides = resolve_run_agent_overrides(&args).unwrap();
513        assert_eq!(overrides.phases, Some(1));
514    }
515
516    #[test]
517    fn resolve_run_agent_overrides_phases_override_takes_precedence_when_quick_false() {
518        let args = RunAgentArgs {
519            profile: None,
520            runner: None,
521            model: None,
522            effort: None,
523            runner_cli: RunnerCliArgs::default(),
524            phases: Some(3),
525            quick: false,
526            repo_prompt: None,
527            git_revert_mode: None,
528            git_commit_push_on: false,
529            git_commit_push_off: false,
530            include_draft: false,
531            notify: false,
532            no_notify: false,
533            notify_fail: false,
534            no_notify_fail: false,
535            notify_sound: false,
536            lfs_check: false,
537            no_progress: false,
538            runner_phase1: None,
539            model_phase1: None,
540            effort_phase1: None,
541            runner_phase2: None,
542            model_phase2: None,
543            effort_phase2: None,
544            runner_phase3: None,
545            model_phase3: None,
546            effort_phase3: None,
547        };
548
549        let overrides = resolve_run_agent_overrides(&args).unwrap();
550        assert_eq!(overrides.phases, Some(3));
551    }
552
553    #[test]
554    fn resolve_run_agent_overrides_phase_flags_parsed_correctly() {
555        let args = RunAgentArgs {
556            profile: None,
557            runner: Some("claude".to_string()),
558            model: Some("sonnet".to_string()),
559            effort: None,
560            runner_cli: RunnerCliArgs::default(),
561            phases: Some(3),
562            quick: false,
563            repo_prompt: None,
564            git_revert_mode: None,
565            git_commit_push_on: false,
566            git_commit_push_off: false,
567            include_draft: false,
568            notify: false,
569            no_notify: false,
570            notify_fail: false,
571            no_notify_fail: false,
572            notify_sound: false,
573            lfs_check: false,
574            no_progress: false,
575            runner_phase1: Some("codex".to_string()),
576            model_phase1: Some("gpt-5.2-codex".to_string()),
577            effort_phase1: Some("high".to_string()),
578            runner_phase2: Some("claude".to_string()),
579            model_phase2: Some("opus".to_string()),
580            effort_phase2: None,
581            runner_phase3: Some("codex".to_string()),
582            model_phase3: Some("gpt-5.2-codex".to_string()),
583            effort_phase3: Some("medium".to_string()),
584        };
585
586        let overrides = resolve_run_agent_overrides(&args).unwrap();
587
588        // Global overrides should still be set
589        assert_eq!(overrides.runner, Some(Runner::Claude));
590        assert_eq!(overrides.model, Some(Model::Custom("sonnet".to_string())));
591
592        // Phase overrides should be populated
593        let phase_overrides = overrides
594            .phase_overrides
595            .expect("phase_overrides should be set");
596
597        // Phase 1
598        let phase1 = phase_overrides.phase1.expect("phase1 should be set");
599        assert_eq!(phase1.runner, Some(Runner::Codex));
600        assert_eq!(phase1.model, Some(Model::Gpt52Codex));
601        assert_eq!(phase1.reasoning_effort, Some(ReasoningEffort::High));
602
603        // Phase 2
604        let phase2 = phase_overrides.phase2.expect("phase2 should be set");
605        assert_eq!(phase2.runner, Some(Runner::Claude));
606        assert_eq!(phase2.model, Some(Model::Custom("opus".to_string())));
607        assert_eq!(phase2.reasoning_effort, None);
608
609        // Phase 3
610        let phase3 = phase_overrides.phase3.expect("phase3 should be set");
611        assert_eq!(phase3.runner, Some(Runner::Codex));
612        assert_eq!(phase3.model, Some(Model::Gpt52Codex));
613        assert_eq!(phase3.reasoning_effort, Some(ReasoningEffort::Medium));
614    }
615
616    #[test]
617    fn resolve_run_agent_overrides_phase_flags_partial() {
618        // Test that partial phase overrides work (e.g., only --runner-phase1)
619        let args = RunAgentArgs {
620            profile: None,
621            runner: None,
622            model: None,
623            effort: None,
624            runner_cli: RunnerCliArgs::default(),
625            phases: None,
626            quick: false,
627            repo_prompt: None,
628            git_revert_mode: None,
629            git_commit_push_on: false,
630            git_commit_push_off: false,
631            include_draft: false,
632            notify: false,
633            no_notify: false,
634            notify_fail: false,
635            no_notify_fail: false,
636            notify_sound: false,
637            lfs_check: false,
638            no_progress: false,
639            runner_phase1: Some("codex".to_string()),
640            model_phase1: None,
641            effort_phase1: None,
642            runner_phase2: None,
643            model_phase2: None,
644            effort_phase2: None,
645            runner_phase3: None,
646            model_phase3: None,
647            effort_phase3: None,
648        };
649
650        let overrides = resolve_run_agent_overrides(&args).unwrap();
651
652        let phase_overrides = overrides
653            .phase_overrides
654            .expect("phase_overrides should be set");
655
656        // Only phase1 should be set
657        let phase1 = phase_overrides.phase1.expect("phase1 should be set");
658        assert_eq!(phase1.runner, Some(Runner::Codex));
659        assert_eq!(phase1.model, None);
660        assert_eq!(phase1.reasoning_effort, None);
661
662        // Phase 2 and 3 should be None
663        assert!(phase_overrides.phase2.is_none());
664        assert!(phase_overrides.phase3.is_none());
665    }
666
667    #[test]
668    fn resolve_run_agent_overrides_empty_phase_flags_returns_none() {
669        // Test that no phase flags results in phase_overrides: None
670        let args = RunAgentArgs {
671            profile: None,
672            runner: None,
673            model: None,
674            effort: None,
675            runner_cli: RunnerCliArgs::default(),
676            phases: None,
677            quick: false,
678            repo_prompt: None,
679            git_revert_mode: None,
680            git_commit_push_on: false,
681            git_commit_push_off: false,
682            include_draft: false,
683            notify: false,
684            no_notify: false,
685            notify_fail: false,
686            no_notify_fail: false,
687            notify_sound: false,
688            lfs_check: false,
689            no_progress: false,
690            runner_phase1: None,
691            model_phase1: None,
692            effort_phase1: None,
693            runner_phase2: None,
694            model_phase2: None,
695            effort_phase2: None,
696            runner_phase3: None,
697            model_phase3: None,
698            effort_phase3: None,
699        };
700
701        let overrides = resolve_run_agent_overrides(&args).unwrap();
702        assert!(overrides.phase_overrides.is_none());
703    }
704
705    #[test]
706    fn resolve_run_agent_overrides_invalid_runner_phase_includes_phase_in_error() {
707        // Test that invalid runner for a phase produces error
708        let args = RunAgentArgs {
709            profile: None,
710            runner: None,
711            model: None,
712            effort: None,
713            runner_cli: RunnerCliArgs::default(),
714            phases: None,
715            quick: false,
716            repo_prompt: None,
717            git_revert_mode: None,
718            git_commit_push_on: false,
719            git_commit_push_off: false,
720            include_draft: false,
721            notify: false,
722            no_notify: false,
723            notify_fail: false,
724            no_notify_fail: false,
725            notify_sound: false,
726            lfs_check: false,
727            no_progress: false,
728            runner_phase1: Some("invalid_runner".to_string()),
729            model_phase1: None,
730            effort_phase1: None,
731            runner_phase2: None,
732            model_phase2: None,
733            effort_phase2: None,
734            runner_phase3: None,
735            model_phase3: None,
736            effort_phase3: None,
737        };
738
739        let result = resolve_run_agent_overrides(&args);
740        assert!(result.is_err());
741        let err = result.unwrap_err().to_string();
742        assert!(err.contains("Invalid runner"));
743    }
744}