1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21
22pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
25
26pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
30
31pub const SESSION_PLACEHOLDER: &str = "{session}";
35
36pub const VIEWER_PATH_PLACEHOLDER: &str = "{path}";
41
42pub const VIEWER_BRANCH_PLACEHOLDER: &str = "{branch}";
47
48pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
51
52const 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
79const DEFAULT_PROBE_ORDER: [&str; 2] = ["claude", "codex"];
82
83fn 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
97const 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
134fn 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#[derive(Debug, Clone, Deserialize)]
177#[serde(deny_unknown_fields)]
178pub struct AgentTemplate {
179 dispatch: Option<String>,
180 cmd: Option<String>,
182 sessions: Option<String>,
183 attach: Option<String>,
184 resume: Option<String>,
185 #[serde(rename = "continue")]
186 continue_: Option<String>,
187 plan: Option<String>,
191}
192
193impl AgentTemplate {
194 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#[derive(Debug, Clone, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub struct ViewerTemplate {
233 pub cmd: String,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240pub enum Provenance {
241 BuiltIn,
243 User,
245 UserOverride,
247}
248
249impl Provenance {
250 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#[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
278fn 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
337fn 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#[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#[derive(Debug, Clone)]
365pub struct AgentsConfig {
366 default: Option<String>,
368 agents: BTreeMap<String, AgentTemplate>,
369 provenance: BTreeMap<String, Provenance>,
370 viewer: Option<ViewerTemplate>,
373 viewers: BTreeMap<String, ViewerTemplate>,
376 default_viewer: Option<String>,
378 path: PathBuf,
379}
380
381const CONFIG_FILENAME: &str = "voro.toml";
383
384impl AgentsConfig {
385 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 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 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 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 pub fn agent_names(&self) -> Vec<String> {
476 self.agents.keys().cloned().collect()
477 }
478
479 pub fn agent(&self, name: &str) -> Option<&AgentTemplate> {
483 self.agents.get(name)
484 }
485
486 pub fn resolve(&self, task_override: Option<&str>) -> Result<ResolvedAgent> {
490 self.resolve_with(task_override, &binary_on_path)
491 }
492
493 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 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 pub fn viewer_names(&self) -> Vec<String> {
544 self.viewers.keys().cloned().collect()
545 }
546
547 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 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 pub fn default_name(&self) -> Option<String> {
613 self.effective_default(&binary_on_path).ok()
614 }
615
616 pub fn provenance(&self, name: &str) -> Option<Provenance> {
618 self.provenance.get(name).copied()
619 }
620
621 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 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 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#[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 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 pub fn is_finished(&self) -> bool {
712 self.state.as_deref() == Some("done")
713 }
714}
715
716pub 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 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 #[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 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 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 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 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 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}