Skip to main content

voro_core/
agent.rs

1//! Agent dispatch templates (DESIGN.md §5, §8): command templates, not state,
2//! so they live outside the database. Owns the built-in `claude`/`codex`
3//! definitions, layers the user's `~/.config/voro/voro.toml` on top, and
4//! resolves which agent a task dispatches with.
5//!
6//! An agent is a set of verb templates; only `dispatch` is required (`cmd` is
7//! an accepted alias). The optional `sessions`/`attach`/`resume`/`continue`
8//! verbs unlock session-aware dispatch and `plan` unlocks the TUI's interactive
9//! planning sessions (DESIGN.md §8); each degrades gracefully when absent
10//! (docs/agent-integration.md). Config is layered: built-ins under `voro.toml`,
11//! which may add agents, override a built-in wholesale, and set `default_agent`
12//! and viewers. A missing file is not an error.
13
14use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21
22/// The prompt-file substitution in `dispatch` and `continue` templates. The
23/// working directory is handled by the spawner, not the template.
24pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
25
26/// The task-id substitution in the `dispatch` template, the numeric id of the
27/// task. Optional — a template that omits it dispatches unchanged — so agents
28/// with a session-naming flag can tie the session back to its task.
29pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
30
31/// The session-reference substitution in `attach`, `resume`, and `continue`
32/// templates: the agent-opaque reference captured at dispatch (a Claude
33/// session UUID, a Codex session id, a tmux session name).
34pub const SESSION_PLACEHOLDER: &str = "{session}";
35
36/// The substitution in a viewer command template (DESIGN.md §11a): the checkout
37/// path of the task's project — or the task's worktree, when it has a branch
38/// checked out in one (DESIGN.md §8). Optional — a viewer that acts on the
39/// current directory (`git difftool -d`) needs no placeholder.
40pub const VIEWER_PATH_PLACEHOLDER: &str = "{path}";
41
42/// The substitution in a viewer command template for the task's git branch, or
43/// empty when the task has none. Paired with [`VIEWER_BASE_PLACEHOLDER`] it lets
44/// a viewer express a diff range (`{base}...{branch}`) rather than a bare
45/// directory (DESIGN.md §8).
46pub const VIEWER_BRANCH_PLACEHOLDER: &str = "{branch}";
47
48/// The substitution in a viewer command template for the checkout's default
49/// branch — the base a task branch is diffed against (DESIGN.md §8).
50pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
51
52/// The agents Voro ships with the binary, layered under any `voro.toml`
53/// (DESIGN.md §5/§8). Compiled in, so a binary upgrade upgrades the agents; a
54/// user `voro.toml` can override either wholesale ([`Provenance::UserOverride`]).
55/// `claude` launches attachably (`--bg`) with the full session verb set and
56/// plans interactively in the foreground; `codex` covers the headless-resume
57/// shape. Must parse and pass [`validate_agent`].
58///
59/// The claude verbs carry per-purpose `--model` defaults: `dispatch` a
60/// workhorse implementation model, `plan` a stronger reasoning model. They
61/// pass the `claude` model *aliases* (`opus`, `fable`), not pinned model ids,
62/// so they resolve to the current model of that class and do not churn with
63/// each release; an operator wanting other models overrides the agent
64/// wholesale in `voro.toml`.
65const BUILTIN_AGENTS: &str = "\
66[agents.claude]
67dispatch = \"claude --bg --name \\\"voro-{task_id}\\\" --permission-mode auto --model opus \\\"$(cat {prompt_file})\\\"\"
68sessions = \"claude agents --json\"
69attach   = \"claude attach {session}\"
70resume   = \"claude --resume {session}\"
71plan     = \"claude --permission-mode auto --model fable \\\"$(cat {prompt_file})\\\"\"
72
73[agents.codex]
74dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\"
75resume   = \"codex resume {session}\"
76continue = \"codex exec resume {session} \\\"$(cat {prompt_file})\\\"\"
77";
78
79/// The order the built-in agents are probed against PATH when no `default` is
80/// configured: the first one both defined and installed wins (DESIGN.md §8).
81const DEFAULT_PROBE_ORDER: [&str; 2] = ["claude", "codex"];
82
83/// The parsed, validated built-in templates, layered under a user file by
84/// [`AgentsConfig::load`]. A parse or validation failure is a bug in
85/// [`BUILTIN_AGENTS`], so it panics rather than surfacing as a config error.
86fn builtin_agents() -> &'static BTreeMap<String, AgentTemplate> {
87    static BUILTINS: LazyLock<BTreeMap<String, AgentTemplate>> = LazyLock::new(|| {
88        let raw: RawConfig = toml::from_str(BUILTIN_AGENTS).expect("built-in agents TOML parses");
89        for (name, agent) in &raw.agents {
90            validate_agent(name, agent, Path::new("<built-in>")).expect("built-in agent is valid");
91        }
92        raw.agents
93    });
94    &BUILTINS
95}
96
97/// Header prose for the skeleton `agent init` writes. [`starter_config`]
98/// appends the current built-ins (commented) and example stanzas after it.
99const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml).
100#
101# This file is OPTIONAL. Voro ships with built-in `claude` and `codex` agents,
102# so a fresh install with one of those on PATH can dispatch with no config here.
103# Run `voro agent list` to see the effective agents and where each comes from.
104#
105# Use this file to extend or override the built-ins, and to set app options:
106#
107#   * add your own agent — a new [agents.<name>] table. Only `dispatch` is
108#     required (`cmd` is an alias): it starts a session on a task, with
109#     `{prompt_file}` replaced by the prompt file's path and the optional
110#     `{task_id}` by the task's numeric id. The optional session verbs unlock
111#     attachable dispatch, and each degrades gracefully when absent:
112#       sessions  list the agent's sessions as JSON (liveness + ref capture)
113#       attach    open a running session interactively    ({session})
114#       resume    reopen a finished session interactively  ({session})
115#       continue  feed a session new input headless        ({session} {prompt_file})
116#       plan      run an interactive foreground planning session ({prompt_file})
117#     See docs/agent-integration.md for the full contract.
118#   * override a built-in — a table named `claude` or `codex` REPLACES that
119#     built-in entirely (not per-verb), so copy every verb you still want. The
120#     built-ins are reproduced below, commented out, ready to copy.
121#   * set `default_agent` — used for tasks with no --agent override. When unset,
122#     Voro picks the first built-in found on PATH (claude, then codex).
123#   * set up viewers — [viewers.<name>] tables define how a task's diff is
124#     shown locally when `voro pr`/`voro open` resolve to the viewer medium
125#     (DESIGN.md §8). A viewer cmd may carry `{path}` (the task's worktree, or
126#     the project checkout when it has none), `{branch}` (the task's branch, or
127#     empty), and `{base}` (the checkout's default branch); `{base}...{branch}`
128#     spells a diff range. `default_viewer` names the one used when a project
129#     does not pick a viewer itself (`voro project action <p> viewer:<name>`); a
130#     single anonymous [viewer] table is the older, still-valid spelling of
131#     the default.
132"#;
133
134/// The full skeleton `voro agent init` writes: the header, the built-ins
135/// reproduced commented-out (copyable to override or model an agent), then
136/// example stanzas. Every line is a comment, so the file defines nothing until
137/// the user uncomments something; the commented block is derived from
138/// [`BUILTIN_AGENTS`] so it cannot drift from what ships.
139fn starter_config() -> String {
140    let mut out = String::from(STARTER_HEADER);
141    out.push_str(
142        "\n# --------------------------------------------------------------------------\n\
143         # Built-in agents, exactly as shipped. Uncomment a block and edit it to\n\
144         # override that agent wholesale; leave it commented to keep the built-in,\n\
145         # which updates with Voro. Copy a block to model a new agent of your own.\n\
146         # --------------------------------------------------------------------------\n#\n",
147    );
148    for line in BUILTIN_AGENTS.lines() {
149        if line.is_empty() {
150            out.push_str("#\n");
151        } else {
152            out.push_str("# ");
153            out.push_str(line);
154            out.push('\n');
155        }
156    }
157    out.push_str(
158        "\n# --------------------------------------------------------------------------\n\
159         # Examples (uncomment and tune):\n#\n\
160         # default_agent = \"claude\"\n#\n\
161         # [agents.mine]\n\
162         # dispatch = \"my-agent run {prompt_file}\"\n#\n\
163         # default_viewer = \"zed\"\n#\n\
164         # [viewers.zed]\n\
165         # cmd = \"zed {path}\"\n#\n\
166         # [viewers.difftool]\n\
167         # cmd = \"git -C {path} difftool -d {base}...{branch}\"\n",
168    );
169    out
170}
171
172/// A named set of verb templates from `voro.toml`. `dispatch` (or its alias
173/// `cmd`) is required and always contains [`PROMPT_FILE_PLACEHOLDER`]; it may
174/// also carry the optional [`TASK_ID_PLACEHOLDER`]. The rest are optional, with
175/// their `{session}`/`{prompt_file}` placeholders validated at parse time.
176#[derive(Debug, Clone, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct AgentTemplate {
179    dispatch: Option<String>,
180    /// Pre-verb alias for `dispatch`, so existing configs load unchanged.
181    cmd: Option<String>,
182    sessions: Option<String>,
183    attach: Option<String>,
184    resume: Option<String>,
185    #[serde(rename = "continue")]
186    continue_: Option<String>,
187    /// An interactive foreground command carrying [`PROMPT_FILE_PLACEHOLDER`],
188    /// run by the TUI's planning flow (DESIGN.md §8) — no `{session}`, since a
189    /// planning session belongs to no task or session row.
190    plan: Option<String>,
191}
192
193impl AgentTemplate {
194    /// The dispatch command — `dispatch`, or its legacy alias `cmd`.
195    /// Presence of exactly one is enforced at parse time.
196    pub fn dispatch(&self) -> &str {
197        self.dispatch
198            .as_deref()
199            .or(self.cmd.as_deref())
200            .expect("parse validates that dispatch or cmd is set")
201    }
202
203    pub fn sessions(&self) -> Option<&str> {
204        self.sessions.as_deref()
205    }
206
207    pub fn attach(&self) -> Option<&str> {
208        self.attach.as_deref()
209    }
210
211    pub fn resume(&self) -> Option<&str> {
212        self.resume.as_deref()
213    }
214
215    pub fn continue_cmd(&self) -> Option<&str> {
216        self.continue_.as_deref()
217    }
218
219    pub fn plan(&self) -> Option<&str> {
220        self.plan.as_deref()
221    }
222}
223
224/// A viewer command template from `voro.toml` (DESIGN.md §11a): a shell command
225/// run in a task's checkout — or its worktree — to open its diff. Defined as a
226/// named `[viewers.<name>]` table or the anonymous `[viewer]` default. The
227/// placeholders `{path}` (checkout/worktree dir), `{branch}` (the task's
228/// branch), and `{base}` (the checkout's default branch) are all optional, so
229/// nothing is validated at parse time.
230#[derive(Debug, Clone, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ViewerTemplate {
233    pub cmd: String,
234}
235
236/// Where an effective agent came from once the built-ins and `voro.toml`
237/// are layered, surfaced by `voro agent list` so it is clear which half of
238/// the config owns each agent.
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum Provenance {
241    /// Ships with the binary; no user file mentions it.
242    BuiltIn,
243    /// Defined only in the user's `voro.toml`.
244    User,
245    /// A user table that replaces a built-in of the same name wholesale.
246    UserOverride,
247}
248
249impl Provenance {
250    /// A short label for `agent list`.
251    pub fn label(self) -> &'static str {
252        match self {
253            Provenance::BuiltIn => "built-in",
254            Provenance::User => "user",
255            Provenance::UserOverride => "user override",
256        }
257    }
258}
259
260/// The raw shape deserialized from `voro.toml` (or the built-in TOML) before
261/// layering. Every field is optional, so a file that only sets `[viewer]`, only
262/// adds an agent, or is empty all parse.
263#[derive(Debug, Deserialize)]
264#[serde(deny_unknown_fields)]
265struct RawConfig {
266    #[serde(default)]
267    default_agent: Option<String>,
268    #[serde(default)]
269    agents: BTreeMap<String, AgentTemplate>,
270    #[serde(default)]
271    viewer: Option<ViewerTemplate>,
272    #[serde(default)]
273    viewers: BTreeMap<String, ViewerTemplate>,
274    #[serde(default)]
275    default_viewer: Option<String>,
276}
277
278/// Validate one agent's verb templates, shared by the built-ins and the user
279/// file. `dispatch` (or its alias `cmd`) must be present and carry the
280/// prompt-file placeholder; the session verbs carry their placeholders when
281/// present.
282fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> {
283    let invalid = |message: String| Error::AgentConfigInvalid {
284        path: path.to_path_buf(),
285        message,
286    };
287    let dispatch = match (&agent.dispatch, &agent.cmd) {
288        (Some(_), Some(_)) => {
289            return Err(invalid(format!(
290                "agent '{name}' sets both dispatch and cmd — cmd is an alias for \
291                 dispatch, keep one"
292            )));
293        }
294        (Some(d), None) => d,
295        (None, Some(c)) => c,
296        (None, None) => {
297            return Err(invalid(format!(
298                "agent '{name}' is missing a dispatch (or cmd) template"
299            )));
300        }
301    };
302    if !dispatch.contains(PROMPT_FILE_PLACEHOLDER) {
303        return Err(invalid(format!(
304            "agent '{name}' cmd is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
305        )));
306    }
307    for (verb, template) in [
308        ("attach", &agent.attach),
309        ("resume", &agent.resume),
310        ("continue", &agent.continue_),
311    ] {
312        if let Some(template) = template
313            && !template.contains(SESSION_PLACEHOLDER)
314        {
315            return Err(invalid(format!(
316                "agent '{name}' {verb} is missing the {SESSION_PLACEHOLDER} placeholder"
317            )));
318        }
319    }
320    if let Some(template) = &agent.continue_
321        && !template.contains(PROMPT_FILE_PLACEHOLDER)
322    {
323        return Err(invalid(format!(
324            "agent '{name}' continue is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
325        )));
326    }
327    if let Some(template) = &agent.plan
328        && !template.contains(PROMPT_FILE_PLACEHOLDER)
329    {
330        return Err(invalid(format!(
331            "agent '{name}' plan is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
332        )));
333    }
334    Ok(())
335}
336
337/// Whether an executable named `name` is on `PATH`, for picking a default agent
338/// when the user file names none. The probe is by agent name, which for the
339/// built-ins is also the binary name.
340fn binary_on_path(name: &str) -> bool {
341    let Some(paths) = std::env::var_os("PATH") else {
342        return false;
343    };
344    std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
345}
346
347/// The agent a task will be dispatched with: the task's own override if it
348/// has one, otherwise the config's global default, with every verb template
349/// resolved.
350#[derive(Debug, Clone, PartialEq, Eq)]
351pub struct ResolvedAgent {
352    pub name: String,
353    pub dispatch: String,
354    pub sessions: Option<String>,
355    pub attach: Option<String>,
356    pub resume: Option<String>,
357    pub continue_cmd: Option<String>,
358    pub plan: Option<String>,
359}
360
361/// The effective agent config: the built-in agents with any `voro.toml`
362/// merged on top, plus the user's `default_agent` and viewers. Each agent
363/// carries its [`Provenance`] so `agent list` can show where it came from.
364#[derive(Debug, Clone)]
365pub struct AgentsConfig {
366    /// The user-set `default_agent`, if any; `None` falls back to a PATH probe.
367    default: Option<String>,
368    agents: BTreeMap<String, AgentTemplate>,
369    provenance: BTreeMap<String, Provenance>,
370    /// The anonymous `[viewer]` table — the pre-names single viewer, still
371    /// honoured as a default when no `default_viewer` is set.
372    viewer: Option<ViewerTemplate>,
373    /// The named `[viewers.<name>]` tables a project's review action can
374    /// pick from (DESIGN.md §8/§11a).
375    viewers: BTreeMap<String, ViewerTemplate>,
376    /// The user-set `default_viewer`, naming a `[viewers.*]` entry.
377    default_viewer: Option<String>,
378    path: PathBuf,
379}
380
381/// The config filename under the `voro/` config directory.
382const CONFIG_FILENAME: &str = "voro.toml";
383
384impl AgentsConfig {
385    /// The config path dispatch reads: `$XDG_CONFIG_HOME/voro/voro.toml`,
386    /// defaulting to `~/.config`. A fresh install resolves here even before
387    /// the file exists — that is the path `agent init` writes.
388    pub fn default_path() -> PathBuf {
389        let config_home = std::env::var_os("XDG_CONFIG_HOME")
390            .map(PathBuf::from)
391            .filter(|p| p.is_absolute())
392            .unwrap_or_else(|| {
393                let home = std::env::var_os("HOME")
394                    .map(PathBuf::from)
395                    .unwrap_or_default();
396                home.join(".config")
397            });
398        config_home.join("voro").join(CONFIG_FILENAME)
399    }
400
401    /// Load the effective config: the built-in agents, with the user file
402    /// layered on top if it exists. A missing file is not an error — the
403    /// built-ins alone dispatch — so a fresh install needs no `agent init`.
404    pub fn load(path: &Path) -> Result<AgentsConfig> {
405        match std::fs::read_to_string(path) {
406            Ok(text) => AgentsConfig::parse(&text, path),
407            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
408                Ok(AgentsConfig::builtin_only(path))
409            }
410            Err(e) => Err(Error::AgentConfigInvalid {
411                path: path.to_path_buf(),
412                message: e.to_string(),
413            }),
414        }
415    }
416
417    /// The built-in agents alone, with no user file layered on. Used when
418    /// the config file is absent.
419    fn builtin_only(path: &Path) -> AgentsConfig {
420        let agents = builtin_agents().clone();
421        let provenance = agents
422            .keys()
423            .map(|name| (name.clone(), Provenance::BuiltIn))
424            .collect();
425        AgentsConfig {
426            default: None,
427            agents,
428            provenance,
429            viewer: None,
430            viewers: BTreeMap::new(),
431            default_viewer: None,
432            path: path.to_path_buf(),
433        }
434    }
435
436    /// Parse the user file's text and layer it over the built-ins: a user
437    /// table replaces a built-in of the same name wholesale (whole-agent
438    /// override), otherwise it adds a new agent. `default_agent`/`[viewer]`
439    /// come from the file.
440    fn parse(text: &str, path: &Path) -> Result<AgentsConfig> {
441        let raw: RawConfig = toml::from_str(text).map_err(|e| Error::AgentConfigInvalid {
442            path: path.to_path_buf(),
443            message: e.message().to_string(),
444        })?;
445        for (name, agent) in &raw.agents {
446            validate_agent(name, agent, path)?;
447        }
448        let mut agents = builtin_agents().clone();
449        let mut provenance: BTreeMap<String, Provenance> = agents
450            .keys()
451            .map(|name| (name.clone(), Provenance::BuiltIn))
452            .collect();
453        for (name, agent) in raw.agents {
454            let prov = if builtin_agents().contains_key(&name) {
455                Provenance::UserOverride
456            } else {
457                Provenance::User
458            };
459            provenance.insert(name.clone(), prov);
460            agents.insert(name, agent);
461        }
462        Ok(AgentsConfig {
463            default: raw.default_agent,
464            agents,
465            provenance,
466            viewer: raw.viewer,
467            viewers: raw.viewers,
468            default_viewer: raw.default_viewer,
469            path: path.to_path_buf(),
470        })
471    }
472
473    /// Every agent name defined in the config, for the TUI's dispatch picker
474    /// (DESIGN.md §8/§9). `agents` is a `BTreeMap`, so this is already sorted.
475    pub fn agent_names(&self) -> Vec<String> {
476        self.agents.keys().cloned().collect()
477    }
478
479    /// The verb templates of a named agent, if it is configured. Used where a
480    /// session already records which agent ran it — jump-in, reconciliation —
481    /// so no default/override resolution applies.
482    pub fn agent(&self, name: &str) -> Option<&AgentTemplate> {
483        self.agents.get(name)
484    }
485
486    /// The agent for a task: its `agent` override if set, otherwise the
487    /// resolved default (§8). An override or default naming an agent absent
488    /// from the config is an error here, not a panic at spawn time.
489    pub fn resolve(&self, task_override: Option<&str>) -> Result<ResolvedAgent> {
490        self.resolve_with(task_override, &binary_on_path)
491    }
492
493    /// [`resolve`](Self::resolve) with an injectable PATH probe, so the
494    /// default-resolution path is testable without depending on what happens
495    /// to be installed.
496    fn resolve_with(
497        &self,
498        task_override: Option<&str>,
499        probe: &dyn Fn(&str) -> bool,
500    ) -> Result<ResolvedAgent> {
501        let (name, origin) = match task_override {
502            Some(name) => (name.to_string(), "task agent override"),
503            None => (self.effective_default(probe)?, "config default"),
504        };
505        let agent = self.agents.get(&name).ok_or_else(|| Error::UnknownAgent {
506            name: name.clone(),
507            origin,
508            path: self.path.clone(),
509            known: self.agents.keys().cloned().collect::<Vec<_>>().join(", "),
510        })?;
511        Ok(ResolvedAgent {
512            name,
513            dispatch: agent.dispatch().to_string(),
514            sessions: agent.sessions.clone(),
515            attach: agent.attach.clone(),
516            resume: agent.resume.clone(),
517            continue_cmd: agent.continue_.clone(),
518            plan: agent.plan.clone(),
519        })
520    }
521
522    /// The default agent's name: the user's `default` when set (honoured even
523    /// if it names a missing agent, so `resolve` reports the mismatch), else
524    /// the first built-in found on PATH. Errors with guidance when neither
525    /// yields anything.
526    fn effective_default(&self, probe: &dyn Fn(&str) -> bool) -> Result<String> {
527        if let Some(default) = &self.default {
528            return Ok(default.clone());
529        }
530        for candidate in DEFAULT_PROBE_ORDER {
531            if self.agents.contains_key(candidate) && probe(candidate) {
532                return Ok(candidate.to_string());
533            }
534        }
535        Err(Error::NoDefaultAgent {
536            probed: DEFAULT_PROBE_ORDER.join(", "),
537            path: self.path.clone(),
538        })
539    }
540
541    /// The names of the `[viewers.*]` tables, sorted, for the TUI's
542    /// review-action picker and `viewer list`.
543    pub fn viewer_names(&self) -> Vec<String> {
544        self.viewers.keys().cloned().collect()
545    }
546
547    /// The name of the viewer used when nothing picks one by name, for
548    /// `viewer list` to flag: the user's `default_viewer` when set (honoured
549    /// even if it names a missing viewer), else the sole `[viewers.*]` entry.
550    /// The anonymous `[viewer]` table has no name, so it yields `None` here
551    /// even though it resolves.
552    pub fn default_viewer_name(&self) -> Option<String> {
553        if self.default_viewer.is_some() {
554            return self.default_viewer.clone();
555        }
556        if self.viewer.is_none() && self.viewers.len() == 1 {
557            return self.viewers.keys().next().cloned();
558        }
559        None
560    }
561
562    /// Resolve a viewer command (DESIGN.md §11a): the named `[viewers.<name>]`
563    /// when a name is given, otherwise the default — `default_viewer` when set,
564    /// else the anonymous `[viewer]` table, else the sole `[viewers.*]` entry.
565    /// Errors carry what to configure.
566    pub fn viewer_cmd(&self, name: Option<&str>) -> Result<&str> {
567        let invalid = |message: String| Error::AgentConfigInvalid {
568            path: self.path.clone(),
569            message,
570        };
571        let named =
572            |name: &str| {
573                self.viewers.get(name).map(|v| v.cmd.as_str()).ok_or_else(|| {
574                let known = if self.viewers.is_empty() {
575                    "none are defined".to_string()
576                } else {
577                    format!("defined viewers: {}", self.viewer_names().join(", "))
578                };
579                invalid(format!(
580                    "no viewer named '{name}' — {known}; add a [viewers.{name}] table with a \
581                     cmd such as 'zed {{path}}' or 'git difftool -d'"
582                ))
583            })
584            };
585        match name {
586            Some(name) => named(name),
587            None => match &self.default_viewer {
588                Some(default) => named(default),
589                None => self
590                    .viewer
591                    .as_ref()
592                    .map(|v| v.cmd.as_str())
593                    .or_else(|| match self.viewers.len() {
594                        1 => self.viewers.values().next().map(|v| v.cmd.as_str()),
595                        _ => None,
596                    })
597                    .ok_or_else(|| {
598                        invalid(
599                            "no viewer configured; add a [viewers.<name>] table with a cmd \
600                             such as 'zed {path}' or 'git difftool -d' to see a task's diff \
601                             (set `default_viewer` when defining several)"
602                                .to_string(),
603                        )
604                    }),
605            },
606        }
607    }
608
609    /// The name of the agent used when a task has no override, for the CLI's
610    /// `agent list` to flag it. `None` when no `default` is set and no
611    /// built-in is on PATH — the same condition `resolve` errors on.
612    pub fn default_name(&self) -> Option<String> {
613        self.effective_default(&binary_on_path).ok()
614    }
615
616    /// The provenance of a named agent, if it is configured.
617    pub fn provenance(&self, name: &str) -> Option<Provenance> {
618        self.provenance.get(name).copied()
619    }
620
621    /// For a user override of a built-in, the verbs the built-in defines that
622    /// the override drops — the one case layering can't fix (§8), so
623    /// `agent list` can warn that those verbs stopped working. Empty for
624    /// built-in or purely-additive user agents.
625    pub fn override_missing_verbs(&self, name: &str) -> Vec<&'static str> {
626        if self.provenance.get(name) != Some(&Provenance::UserOverride) {
627            return Vec::new();
628        }
629        let (Some(user), Some(builtin)) = (self.agents.get(name), builtin_agents().get(name))
630        else {
631            return Vec::new();
632        };
633        [
634            (
635                "sessions",
636                builtin.sessions.is_some(),
637                user.sessions.is_some(),
638            ),
639            ("attach", builtin.attach.is_some(), user.attach.is_some()),
640            ("resume", builtin.resume.is_some(), user.resume.is_some()),
641            (
642                "continue",
643                builtin.continue_.is_some(),
644                user.continue_.is_some(),
645            ),
646            ("plan", builtin.plan.is_some(), user.plan.is_some()),
647        ]
648        .into_iter()
649        .filter_map(|(verb, in_builtin, in_user)| (in_builtin && !in_user).then_some(verb))
650        .collect()
651    }
652
653    /// Every agent as `(name, template, provenance)`, sorted by name, for
654    /// `agent list`.
655    pub fn entries(&self) -> impl Iterator<Item = (&str, &AgentTemplate, Provenance)> {
656        self.agents.iter().map(|(name, agent)| {
657            let prov = self
658                .provenance
659                .get(name)
660                .copied()
661                .unwrap_or(Provenance::User);
662            (name.as_str(), agent, prov)
663        })
664    }
665
666    /// Write the [`starter_config`] skeleton to `path`, creating parent
667    /// directories. Refuses to overwrite an existing file so a hand-tuned
668    /// config is never clobbered.
669    pub fn write_starter(path: &Path) -> Result<()> {
670        if path.exists() {
671            return Err(Error::Invalid(format!(
672                "{} already exists; edit it directly rather than reinitialising",
673                path.display()
674            )));
675        }
676        if let Some(parent) = path.parent() {
677            std::fs::create_dir_all(parent).map_err(|e| Error::AgentConfigInvalid {
678                path: path.to_path_buf(),
679                message: e.to_string(),
680            })?;
681        }
682        std::fs::write(path, starter_config()).map_err(|e| Error::AgentConfigInvalid {
683            path: path.to_path_buf(),
684            message: e.to_string(),
685        })
686    }
687}
688
689/// One session from an agent's `sessions` command output: a JSON array of
690/// objects, of which the fields below are read and everything else ignored.
691/// `sessionId` (falling back to `id`) is the durable reference substituted
692/// into `{session}`; `cwd` and `startedAt` (ms epoch) identify a fresh
693/// dispatch's session among its siblings; `state` is `"done"` once finished.
694#[derive(Debug, Clone, PartialEq, Eq)]
695pub struct AgentSessionEntry {
696    pub session_ref: String,
697    pub short_id: Option<String>,
698    pub cwd: Option<String>,
699    pub started_at_ms: Option<i64>,
700    pub state: Option<String>,
701}
702
703impl AgentSessionEntry {
704    /// Whether this entry is the session a stored reference points at — either
705    /// id form matches, since a log-parsed fallback may record the short id.
706    pub fn matches_ref(&self, session_ref: &str) -> bool {
707        self.session_ref == session_ref || self.short_id.as_deref() == Some(session_ref)
708    }
709
710    /// A finished session: still listed, but no longer running.
711    pub fn is_finished(&self) -> bool {
712        self.state.as_deref() == Some("done")
713    }
714}
715
716/// Parse a `sessions` command's stdout. Entries without any id are skipped
717/// rather than failing the whole listing; anything that is not a JSON array
718/// is an error, so a misconfigured `sessions` verb surfaces rather than
719/// reading as "no sessions".
720pub fn parse_sessions_json(json: &str) -> Result<Vec<AgentSessionEntry>> {
721    let value: serde_json::Value = serde_json::from_str(json)
722        .map_err(|e| Error::Invalid(format!("sessions output is not JSON: {e}")))?;
723    let array = value
724        .as_array()
725        .ok_or_else(|| Error::Invalid("sessions output is not a JSON array".into()))?;
726    let mut entries = Vec::new();
727    for item in array {
728        let get_str = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
729        let Some(session_ref) = get_str("sessionId").or_else(|| get_str("id")) else {
730            continue;
731        };
732        entries.push(AgentSessionEntry {
733            session_ref,
734            short_id: get_str("id"),
735            cwd: get_str("cwd"),
736            started_at_ms: item.get("startedAt").and_then(|v| v.as_i64()),
737            state: get_str("state"),
738        });
739    }
740    Ok(entries)
741}
742
743#[cfg(test)]
744mod tests {
745    use super::*;
746
747    const CONFIG: &str = r#"
748        default_agent = "claude"
749
750        [agents.claude]
751        cmd = "claude -p --output-format stream-json {prompt_file}"
752
753        [agents.codex]
754        cmd = "codex exec {prompt_file}"
755    "#;
756
757    fn config() -> AgentsConfig {
758        AgentsConfig::parse(CONFIG, Path::new("/tmp/voro.toml")).unwrap()
759    }
760
761    #[test]
762    fn agent_names_lists_every_configured_agent() {
763        assert_eq!(config().agent_names(), vec!["claude", "codex"]);
764    }
765
766    #[test]
767    fn resolves_default_when_task_has_no_override() {
768        let resolved = config().resolve(None).unwrap();
769        assert_eq!(resolved.name, "claude");
770        assert_eq!(
771            resolved.dispatch,
772            "claude -p --output-format stream-json {prompt_file}"
773        );
774    }
775
776    #[test]
777    fn task_override_wins_over_default() {
778        let resolved = config().resolve(Some("codex")).unwrap();
779        assert_eq!(resolved.name, "codex");
780        assert_eq!(resolved.dispatch, "codex exec {prompt_file}");
781    }
782
783    #[test]
784    fn unknown_override_errors_at_resolution() {
785        let err = config().resolve(Some("gemini")).unwrap_err();
786        let message = err.to_string();
787        assert!(message.contains("gemini"), "{message}");
788        assert!(message.contains("task agent override"), "{message}");
789        assert!(message.contains("claude, codex"), "{message}");
790    }
791
792    #[test]
793    fn unknown_default_errors_at_resolution() {
794        let text = r#"
795            default_agent = "gemini"
796
797            [agents.claude]
798            cmd = "claude -p {prompt_file}"
799        "#;
800        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
801        let message = config.resolve(None).unwrap_err().to_string();
802        assert!(message.contains("gemini"), "{message}");
803        assert!(message.contains("config default"), "{message}");
804    }
805
806    #[test]
807    fn cmd_without_prompt_file_placeholder_is_rejected() {
808        let text = r#"
809            default_agent = "claude"
810
811            [agents.claude]
812            cmd = "claude -p"
813        "#;
814        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
815            .unwrap_err()
816            .to_string();
817        assert!(message.contains("{prompt_file}"), "{message}");
818        assert!(message.contains("claude"), "{message}");
819    }
820
821    #[test]
822    fn invalid_toml_names_the_file() {
823        let message = AgentsConfig::parse("default = ", Path::new("/tmp/voro.toml"))
824            .unwrap_err()
825            .to_string();
826        assert!(message.contains("/tmp/voro.toml"), "{message}");
827    }
828
829    #[test]
830    fn loads_from_disk() {
831        let path = std::env::temp_dir().join(format!("voro-agents-{}.toml", std::process::id()));
832        std::fs::write(&path, CONFIG).unwrap();
833        let config = AgentsConfig::load(&path).unwrap();
834        std::fs::remove_file(&path).unwrap();
835        assert_eq!(config.resolve(None).unwrap().name, "claude");
836    }
837
838    #[test]
839    fn missing_file_loads_the_builtins() {
840        let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
841        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
842        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
843        let claude = config.agent("claude").unwrap();
844        assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
845        assert!(claude.sessions().is_some());
846        assert!(claude.attach().is_some());
847        assert!(claude.resume().is_some());
848    }
849
850    #[test]
851    fn builtins_layer_under_a_user_file() {
852        let text = r#"
853            [agents.mycustom]
854            dispatch = "mytool {prompt_file}"
855        "#;
856        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
857        assert_eq!(config.agent_names(), vec!["claude", "codex", "mycustom"]);
858        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
859        assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
860        assert_eq!(config.provenance("mycustom"), Some(Provenance::User));
861        assert!(config.agent("claude").unwrap().sessions().is_some());
862    }
863
864    #[test]
865    fn a_user_table_overrides_a_builtin_wholesale() {
866        let text = r#"
867            [agents.claude]
868            cmd = "claude -p {prompt_file}"
869        "#;
870        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
871        assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
872        let claude = config.agent("claude").unwrap();
873        assert_eq!(claude.dispatch(), "claude -p {prompt_file}");
874        assert_eq!(claude.sessions(), None, "override is not merged per-verb");
875        assert_eq!(claude.attach(), None);
876        assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
877    }
878
879    #[test]
880    fn override_dropping_verbs_is_reported() {
881        let text = r#"
882            [agents.claude]
883            cmd = "claude -p {prompt_file}"
884        "#;
885        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
886        let missing = config.override_missing_verbs("claude");
887        assert!(missing.contains(&"sessions"), "{missing:?}");
888        assert!(missing.contains(&"attach"), "{missing:?}");
889        assert!(missing.contains(&"resume"), "{missing:?}");
890        assert!(config.override_missing_verbs("codex").is_empty());
891    }
892
893    #[test]
894    fn default_probes_path_when_the_user_sets_none() {
895        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
896        let only_codex = |name: &str| name == "codex";
897        assert_eq!(
898            config.resolve_with(None, &only_codex).unwrap().name,
899            "codex"
900        );
901        let both = |_: &str| true;
902        assert_eq!(config.resolve_with(None, &both).unwrap().name, "claude");
903    }
904
905    #[test]
906    fn no_default_and_nothing_on_path_errors_with_guidance() {
907        let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
908        let none = |_: &str| false;
909        let message = config.resolve_with(None, &none).unwrap_err().to_string();
910        assert!(message.contains("no default agent"), "{message}");
911        assert!(message.contains("claude, codex"), "{message}");
912    }
913
914    #[test]
915    fn user_default_is_honoured_over_the_path_probe() {
916        let text = r#"
917            default_agent = "codex"
918        "#;
919        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
920        let both = |_: &str| true;
921        assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
922    }
923
924    // --- session verbs ---
925
926    const VERBS_CONFIG: &str = r#"
927        default_agent = "claude"
928
929        [agents.claude]
930        dispatch = "claude --bg \"$(cat {prompt_file})\""
931        sessions = "claude agents --json"
932        attach   = "claude attach {session}"
933        resume   = "claude --resume {session}"
934
935        [agents.codex]
936        dispatch = "codex exec {prompt_file}"
937        resume   = "codex resume {session}"
938        continue = "codex exec resume {session} \"$(cat {prompt_file})\""
939    "#;
940
941    #[test]
942    fn verbs_parse_and_resolve() {
943        let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
944        let claude = config.resolve(None).unwrap();
945        assert_eq!(claude.sessions.as_deref(), Some("claude agents --json"));
946        assert_eq!(claude.attach.as_deref(), Some("claude attach {session}"));
947        assert_eq!(claude.resume.as_deref(), Some("claude --resume {session}"));
948        assert_eq!(claude.continue_cmd, None);
949
950        let codex = config.resolve(Some("codex")).unwrap();
951        assert_eq!(codex.sessions, None);
952        assert_eq!(codex.attach, None);
953        assert_eq!(
954            codex.continue_cmd.as_deref(),
955            Some("codex exec resume {session} \"$(cat {prompt_file})\"")
956        );
957    }
958
959    #[test]
960    fn cmd_alias_behaves_as_dispatch_with_every_verb_absent() {
961        let resolved = config().resolve(None).unwrap();
962        assert_eq!(
963            resolved.dispatch,
964            "claude -p --output-format stream-json {prompt_file}"
965        );
966        assert_eq!(resolved.sessions, None);
967        assert_eq!(resolved.attach, None);
968        assert_eq!(resolved.resume, None);
969        assert_eq!(resolved.continue_cmd, None);
970    }
971
972    #[test]
973    fn both_dispatch_and_cmd_is_rejected() {
974        let text = r#"
975            default_agent = "claude"
976
977            [agents.claude]
978            cmd = "claude -p {prompt_file}"
979            dispatch = "claude --bg {prompt_file}"
980        "#;
981        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
982            .unwrap_err()
983            .to_string();
984        assert!(message.contains("alias"), "{message}");
985    }
986
987    #[test]
988    fn agent_without_dispatch_or_cmd_is_rejected() {
989        let text = r#"
990            default_agent = "claude"
991
992            [agents.claude]
993            sessions = "claude agents --json"
994        "#;
995        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
996            .unwrap_err()
997            .to_string();
998        assert!(message.contains("dispatch"), "{message}");
999    }
1000
1001    #[test]
1002    fn attach_resume_continue_require_the_session_placeholder() {
1003        for verb in ["attach", "resume", "continue"] {
1004            let text = format!(
1005                "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
1006                 {verb} = \"reopen {{prompt_file}}\"\n"
1007            );
1008            let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
1009                .unwrap_err()
1010                .to_string();
1011            assert!(message.contains("{session}"), "{verb}: {message}");
1012            assert!(message.contains(verb), "{verb}: {message}");
1013        }
1014    }
1015
1016    // --- plan verb (task #112) ---
1017
1018    #[test]
1019    fn plan_parses_resolves_and_is_optional() {
1020        let text = r#"
1021            default_agent = "a"
1022
1023            [agents.a]
1024            dispatch = "run {prompt_file}"
1025            plan = "run --interactive {prompt_file}"
1026
1027            [agents.b]
1028            dispatch = "other {prompt_file}"
1029        "#;
1030        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1031        let a = config.resolve(None).unwrap();
1032        assert_eq!(a.plan.as_deref(), Some("run --interactive {prompt_file}"));
1033        assert_eq!(config.agent("a").unwrap().plan(), a.plan.as_deref());
1034        // an agent without the verb resolves with it absent, like the others
1035        let b = config.resolve(Some("b")).unwrap();
1036        assert_eq!(b.plan, None);
1037        assert_eq!(config.agent("b").unwrap().plan(), None);
1038    }
1039
1040    #[test]
1041    fn plan_requires_the_prompt_file_placeholder() {
1042        let text = r#"
1043            default_agent = "a"
1044
1045            [agents.a]
1046            dispatch = "run {prompt_file}"
1047            plan = "run --interactive"
1048        "#;
1049        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1050            .unwrap_err()
1051            .to_string();
1052        assert!(message.contains("{prompt_file}"), "{message}");
1053        assert!(message.contains("plan"), "{message}");
1054    }
1055
1056    #[test]
1057    fn builtin_claude_defines_plan_and_an_override_dropping_it_is_reported() {
1058        let agents = builtin_agents();
1059        let plan = agents["claude"].plan().unwrap();
1060        assert!(plan.contains(PROMPT_FILE_PLACEHOLDER), "{plan}");
1061        assert!(
1062            !plan.contains("--bg"),
1063            "plan runs in the foreground: {plan}"
1064        );
1065        assert!(agents["codex"].plan().is_none());
1066
1067        let text = r#"
1068            [agents.claude]
1069            cmd = "claude -p {prompt_file}"
1070        "#;
1071        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1072        assert!(
1073            config.override_missing_verbs("claude").contains(&"plan"),
1074            "{:?}",
1075            config.override_missing_verbs("claude")
1076        );
1077    }
1078
1079    #[test]
1080    fn continue_requires_the_prompt_file_placeholder() {
1081        let text = r#"
1082            default_agent = "a"
1083
1084            [agents.a]
1085            dispatch = "run {prompt_file}"
1086            continue = "reopen {session}"
1087        "#;
1088        let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1089            .unwrap_err()
1090            .to_string();
1091        assert!(message.contains("{prompt_file}"), "{message}");
1092        assert!(message.contains("continue"), "{message}");
1093    }
1094
1095    #[test]
1096    fn agent_looks_up_templates_by_name() {
1097        let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
1098        let claude = config.agent("claude").unwrap();
1099        assert_eq!(claude.attach(), Some("claude attach {session}"));
1100        assert_eq!(claude.sessions(), Some("claude agents --json"));
1101        assert!(config.agent("gemini").is_none());
1102    }
1103
1104    #[test]
1105    fn parse_sessions_json_reads_the_listing_shape() {
1106        let json = r#"[
1107            {"pid": 4321, "id": "deadbeef", "cwd": "/tmp/proj", "kind": "background",
1108             "startedAt": 1767950000000, "sessionId": "3f6c0e6e-1111-2222-3333-444455556666",
1109             "name": "t", "status": "idle", "state": "done"},
1110            {"id": "cafebabe", "cwd": "/tmp/other", "startedAt": 1767950001000},
1111            {"pid": 1}
1112        ]"#;
1113        let entries = parse_sessions_json(json).unwrap();
1114        assert_eq!(entries.len(), 2, "the id-less entry is skipped");
1115        assert_eq!(
1116            entries[0].session_ref,
1117            "3f6c0e6e-1111-2222-3333-444455556666"
1118        );
1119        assert_eq!(entries[0].short_id.as_deref(), Some("deadbeef"));
1120        assert_eq!(entries[0].cwd.as_deref(), Some("/tmp/proj"));
1121        assert_eq!(entries[0].started_at_ms, Some(1767950000000));
1122        assert!(entries[0].is_finished());
1123        assert!(entries[0].matches_ref("deadbeef"), "short id matches too");
1124        assert!(entries[0].matches_ref("3f6c0e6e-1111-2222-3333-444455556666"));
1125
1126        assert_eq!(entries[1].session_ref, "cafebabe", "id is the fallback");
1127        assert!(!entries[1].is_finished(), "no state means not finished");
1128    }
1129
1130    #[test]
1131    fn parse_sessions_json_rejects_non_arrays() {
1132        assert!(parse_sessions_json("{}").is_err());
1133        assert!(parse_sessions_json("not json").is_err());
1134        assert_eq!(parse_sessions_json("[]").unwrap(), vec![]);
1135    }
1136
1137    #[test]
1138    fn viewer_resolution_errors_with_guidance_when_nothing_is_configured() {
1139        let message = config().viewer_cmd(None).unwrap_err().to_string();
1140        assert!(message.contains("no viewer configured"), "{message}");
1141        assert!(message.contains("[viewers.<name>]"), "{message}");
1142        assert!(message.contains("/tmp/voro.toml"), "{message}");
1143        assert!(config().viewer_names().is_empty());
1144        assert_eq!(config().default_viewer_name(), None);
1145    }
1146
1147    #[test]
1148    fn the_anonymous_viewer_table_is_the_default_viewer() {
1149        let text = r#"
1150            default_agent = "claude"
1151
1152            [agents.claude]
1153            cmd = "claude -p {prompt_file}"
1154
1155            [viewer]
1156            cmd = "zed {path}"
1157        "#;
1158        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1159        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
1160    }
1161
1162    #[test]
1163    fn named_viewers_resolve_by_name_and_default_viewer_picks_among_them() {
1164        let text = r#"
1165            default_viewer = "zed"
1166
1167            [viewers.zed]
1168            cmd = "zed {path}"
1169
1170            [viewers.difftool]
1171            cmd = "git difftool -d"
1172        "#;
1173        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1174        assert_eq!(config.viewer_names(), vec!["difftool", "zed"]);
1175        assert_eq!(
1176            config.viewer_cmd(Some("difftool")).unwrap(),
1177            "git difftool -d"
1178        );
1179        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
1180        assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
1181    }
1182
1183    #[test]
1184    fn a_sole_named_viewer_is_the_default_without_being_named() {
1185        let text = r#"
1186            [viewers.zed]
1187            cmd = "zed {path}"
1188        "#;
1189        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1190        assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
1191        assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
1192    }
1193
1194    #[test]
1195    fn several_named_viewers_without_a_default_error_with_guidance() {
1196        let text = r#"
1197            [viewers.zed]
1198            cmd = "zed {path}"
1199
1200            [viewers.difftool]
1201            cmd = "git difftool -d"
1202        "#;
1203        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1204        let message = config.viewer_cmd(None).unwrap_err().to_string();
1205        assert!(message.contains("default_viewer"), "{message}");
1206    }
1207
1208    #[test]
1209    fn an_unknown_viewer_name_errors_listing_the_known_ones() {
1210        let text = r#"
1211            [viewers.zed]
1212            cmd = "zed {path}"
1213        "#;
1214        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1215        let message = config.viewer_cmd(Some("emacs")).unwrap_err().to_string();
1216        assert!(message.contains("emacs"), "{message}");
1217        assert!(message.contains("zed"), "{message}");
1218        // a default_viewer naming a missing table reports the same way
1219        let text = r#"default_viewer = "gone""#;
1220        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1221        let message = config.viewer_cmd(None).unwrap_err().to_string();
1222        assert!(message.contains("gone"), "{message}");
1223    }
1224
1225    #[test]
1226    fn starter_config_defines_nothing_and_leaves_the_builtins() {
1227        let config = AgentsConfig::parse(&starter_config(), Path::new("/tmp/voro.toml")).unwrap();
1228        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
1229        assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
1230        assert!(config.viewer_names().is_empty());
1231        assert!(config.viewer_cmd(None).is_err());
1232        let claude = config.agent("claude").unwrap();
1233        assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
1234        assert!(
1235            claude.dispatch().contains("voro-{task_id}"),
1236            "{}",
1237            claude.dispatch()
1238        );
1239        assert!(claude.sessions().is_some());
1240        assert!(claude.attach().is_some());
1241        assert!(claude.resume().is_some());
1242    }
1243
1244    #[test]
1245    fn starter_config_reproduces_the_builtins_commented_for_copying() {
1246        let skeleton = starter_config();
1247        for line in BUILTIN_AGENTS.lines().filter(|l| !l.is_empty()) {
1248            let commented = format!("# {line}");
1249            assert!(
1250                skeleton.contains(&commented),
1251                "skeleton is missing built-in line: {commented}"
1252            );
1253        }
1254        // Uncommenting the reproduced claude block must yield a valid override.
1255        let uncommented: String = BUILTIN_AGENTS
1256            .lines()
1257            .take_while(|l| !l.starts_with("[agents.codex]"))
1258            .collect::<Vec<_>>()
1259            .join("\n");
1260        let config = AgentsConfig::parse(&uncommented, Path::new("/tmp/voro.toml")).unwrap();
1261        assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
1262        assert!(config.override_missing_verbs("claude").is_empty());
1263    }
1264
1265    #[test]
1266    fn entries_carry_name_template_and_provenance() {
1267        // CONFIG overrides both built-ins wholesale, hence UserOverride below.
1268        let config = config();
1269        let entries: Vec<_> = config.entries().collect();
1270        assert_eq!(entries.len(), 2);
1271        assert_eq!(entries[0].0, "claude");
1272        assert_eq!(
1273            entries[0].1.dispatch(),
1274            "claude -p --output-format stream-json {prompt_file}"
1275        );
1276        assert_eq!(entries[0].2, Provenance::UserOverride);
1277        assert_eq!(entries[1].0, "codex");
1278        assert_eq!(entries[1].1.dispatch(), "codex exec {prompt_file}");
1279        assert_eq!(entries[1].2, Provenance::UserOverride);
1280    }
1281
1282    #[test]
1283    fn write_starter_creates_parent_and_refuses_to_clobber() {
1284        let dir = std::env::temp_dir().join(format!("voro-init-{}", std::process::id()));
1285        let path = dir.join("voro/voro.toml");
1286        let _ = std::fs::remove_dir_all(&dir);
1287
1288        AgentsConfig::write_starter(&path).unwrap();
1289        let config = AgentsConfig::load(&path).unwrap();
1290        assert_eq!(config.agent_names(), vec!["claude", "codex"]);
1291
1292        let err = AgentsConfig::write_starter(&path).unwrap_err().to_string();
1293        assert!(err.contains("already exists"), "{err}");
1294
1295        std::fs::remove_dir_all(&dir).unwrap();
1296    }
1297
1298    #[test]
1299    fn builtins_parse_and_validate() {
1300        let agents = builtin_agents();
1301        assert!(agents.contains_key("claude"));
1302        assert!(agents.contains_key("codex"));
1303        assert!(agents["claude"].sessions().is_some());
1304        assert!(agents["codex"].continue_cmd().is_some());
1305    }
1306
1307    #[test]
1308    fn builtin_claude_carries_per_purpose_model_defaults() {
1309        let claude = &builtin_agents()["claude"];
1310        // dispatch runs a workhorse implementation model, plan a stronger one;
1311        // both via `claude` model aliases, so neither churns with a release.
1312        assert!(
1313            claude.dispatch().contains("--model opus"),
1314            "{}",
1315            claude.dispatch()
1316        );
1317        let plan = claude.plan().unwrap();
1318        assert!(plan.contains("--model fable"), "{plan}");
1319    }
1320
1321    #[test]
1322    fn default_agent_key_sets_the_default() {
1323        let text = r#"
1324            default_agent = "codex"
1325        "#;
1326        let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1327        let both = |_: &str| true;
1328        assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
1329    }
1330}