Skip to main content

eval_magic/core/
run_mode.rs

1//! Run mode — *how* an eval is dispatched, independent of *which* harness runs
2//! it.
3//!
4//! There are two dispatch **mechanisms** in the code today:
5//!
6//! - [`DispatchMechanism::InSession`] — the runner hands tasks to in-session
7//!   subagents (Claude Code's Task tool). The reference is Claude Code.
8//! - [`DispatchMechanism::Cli`] — each task is dispatched through a one-shot
9//!   harness CLI subprocess (`codex exec`). The reference is Codex.
10//!
11//! These two mechanisms underpin the three *user-facing* run modes documented in
12//! the README: **fully-interactive** rides on [`InSession`](DispatchMechanism::InSession);
13//! **headless** and **hybrid** both ride on [`Cli`](DispatchMechanism::Cli),
14//! differing only in whether a human/agent session drives the loop — not in how
15//! a single task reaches the harness.
16//!
17//! This is distinct from the comparison [`Mode`](crate::core::Mode)
18//! (`new-skill` / `revision`), which selects the two conditions being compared,
19//! not the dispatch path.
20
21use serde::{Deserialize, Serialize};
22
23use crate::core::Harness;
24
25/// How a single dispatch is delivered to a harness. The primary code axis for
26/// run-mode concerns (next-steps guidance, transcript source).
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28pub enum DispatchMechanism {
29    /// In-session subagent dispatch (Claude Code's Task tool).
30    InSession,
31    /// One-shot harness CLI subprocess dispatch (`codex exec`).
32    Cli,
33}
34
35/// The user-facing run mode — *who/what drives the loop* plus which dispatch
36/// mechanism each task rides on. This is the parity vocabulary documented in the
37/// README (§Run modes); it maps down to a [`DispatchMechanism`] via
38/// [`RunMode::mechanism`]. `hybrid` and `headless` both ride on
39/// [`Cli`](DispatchMechanism::Cli) and differ only in whether a session drives
40/// the loop — a distinction we persist (in `conditions.json`) even though it
41/// doesn't change how a single task reaches the harness.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
43#[serde(rename_all = "kebab-case")]
44#[value(rename_all = "kebab-case")]
45pub enum RunMode {
46    /// In-session subagent dispatch (Claude Code's Task tool).
47    Interactive,
48    /// An agent session orchestrates while each dispatch shells out to the
49    /// harness CLI (`claude -p`, `codex exec`).
50    Hybrid,
51    /// No session drives the loop; eval-magic commands dispatch through the
52    /// harness CLI end to end.
53    Headless,
54}
55
56impl RunMode {
57    /// The dispatch mechanism this run mode rides on.
58    pub fn mechanism(self) -> DispatchMechanism {
59        match self {
60            RunMode::Interactive => DispatchMechanism::InSession,
61            RunMode::Hybrid | RunMode::Headless => DispatchMechanism::Cli,
62        }
63    }
64
65    /// The default run mode for a harness when `--run-mode` is omitted, chosen to
66    /// preserve today's behavior: Claude Code → interactive, the CLI-dispatch
67    /// harnesses → hybrid.
68    pub fn default_for(harness: Harness) -> RunMode {
69        match harness {
70            Harness::ClaudeCode => RunMode::Interactive,
71            Harness::Codex | Harness::OpenCode => RunMode::Hybrid,
72        }
73    }
74
75    /// The kebab-case identifier (matches the `--run-mode` flag values and the
76    /// serialized form in `conditions.json`).
77    pub fn as_str(self) -> &'static str {
78        match self {
79            RunMode::Interactive => "interactive",
80            RunMode::Hybrid => "hybrid",
81            RunMode::Headless => "headless",
82        }
83    }
84}
85
86/// Resolve the effective run mode for a harness, defaulting per harness when
87/// unspecified and rejecting unsupported `(harness, mode)` combinations. The
88/// `Err` string is operator-facing.
89pub fn resolve_run_mode(harness: Harness, requested: Option<RunMode>) -> Result<RunMode, String> {
90    let mode = requested.unwrap_or_else(|| RunMode::default_for(harness));
91    let supported: &[RunMode] = match harness {
92        // Claude Code wires every mode: in-session (interactive) plus both CLI
93        // modes (hybrid and headless ride the same `claude -p` mechanism).
94        Harness::ClaudeCode => &[RunMode::Interactive, RunMode::Hybrid, RunMode::Headless],
95        // Codex dispatches via subprocess, so in-session doesn't translate, but
96        // both CLI modes do (hybrid is agent-driven, headless human-driven).
97        Harness::Codex => &[RunMode::Hybrid, RunMode::Headless],
98        // OpenCode's CLI path is only partially wired (no transcript ingest), so
99        // only hybrid is advertised for now.
100        Harness::OpenCode => &[RunMode::Hybrid],
101    };
102    if supported.contains(&mode) {
103        return Ok(mode);
104    }
105    let supported_list = supported
106        .iter()
107        .map(|m| m.as_str())
108        .collect::<Vec<_>>()
109        .join(", ");
110    Err(format!(
111        "--run-mode {} is not supported for --harness {}; supported: {}",
112        mode.as_str(),
113        harness_label(harness),
114        supported_list,
115    ))
116}
117
118/// The kebab-case CLI identifier for a harness (for operator-facing messages).
119fn harness_label(harness: Harness) -> &'static str {
120    match harness {
121        Harness::ClaudeCode => "claude-code",
122        Harness::Codex => "codex",
123        Harness::OpenCode => "opencode",
124    }
125}
126
127/// Run-option support for a harness's currently wired dispatch mechanism.
128///
129/// This is intentionally narrower than full harness parity: it only describes
130/// options the generic `run` preflight must accept or reject before the build
131/// sequence starts. Harness-specific behavior still lives behind the adapter.
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
133pub struct HarnessRunCapabilities {
134    pub mechanism: DispatchMechanism,
135    pub supports_guard: bool,
136    pub supports_bootstrap_with_no_stage: bool,
137    pub supports_stage_name_with_no_stage: bool,
138}
139
140/// The focused capability table for generic `run` option validation.
141pub fn capabilities_for(harness: Harness) -> HarnessRunCapabilities {
142    match harness {
143        Harness::ClaudeCode => HarnessRunCapabilities {
144            mechanism: DispatchMechanism::InSession,
145            supports_guard: true,
146            supports_bootstrap_with_no_stage: true,
147            supports_stage_name_with_no_stage: true,
148        },
149        Harness::Codex => HarnessRunCapabilities {
150            mechanism: DispatchMechanism::Cli,
151            supports_guard: true,
152            supports_bootstrap_with_no_stage: false,
153            supports_stage_name_with_no_stage: false,
154        },
155        Harness::OpenCode => HarnessRunCapabilities {
156            mechanism: DispatchMechanism::Cli,
157            supports_guard: false,
158            supports_bootstrap_with_no_stage: true,
159            supports_stage_name_with_no_stage: true,
160        },
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167
168    #[test]
169    fn capabilities_capture_run_option_support_by_harness() {
170        let claude = capabilities_for(Harness::ClaudeCode);
171        assert_eq!(claude.mechanism, DispatchMechanism::InSession);
172        assert!(claude.supports_guard);
173        assert!(claude.supports_bootstrap_with_no_stage);
174        assert!(claude.supports_stage_name_with_no_stage);
175
176        let codex = capabilities_for(Harness::Codex);
177        assert_eq!(codex.mechanism, DispatchMechanism::Cli);
178        assert!(codex.supports_guard);
179        assert!(!codex.supports_bootstrap_with_no_stage);
180        assert!(!codex.supports_stage_name_with_no_stage);
181
182        let opencode = capabilities_for(Harness::OpenCode);
183        assert_eq!(opencode.mechanism, DispatchMechanism::Cli);
184        assert!(!opencode.supports_guard);
185        assert!(opencode.supports_bootstrap_with_no_stage);
186        assert!(opencode.supports_stage_name_with_no_stage);
187    }
188
189    #[test]
190    fn run_mode_mechanism_maps_each_mode() {
191        assert_eq!(
192            RunMode::Interactive.mechanism(),
193            DispatchMechanism::InSession
194        );
195        assert_eq!(RunMode::Hybrid.mechanism(), DispatchMechanism::Cli);
196        assert_eq!(RunMode::Headless.mechanism(), DispatchMechanism::Cli);
197    }
198
199    #[test]
200    fn run_mode_default_per_harness_preserves_today() {
201        assert_eq!(
202            RunMode::default_for(Harness::ClaudeCode),
203            RunMode::Interactive
204        );
205        assert_eq!(RunMode::default_for(Harness::Codex), RunMode::Hybrid);
206        assert_eq!(RunMode::default_for(Harness::OpenCode), RunMode::Hybrid);
207    }
208
209    #[test]
210    fn resolve_run_mode_defaults_when_unspecified() {
211        assert_eq!(
212            resolve_run_mode(Harness::ClaudeCode, None).unwrap(),
213            RunMode::Interactive
214        );
215        assert_eq!(
216            resolve_run_mode(Harness::Codex, None).unwrap(),
217            RunMode::Hybrid
218        );
219    }
220
221    #[test]
222    fn resolve_run_mode_accepts_claude_hybrid() {
223        assert_eq!(
224            resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Hybrid)).unwrap(),
225            RunMode::Hybrid
226        );
227    }
228
229    #[test]
230    fn resolve_run_mode_rejects_interactive_for_cli_harnesses() {
231        let err = resolve_run_mode(Harness::Codex, Some(RunMode::Interactive)).unwrap_err();
232        assert!(err.contains("interactive"), "message was: {err}");
233        assert!(err.contains("codex"), "message was: {err}");
234        assert!(resolve_run_mode(Harness::OpenCode, Some(RunMode::Interactive)).is_err());
235    }
236
237    #[test]
238    fn resolve_run_mode_accepts_claude_headless() {
239        assert_eq!(
240            resolve_run_mode(Harness::ClaudeCode, Some(RunMode::Headless)).unwrap(),
241            RunMode::Headless
242        );
243    }
244
245    #[test]
246    fn resolve_run_mode_accepts_codex_headless() {
247        assert_eq!(
248            resolve_run_mode(Harness::Codex, Some(RunMode::Headless)).unwrap(),
249            RunMode::Headless
250        );
251    }
252
253    #[test]
254    fn run_mode_serde_roundtrips_kebab_case() {
255        assert_eq!(
256            serde_json::to_string(&RunMode::Hybrid).unwrap(),
257            "\"hybrid\""
258        );
259        let parsed: RunMode = serde_json::from_str("\"headless\"").unwrap();
260        assert_eq!(parsed, RunMode::Headless);
261    }
262}