Skip to main content

layover_core/
config.rs

1//! Parsing of the single `layover.toml` factory definition.
2//!
3//! Unknown fields are rejected rather than ignored: a typo in a factory definition should fail
4//! at load time, while a human is still watching, rather than silently changing behaviour.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use serde::Deserialize;
11
12use crate::agent::{Agent, AgentName};
13use crate::cost::{RateCard, Reserve};
14use crate::pipeline::{Pipeline, PipelineName};
15use crate::route::Route;
16
17/// Filesystem and network locations used by the Tower.
18#[derive(Debug, Clone, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct Paths {
21    /// Root directory holding one hangar per agent.
22    #[serde(default = "default_state_dir")]
23    pub state_dir: PathBuf,
24    /// The shared working directory agents operate in.
25    #[serde(default = "default_work_dir")]
26    pub work_dir: PathBuf,
27    /// Path to the shared Logbook.
28    #[serde(default = "default_logbook")]
29    pub logbook: PathBuf,
30    /// Directory that `prompt_file` paths are resolved against.
31    #[serde(default = "default_prompt_dir")]
32    pub prompt_dir: PathBuf,
33    /// Address the HTTP API binds to.
34    #[serde(default = "default_http_addr")]
35    pub http_addr: String,
36}
37
38impl Default for Paths {
39    fn default() -> Self {
40        Self {
41            state_dir: default_state_dir(),
42            work_dir: default_work_dir(),
43            logbook: default_logbook(),
44            prompt_dir: default_prompt_dir(),
45            http_addr: default_http_addr(),
46        }
47    }
48}
49
50/// Factory-wide defaults, overridable per agent.
51#[derive(Debug, Clone, Deserialize)]
52#[serde(deny_unknown_fields)]
53pub struct Defaults {
54    /// Runner used by agents that do not name one.
55    #[serde(default)]
56    pub runner: Option<String>,
57    /// Names of environment variables forwarded to every agent's CLI.
58    ///
59    /// Most factories run one CLI that needs one credential, and repeating it under every agent
60    /// is a list that falls out of step the first time an agent is added. Per-agent
61    /// [`crate::Agent::env_from`] adds to this rather than replacing it, so an agent that needs a
62    /// token nobody else should hold names only that token.
63    #[serde(default)]
64    pub env_from: Vec<String>,
65    /// Maximum depth of a chain, in flights.
66    ///
67    /// A hop is spent per flight and branches inherit the remaining count, so this bounds depth
68    /// and not breadth. See [`crate::itinerary`].
69    #[serde(default = "default_max_hops")]
70    pub max_hops: u32,
71    /// Shared cost budget for an entire itinerary, in US dollars.
72    #[serde(default = "default_fuel_usd")]
73    pub fuel_usd: f64,
74    /// Deterministic backstop on total runs per itinerary.
75    ///
76    /// Fuel is the intended bound on breadth, but it depends on runners reporting cost. This cap
77    /// needs no runner cooperation and is therefore always enforced.
78    #[serde(default = "default_max_runs")]
79    pub max_runs: u32,
80    /// Wall-clock limit for a single run.
81    #[serde(default = "default_timeout_sec")]
82    pub timeout_sec: u64,
83    /// How many times interrupted work may be restarted automatically.
84    ///
85    /// A crash loop that restarts itself forever is a fork bomb that looks like resilience, so
86    /// recovery is bounded like every other rail. Zero disables automatic recovery.
87    #[serde(default = "default_max_recovery_attempts")]
88    pub max_recovery_attempts: u32,
89    /// How many runs may be live at once, across the whole factory.
90    ///
91    /// The one rail that protects the machine rather than a budget. Hops bounds depth, Fuel and
92    /// the Reserve bound money, the run cap bounds a chain's total — none of them bounds how many
93    /// headless CLIs start simultaneously, which is what a fan-out over an unknown number of
94    /// items produces. Excess work queues rather than being refused: a busy machine will not be
95    /// busy in a minute, so delaying is right where refusing would silently drop work.
96    #[serde(default = "default_max_concurrent_runs")]
97    pub max_concurrent_runs: usize,
98    /// How many generations of spawning separate a chain from the trigger that began it.
99    ///
100    /// A spawned itinerary gets fresh Hops, so Hops cannot see across chains: without this an
101    /// agent that spawns an agent that spawns an agent recurses forever while every individual
102    /// chain stays perfectly inside its rails. This is Hops, one level up.
103    #[serde(default = "default_max_spawn_generations")]
104    pub max_spawn_generations: u32,
105}
106
107impl Default for Defaults {
108    fn default() -> Self {
109        Self {
110            runner: None,
111            env_from: Vec::new(),
112            max_hops: default_max_hops(),
113            fuel_usd: default_fuel_usd(),
114            max_runs: default_max_runs(),
115            timeout_sec: default_timeout_sec(),
116            max_recovery_attempts: default_max_recovery_attempts(),
117            max_concurrent_runs: default_max_concurrent_runs(),
118            max_spawn_generations: default_max_spawn_generations(),
119        }
120    }
121}
122
123/// How Layover passes its MCP endpoint to a runner.
124#[derive(Debug, Clone, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub struct McpWiring {
127    /// Command-line flag carrying the MCP configuration.
128    pub flag: String,
129    /// Configuration dialect the runner expects.
130    pub format: String,
131    /// Prepended to the path the flag carries.
132    ///
133    /// Copilot CLI's `--additional-mcp-config` takes *either* a JSON string or a file path, and
134    /// tells the two apart by a leading `@`. Without it the path is read as JSON, which fails as
135    /// a parse error about the factory's own configuration rather than anything recognisable.
136    ///
137    /// Empty for CLIs that take a plain path, which is most of them.
138    #[serde(default)]
139    pub prefix: String,
140}
141
142impl McpWiring {
143    /// The argument that follows [`Self::flag`].
144    #[must_use]
145    pub fn argument(&self, path: &str) -> String {
146        format!("{}{path}", self.prefix)
147    }
148}
149
150/// How to invoke a particular headless agent CLI.
151#[derive(Debug, Clone, Deserialize)]
152#[serde(deny_unknown_fields)]
153pub struct Runner {
154    /// Command and arguments.
155    ///
156    /// `{prompt}` substitutes the **path** to the agent's composed instructions, which the Tower
157    /// writes into the run's Hangar before spawning. It is not the instructions themselves.
158    ///
159    /// That distinction is the whole design. A prompt is passed on **stdin**, never on the
160    /// command line: Windows caps a command line at 32,767 characters, and real agent prompts go
161    /// well past it — a sibling project's review agent composes to roughly 98 KB, three times
162    /// over, and its ordinary developer agent to 34 KB. Inlining the prompt would work in every
163    /// test written against a small fixture and fail on the first agent worth running.
164    ///
165    /// So most runners need no placeholder at all. It exists for CLIs that accept a file of
166    /// instructions as a flag; those that do not get the instructions prepended to stdin.
167    pub command: Vec<String>,
168    /// How this runner is told where Layover's MCP server is.
169    #[serde(default)]
170    pub mcp: Option<McpWiring>,
171}
172
173impl Runner {
174    /// The placeholder substituted with the path to the composed instructions.
175    pub const PROMPT_PATH: &'static str = "{prompt}";
176
177    /// The placeholder substituted with the agent's model.
178    ///
179    /// Every supported CLI spells its model flag differently — `--model`, `-m`, a config key — so
180    /// the spelling stays in the runner command, which is already the one place that knows how to
181    /// invoke a given CLI. The alternative, a `model_flag` field, would put half of an invocation
182    /// in one place and half in another.
183    pub const MODEL: &'static str = "{model}";
184
185    /// The placeholder substituted with this runner's [`McpWiring::flag`] and the path to the
186    /// generated MCP configuration — two arguments, not one.
187    ///
188    /// Optional. A command that does not contain it gets the pair appended at the end, which is
189    /// what `claude` and `copilot` want. It exists for commands that end in a positional argument
190    /// — `codex exec … -` reads the prompt from stdin and must stay last — where appending would
191    /// put a flag after the thing it has to precede.
192    pub const MCP: &'static str = "{mcp}";
193
194    /// Returns `true` when this runner wants the instructions as a file it is handed.
195    ///
196    /// When `false`, the Tower prepends them to the stdin payload instead.
197    #[must_use]
198    pub fn takes_prompt_path(&self) -> bool {
199        self.command
200            .iter()
201            .any(|arg| arg.contains(Self::PROMPT_PATH))
202    }
203
204    /// Returns `true` when this runner can carry an agent's `model`.
205    ///
206    /// An agent that declares a model whose runner cannot carry it is a silent no-op: the run
207    /// happens, on whichever model the CLI defaults to, and nothing says the declaration was
208    /// ignored. Validation warns about it rather than letting it pass.
209    #[must_use]
210    pub fn takes_model(&self) -> bool {
211        self.command.iter().any(|arg| arg.contains(Self::MODEL))
212    }
213
214    /// Builds the command line for one run.
215    ///
216    /// Substitution is textual and deliberately so: a placeholder sits inside an argument like
217    /// `--model={model}` as readily as it stands alone, and the operator writes whichever their
218    /// CLI expects.
219    ///
220    /// An argument that is *only* a `{model}` placeholder disappears when no model is set, rather
221    /// than becoming an empty argument — an empty string in `argv` is not nothing, and several
222    /// CLIs treat it as a positional.
223    #[must_use]
224    pub fn invocation(&self, prompt_path: Option<&str>, model: Option<&str>) -> Vec<String> {
225        self.invocation_with_mcp(prompt_path, model, None)
226    }
227
228    /// The command to run, with every placeholder resolved.
229    ///
230    /// `mcp_config` is the path to the file [`McpWiring::format`] describes. When the command
231    /// names [`Self::MCP`] the flag and path replace it there; otherwise they are appended, which
232    /// is the right answer for every CLI that does not end in a positional argument.
233    #[must_use]
234    pub fn invocation_with_mcp(
235        &self,
236        prompt_path: Option<&str>,
237        model: Option<&str>,
238        mcp_config: Option<&str>,
239    ) -> Vec<String> {
240        let wiring = self.mcp.as_ref().zip(mcp_config);
241        let mut out = Vec::with_capacity(self.command.len() + 2);
242
243        for arg in &self.command {
244            if arg == Self::MODEL && model.is_none() {
245                continue;
246            }
247
248            if arg == Self::MCP {
249                if let Some((mcp, path)) = wiring {
250                    out.push(mcp.flag.clone());
251                    out.push(mcp.argument(path));
252                }
253                // Dropped when there is nothing to wire: an unresolved placeholder reaching a CLI
254                // becomes an argument it does not understand.
255                continue;
256            }
257
258            let mut rendered = arg.clone();
259            if let Some(path) = prompt_path {
260                rendered = rendered.replace(Self::PROMPT_PATH, path);
261            }
262            if let Some(model) = model {
263                rendered = rendered.replace(Self::MODEL, model);
264            }
265
266            out.push(rendered);
267        }
268
269        if let Some((mcp, path)) = wiring
270            && !self.command.iter().any(|arg| arg == Self::MCP)
271        {
272            out.push(mcp.flag.clone());
273            out.push(mcp.argument(path));
274        }
275
276        out
277    }
278}
279
280/// A bound on what the whole factory may spend, across every itinerary.
281///
282/// Fuel bounds one chain. This bounds the factory, which is a different question: a scheduled
283/// pipeline mints a fresh itinerary — and a fresh Fuel budget — on every tick, so every chain can
284/// stay inside its rail while the total runs away. See [`crate::cost::reserve`].
285#[derive(Debug, Clone, Deserialize)]
286#[serde(deny_unknown_fields)]
287pub struct ReserveConfig {
288    /// Ceiling for the window, in US dollars. Zero means unlimited.
289    #[serde(default = "default_reserve_usd")]
290    pub fuel_usd: f64,
291    /// How far back the rolling window reaches.
292    ///
293    /// Rolling rather than per calendar day, deliberately: a daily bucket can be spent twice
294    /// across midnight, and needs a timezone to decide when midnight is.
295    #[serde(default = "default_reserve_window_hours")]
296    pub window_hours: u64,
297}
298
299impl ReserveConfig {
300    /// Returns the window as a duration.
301    #[must_use]
302    pub fn window(&self) -> Duration {
303        Duration::from_secs(self.window_hours.saturating_mul(3_600))
304    }
305
306    /// Builds the runtime Reserve this configuration describes.
307    #[must_use]
308    pub fn to_reserve(&self) -> Reserve {
309        Reserve::new(self.fuel_usd, self.window())
310    }
311
312    /// Returns `true` when no ceiling is enforced.
313    #[must_use]
314    pub fn is_unlimited(&self) -> bool {
315        !(self.fuel_usd.is_finite() && self.fuel_usd > 0.0)
316    }
317}
318
319impl Default for ReserveConfig {
320    fn default() -> Self {
321        Self {
322            fuel_usd: default_reserve_usd(),
323            window_hours: default_reserve_window_hours(),
324        }
325    }
326}
327
328/// A whole factory definition.
329#[derive(Debug, Clone, Deserialize)]
330#[serde(deny_unknown_fields)]
331pub struct Config {
332    /// Filesystem and network locations.
333    #[serde(default)]
334    pub layover: Paths,
335    /// Factory-wide defaults.
336    #[serde(default)]
337    pub defaults: Defaults,
338    /// The factory-wide spend ceiling.
339    #[serde(default)]
340    pub reserve: ReserveConfig,
341    /// Published model prices, used only when a runner reports tokens but no cost.
342    #[serde(default)]
343    pub rates: RateCard,
344    /// Available runners, keyed by name.
345    #[serde(default)]
346    pub runners: BTreeMap<String, Runner>,
347    /// Configured agents, keyed by name.
348    #[serde(default)]
349    pub agents: BTreeMap<AgentName, Agent>,
350    /// Named, triggerable entry points, keyed by name.
351    #[serde(default)]
352    pub pipelines: BTreeMap<PipelineName, Pipeline>,
353    /// The route map.
354    #[serde(default)]
355    pub routes: Vec<Route>,
356}
357
358impl Config {
359    /// Parses a factory definition from TOML text.
360    ///
361    /// # Errors
362    ///
363    /// Returns [`ConfigError::Parse`] if the text is not a valid factory definition, including
364    /// when it carries fields Layover does not recognise.
365    pub fn from_toml(text: &str, origin: impl Into<PathBuf>) -> Result<Self, ConfigError> {
366        toml::from_str(text).map_err(|source| ConfigError::Parse {
367            path: origin.into(),
368            source: Box::new(source),
369        })
370    }
371
372    /// Reads and parses a factory definition from disk.
373    ///
374    /// # Errors
375    ///
376    /// Returns [`ConfigError::Read`] if the file cannot be read, or [`ConfigError::Parse`] if
377    /// its contents are not a valid factory definition.
378    pub fn load(path: impl AsRef<Path>) -> Result<Self, ConfigError> {
379        let path = path.as_ref();
380        let text = std::fs::read_to_string(path).map_err(|source| ConfigError::Read {
381            path: path.to_path_buf(),
382            source,
383        })?;
384        Self::from_toml(&text, path)
385    }
386
387    /// Returns the agents a human or a schedule may send flights to.
388    ///
389    /// An agent is an entry point when it is marked `entry = true` or when a pipeline names it.
390    /// The two are different things: `entry` is a bare permission, while a pipeline is a named
391    /// trigger that also carries a schedule and flags.
392    ///
393    /// Each agent appears once however many pipelines name it.
394    pub fn entry_agents(&self) -> impl Iterator<Item = &AgentName> {
395        let marked = self
396            .agents
397            .iter()
398            .filter(|(_, agent)| agent.entry)
399            .map(|(name, _)| name);
400
401        let piped = self.pipelines.values().map(|pipeline| &pipeline.entry);
402
403        marked.chain(piped).collect::<BTreeSet<_>>().into_iter()
404    }
405
406    /// Returns the effective Fuel budget for an itinerary started at `agent`.
407    #[must_use]
408    pub fn fuel_for(&self, agent: &AgentName) -> f64 {
409        self.agents
410            .get(agent)
411            .and_then(|a| a.fuel_usd)
412            .unwrap_or(self.defaults.fuel_usd)
413    }
414
415    /// Returns every flag name declared by any pipeline.
416    pub fn declared_flags(&self) -> impl Iterator<Item = &str> {
417        self.pipelines
418            .values()
419            .flat_map(|pipeline| pipeline.flags.keys().map(String::as_str))
420    }
421
422    /// Returns the pipelines a clock triggers.
423    pub fn scheduled_pipelines(&self) -> impl Iterator<Item = (&PipelineName, &Pipeline)> {
424        self.pipelines
425            .iter()
426            .filter(|(_, pipeline)| !pipeline.trigger.is_manual())
427    }
428}
429
430/// Why a factory definition could not be loaded.
431#[derive(Debug, thiserror::Error)]
432pub enum ConfigError {
433    /// The file could not be read.
434    #[error("could not read factory definition at {path}")]
435    Read {
436        /// Path that was attempted.
437        path: PathBuf,
438        /// Underlying I/O failure.
439        #[source]
440        source: std::io::Error,
441    },
442    /// The file was not a valid factory definition.
443    #[error("could not parse factory definition at {path}")]
444    Parse {
445        /// Path that was attempted.
446        path: PathBuf,
447        /// Underlying parse failure.
448        #[source]
449        source: Box<toml::de::Error>,
450    },
451}
452
453fn default_state_dir() -> PathBuf {
454    PathBuf::from(".layover/state")
455}
456
457fn default_work_dir() -> PathBuf {
458    PathBuf::from("workspace")
459}
460
461fn default_logbook() -> PathBuf {
462    PathBuf::from(".layover/logbook.md")
463}
464
465fn default_prompt_dir() -> PathBuf {
466    PathBuf::from("prompts")
467}
468
469fn default_http_addr() -> String {
470    "127.0.0.1:7878".to_owned()
471}
472
473const fn default_max_hops() -> u32 {
474    8
475}
476
477const fn default_fuel_usd() -> f64 {
478    5.0
479}
480
481/// Four at once: enough for a fan-out to be worth having, few enough that a laptop stays usable.
482const fn default_max_concurrent_runs() -> usize {
483    4
484}
485
486/// One generation. A scanner may spawn a reviewer per item; that reviewer may not spawn more.
487const fn default_max_spawn_generations() -> u32 {
488    1
489}
490
491const fn default_max_runs() -> u32 {
492    64
493}
494
495const fn default_max_recovery_attempts() -> u32 {
496    2
497}
498
499const fn default_reserve_usd() -> f64 {
500    100.0
501}
502
503const fn default_reserve_window_hours() -> u64 {
504    24
505}
506
507const fn default_timeout_sec() -> u64 {
508    900
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514    use crate::pipeline::Trigger;
515
516    #[test]
517    fn omitted_sections_fall_back_to_defaults() {
518        let config = Config::from_toml("", "test.toml").expect("an empty factory parses");
519
520        assert_eq!(config.defaults.max_hops, 8);
521        assert_eq!(config.defaults.max_runs, 64);
522        assert_eq!(config.layover.http_addr, "127.0.0.1:7878");
523        assert_eq!(config.layover.state_dir, PathBuf::from(".layover/state"));
524        assert_eq!(config.layover.prompt_dir, PathBuf::from("prompts"));
525        assert!(config.pipelines.is_empty());
526    }
527
528    #[test]
529    fn a_typo_is_rejected_rather_than_ignored() {
530        let error = Config::from_toml(
531            r#"
532            [agents.planner]
533            prompt = "plan"
534            entrypoint = true
535            "#,
536            "test.toml",
537        )
538        .expect_err("an unrecognised field must not be silently dropped");
539
540        assert!(matches!(error, ConfigError::Parse { .. }));
541    }
542
543    #[test]
544    fn a_per_agent_fuel_override_wins() {
545        let config = Config::from_toml(
546            r#"
547            [defaults]
548            fuel_usd = 5.0
549
550            [agents.thrifty]
551            prompt = "be brief"
552            fuel_usd = 1.0
553
554            [agents.spendy]
555            prompt = "take your time"
556            "#,
557            "test.toml",
558        )
559        .expect("config parses");
560
561        assert!((config.fuel_for(&"thrifty".into()) - 1.0).abs() < 1e-9);
562        assert!((config.fuel_for(&"spendy".into()) - 5.0).abs() < 1e-9);
563    }
564
565    #[test]
566    fn entry_agents_include_marked_agents_and_pipeline_entries() {
567        let config = Config::from_toml(
568            r#"
569            [agents.front_door]
570            prompt = "start here"
571            entry = true
572
573            [agents.scanner]
574            prompt = "poll for work"
575
576            [agents.inner]
577            prompt = "not directly reachable"
578
579            [pipelines.review-bot]
580            entry = "scanner"
581            trigger = { every = "1h" }
582            "#,
583            "test.toml",
584        )
585        .expect("config parses");
586
587        let mut entries: Vec<String> = config.entry_agents().map(ToString::to_string).collect();
588        entries.sort();
589
590        assert_eq!(entries, ["front_door", "scanner"]);
591    }
592
593    #[test]
594    fn an_agent_that_is_both_marked_and_piped_is_listed_once() {
595        let config = Config::from_toml(
596            r#"
597            [agents.analyst]
598            prompt = "analyse"
599            entry = true
600
601            [pipelines.development]
602            entry = "analyst"
603            "#,
604            "test.toml",
605        )
606        .expect("config parses");
607
608        assert_eq!(config.entry_agents().count(), 1);
609    }
610
611    #[test]
612    fn two_pipelines_sharing_an_entry_agent_list_it_once() {
613        let config = Config::from_toml(
614            r#"
615            [agents.analyst]
616            prompt = "analyse"
617
618            [pipelines.development]
619            entry = "analyst"
620
621            [pipelines.nightly]
622            entry = "analyst"
623            trigger = { every = "1d" }
624            "#,
625            "test.toml",
626        )
627        .expect("config parses");
628
629        assert_eq!(
630            config.entry_agents().collect::<Vec<_>>(),
631            vec![&AgentName::from("analyst")]
632        );
633    }
634
635    #[test]
636    fn scheduled_pipelines_are_separable_from_manual_ones() {
637        let config = Config::from_toml(
638            r#"
639            [agents.analyst]
640            prompt = "analyse"
641
642            [agents.scanner]
643            prompt = "scan"
644
645            [pipelines.development]
646            entry = "analyst"
647            trigger = "manual"
648
649            [pipelines.review-bot]
650            entry = "scanner"
651            trigger = { cron = "0 * * * *" }
652            "#,
653            "test.toml",
654        )
655        .expect("config parses");
656
657        let scheduled: Vec<&str> = config
658            .scheduled_pipelines()
659            .map(|(name, _)| name.as_str())
660            .collect();
661
662        assert_eq!(scheduled, ["review-bot"]);
663        assert_eq!(
664            config.pipelines[&PipelineName::from("development")].trigger,
665            Trigger::Manual
666        );
667    }
668
669    #[test]
670    fn declared_flags_are_collected_across_pipelines() {
671        let config = Config::from_toml(
672            r#"
673            [agents.analyst]
674            prompt = "analyse"
675
676            [pipelines.development]
677            entry = "analyst"
678
679            [pipelines.development.flags]
680            run_e2e = { default = false }
681
682            [pipelines.nightly]
683            entry = "analyst"
684            trigger = { every = "1d" }
685
686            [pipelines.nightly.flags]
687            deep_scan = { default = true }
688            "#,
689            "test.toml",
690        )
691        .expect("config parses");
692
693        let mut flags: Vec<&str> = config.declared_flags().collect();
694        flags.sort_unstable();
695
696        assert_eq!(flags, ["deep_scan", "run_e2e"]);
697    }
698}
699
700#[cfg(test)]
701mod invocation_tests {
702    use crate::config::Runner;
703
704    fn runner(args: &[&str]) -> Runner {
705        toml::from_str(&format!(
706            "command = [{}]",
707            args.iter()
708                .map(|a| format!("\"{a}\""))
709                .collect::<Vec<_>>()
710                .join(", ")
711        ))
712        .expect("parses")
713    }
714
715    #[test]
716    fn a_model_placeholder_is_substituted_wherever_it_sits() {
717        // Some CLIs take `--model x`, some take `--model=x`. The operator writes whichever theirs
718        // wants, so substitution has to be textual rather than positional.
719        let separate = runner(&["claude", "-p", "--model", "{model}"]);
720        assert_eq!(
721            separate.invocation(None, Some("claude-opus-5")),
722            ["claude", "-p", "--model", "claude-opus-5"]
723        );
724
725        let joined = runner(&["codex", "exec", "--model={model}"]);
726        assert_eq!(
727            joined.invocation(None, Some("gpt-5.4")),
728            ["codex", "exec", "--model=gpt-5.4"]
729        );
730    }
731
732    #[test]
733    fn a_bare_model_placeholder_disappears_when_no_model_is_set() {
734        // An empty string in argv is not nothing; several CLIs read it as a positional argument.
735        let r = runner(&["claude", "-p", "{model}"]);
736        assert_eq!(r.invocation(None, None), ["claude", "-p"]);
737    }
738
739    fn mcp_runner(args: &[&str], flag: &str) -> Runner {
740        toml::from_str(&format!(
741            "command = [{}]\nmcp = {{ flag = \"{flag}\", format = \"claude_json\" }}",
742            args.iter()
743                .map(|a| format!("\"{a}\""))
744                .collect::<Vec<_>>()
745                .join(", ")
746        ))
747        .expect("parses")
748    }
749
750    #[test]
751    fn mcp_wiring_is_appended_when_the_command_does_not_place_it() {
752        // What `claude` and `copilot` want, and what every existing factory file relies on.
753        let r = mcp_runner(&["copilot", "--allow-all-tools"], "--mcp-config");
754        assert_eq!(
755            r.invocation_with_mcp(None, None, Some("/h/mcp.json")),
756            [
757                "copilot",
758                "--allow-all-tools",
759                "--mcp-config",
760                "/h/mcp.json"
761            ]
762        );
763    }
764
765    #[test]
766    fn a_prefix_is_prepended_to_the_path_rather_than_passed_separately() {
767        // Copilot CLI's `--additional-mcp-config` takes either a JSON string or a file path and
768        // tells them apart by a leading `@`. Passed as its own argument the `@` would be a second
769        // value the flag never sees; without it the path is parsed as JSON and the run dies
770        // complaining about the factory's own configuration.
771        let runner: Runner = toml::from_str(
772            r#"command = ["copilot", "--allow-all-tools"]
773mcp = { flag = "--additional-mcp-config", format = "claude_json", prefix = "@" }"#,
774        )
775        .expect("parses");
776
777        assert_eq!(
778            runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
779            [
780                "copilot",
781                "--allow-all-tools",
782                "--additional-mcp-config",
783                "@/h/mcp.json"
784            ]
785        );
786    }
787
788    #[test]
789    fn a_prefix_applies_where_the_command_places_the_wiring_too() {
790        // Both branches render the argument, and only one of them having the prefix would be a
791        // factory that works until somebody adds `{mcp}` to keep a positional argument last.
792        let runner: Runner = toml::from_str(
793            r#"command = ["agent", "{mcp}", "-"]
794mcp = { flag = "--cfg", format = "claude_json", prefix = "@" }"#,
795        )
796        .expect("parses");
797
798        assert_eq!(
799            runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
800            ["agent", "--cfg", "@/h/mcp.json", "-"]
801        );
802    }
803
804    #[test]
805    fn a_runner_without_a_prefix_still_gets_a_bare_path() {
806        let runner = mcp_runner(&["claude", "-p"], "--mcp-config");
807
808        assert_eq!(
809            runner.invocation_with_mcp(None, None, Some("/h/mcp.json")),
810            ["claude", "-p", "--mcp-config", "/h/mcp.json"]
811        );
812    }
813
814    #[test]
815    fn mcp_wiring_goes_where_the_command_puts_it_when_it_says() {
816        // `codex exec … -` reads the prompt from stdin and the `-` has to stay last, so appending
817        // would put the flag after the argument it must precede.
818        let r = mcp_runner(&["codex", "exec", "{mcp}", "-"], "-c");
819        assert_eq!(
820            r.invocation_with_mcp(None, None, Some("/h/mcp.toml")),
821            ["codex", "exec", "-c", "/h/mcp.toml", "-"]
822        );
823    }
824
825    #[test]
826    fn an_mcp_placeholder_disappears_when_there_is_nothing_to_wire() {
827        // An unresolved placeholder reaching a CLI becomes an argument it does not understand.
828        let r = mcp_runner(&["codex", "exec", "{mcp}", "-"], "-c");
829        assert_eq!(
830            r.invocation_with_mcp(None, None, None),
831            ["codex", "exec", "-"]
832        );
833    }
834
835    #[test]
836    fn a_runner_with_no_mcp_block_is_wired_to_nothing_even_if_a_path_exists() {
837        // The factory always has a config file to offer; only the runner knows whether its CLI
838        // can be told about one.
839        let r = runner(&["echo", "hello"]);
840        assert_eq!(
841            r.invocation_with_mcp(None, None, Some("/h/mcp.json")),
842            ["echo", "hello"]
843        );
844    }
845
846    #[test]
847    fn the_prompt_path_is_substituted_independently_of_the_model() {
848        let r = runner(&["agent", "--file", "{prompt}", "--model", "{model}"]);
849        assert_eq!(
850            r.invocation(Some("/run/prompt.md"), Some("m1")),
851            ["agent", "--file", "/run/prompt.md", "--model", "m1"]
852        );
853    }
854
855    #[test]
856    fn a_runner_without_placeholders_is_passed_through_untouched() {
857        let r = runner(&["copilot", "--allow-all-tools"]);
858        assert_eq!(
859            r.invocation(Some("/x"), Some("m")),
860            ["copilot", "--allow-all-tools"]
861        );
862        assert!(!r.takes_model());
863        assert!(!r.takes_prompt_path());
864    }
865
866    #[test]
867    fn declaring_a_model_a_runner_cannot_carry_is_a_warning() {
868        let config: crate::config::Config = toml::from_str(
869            r#"
870[layover]
871work_dir = "work"
872
873[defaults]
874runner = "claude"
875
876[runners.claude]
877command = ["claude", "-p"]
878
879[agents.analyst]
880prompt = "analyse"
881model = "claude-opus-5"
882entry = true
883"#,
884        )
885        .expect("parses");
886
887        let said: Vec<_> = crate::validate::validate(&config)
888            .iter()
889            .map(|d| d.message.clone())
890            .collect();
891
892        assert!(
893            said.iter().any(|m| m.contains("no `{model}` placeholder")),
894            "{said:?}"
895        );
896    }
897}