use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use serde::Deserialize;
use crate::error::{Error, Result};
pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
pub const SESSION_PLACEHOLDER: &str = "{session}";
pub const VIEWER_PATH_PLACEHOLDER: &str = "{path}";
pub const VIEWER_BRANCH_PLACEHOLDER: &str = "{branch}";
pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
const BUILTIN_AGENTS: &str = "\
[agents.claude]
dispatch = \"claude --bg --name \\\"voro-{task_id}\\\" --permission-mode auto --model opus \\\"$(cat {prompt_file})\\\"\"
sessions = \"claude agents --json\"
attach = \"claude attach {session}\"
resume = \"claude --resume {session}\"
plan = \"claude --permission-mode auto --model fable \\\"$(cat {prompt_file})\\\"\"
[agents.codex]
dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\"
resume = \"codex resume {session}\"
continue = \"codex exec resume {session} \\\"$(cat {prompt_file})\\\"\"
";
const DEFAULT_PROBE_ORDER: [&str; 2] = ["claude", "codex"];
fn builtin_agents() -> &'static BTreeMap<String, AgentTemplate> {
static BUILTINS: LazyLock<BTreeMap<String, AgentTemplate>> = LazyLock::new(|| {
let raw: RawConfig = toml::from_str(BUILTIN_AGENTS).expect("built-in agents TOML parses");
for (name, agent) in &raw.agents {
validate_agent(name, agent, Path::new("<built-in>")).expect("built-in agent is valid");
}
raw.agents
});
&BUILTINS
}
const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml).
#
# This file is OPTIONAL. Voro ships with built-in `claude` and `codex` agents,
# so a fresh install with one of those on PATH can dispatch with no config here.
# Run `voro agent list` to see the effective agents and where each comes from.
#
# Use this file to extend or override the built-ins, and to set app options:
#
# * add your own agent — a new [agents.<name>] table. Only `dispatch` is
# required (`cmd` is an alias): it starts a session on a task, with
# `{prompt_file}` replaced by the prompt file's path and the optional
# `{task_id}` by the task's numeric id. The optional session verbs unlock
# attachable dispatch, and each degrades gracefully when absent:
# sessions list the agent's sessions as JSON (liveness + ref capture)
# attach open a running session interactively ({session})
# resume reopen a finished session interactively ({session})
# continue feed a session new input headless ({session} {prompt_file})
# plan run an interactive foreground planning session ({prompt_file})
# See docs/agent-integration.md for the full contract.
# * override a built-in — a table named `claude` or `codex` REPLACES that
# built-in entirely (not per-verb), so copy every verb you still want. The
# built-ins are reproduced below, commented out, ready to copy.
# * set `default_agent` — used for tasks with no --agent override. When unset,
# Voro picks the first built-in found on PATH (claude, then codex).
# * set up viewers — [viewers.<name>] tables define how a task's diff is
# shown locally when `voro pr`/`voro open` resolve to the viewer medium
# (DESIGN.md §8). A viewer cmd may carry `{path}` (the task's worktree, or
# the project checkout when it has none), `{branch}` (the task's branch, or
# empty), and `{base}` (the checkout's default branch); `{base}...{branch}`
# spells a diff range. `default_viewer` names the one used when a project
# does not pick a viewer itself (`voro project action <p> viewer:<name>`); a
# single anonymous [viewer] table is the older, still-valid spelling of
# the default.
"#;
fn starter_config() -> String {
let mut out = String::from(STARTER_HEADER);
out.push_str(
"\n# --------------------------------------------------------------------------\n\
# Built-in agents, exactly as shipped. Uncomment a block and edit it to\n\
# override that agent wholesale; leave it commented to keep the built-in,\n\
# which updates with Voro. Copy a block to model a new agent of your own.\n\
# --------------------------------------------------------------------------\n#\n",
);
for line in BUILTIN_AGENTS.lines() {
if line.is_empty() {
out.push_str("#\n");
} else {
out.push_str("# ");
out.push_str(line);
out.push('\n');
}
}
out.push_str(
"\n# --------------------------------------------------------------------------\n\
# Examples (uncomment and tune):\n#\n\
# default_agent = \"claude\"\n#\n\
# [agents.mine]\n\
# dispatch = \"my-agent run {prompt_file}\"\n#\n\
# default_viewer = \"zed\"\n#\n\
# [viewers.zed]\n\
# cmd = \"zed {path}\"\n#\n\
# [viewers.difftool]\n\
# cmd = \"git -C {path} difftool -d {base}...{branch}\"\n",
);
out
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AgentTemplate {
dispatch: Option<String>,
cmd: Option<String>,
sessions: Option<String>,
attach: Option<String>,
resume: Option<String>,
#[serde(rename = "continue")]
continue_: Option<String>,
plan: Option<String>,
}
impl AgentTemplate {
pub fn dispatch(&self) -> &str {
self.dispatch
.as_deref()
.or(self.cmd.as_deref())
.expect("parse validates that dispatch or cmd is set")
}
pub fn sessions(&self) -> Option<&str> {
self.sessions.as_deref()
}
pub fn attach(&self) -> Option<&str> {
self.attach.as_deref()
}
pub fn resume(&self) -> Option<&str> {
self.resume.as_deref()
}
pub fn continue_cmd(&self) -> Option<&str> {
self.continue_.as_deref()
}
pub fn plan(&self) -> Option<&str> {
self.plan.as_deref()
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ViewerTemplate {
pub cmd: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
BuiltIn,
User,
UserOverride,
}
impl Provenance {
pub fn label(self) -> &'static str {
match self {
Provenance::BuiltIn => "built-in",
Provenance::User => "user",
Provenance::UserOverride => "user override",
}
}
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawConfig {
#[serde(default)]
default_agent: Option<String>,
#[serde(default)]
agents: BTreeMap<String, AgentTemplate>,
#[serde(default)]
viewer: Option<ViewerTemplate>,
#[serde(default)]
viewers: BTreeMap<String, ViewerTemplate>,
#[serde(default)]
default_viewer: Option<String>,
}
fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> {
let invalid = |message: String| Error::AgentConfigInvalid {
path: path.to_path_buf(),
message,
};
let dispatch = match (&agent.dispatch, &agent.cmd) {
(Some(_), Some(_)) => {
return Err(invalid(format!(
"agent '{name}' sets both dispatch and cmd — cmd is an alias for \
dispatch, keep one"
)));
}
(Some(d), None) => d,
(None, Some(c)) => c,
(None, None) => {
return Err(invalid(format!(
"agent '{name}' is missing a dispatch (or cmd) template"
)));
}
};
if !dispatch.contains(PROMPT_FILE_PLACEHOLDER) {
return Err(invalid(format!(
"agent '{name}' cmd is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
)));
}
for (verb, template) in [
("attach", &agent.attach),
("resume", &agent.resume),
("continue", &agent.continue_),
] {
if let Some(template) = template
&& !template.contains(SESSION_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' {verb} is missing the {SESSION_PLACEHOLDER} placeholder"
)));
}
}
if let Some(template) = &agent.continue_
&& !template.contains(PROMPT_FILE_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' continue is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
)));
}
if let Some(template) = &agent.plan
&& !template.contains(PROMPT_FILE_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' plan is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
)));
}
Ok(())
}
fn binary_on_path(name: &str) -> bool {
let Some(paths) = std::env::var_os("PATH") else {
return false;
};
std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAgent {
pub name: String,
pub dispatch: String,
pub sessions: Option<String>,
pub attach: Option<String>,
pub resume: Option<String>,
pub continue_cmd: Option<String>,
pub plan: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AgentsConfig {
default: Option<String>,
agents: BTreeMap<String, AgentTemplate>,
provenance: BTreeMap<String, Provenance>,
viewer: Option<ViewerTemplate>,
viewers: BTreeMap<String, ViewerTemplate>,
default_viewer: Option<String>,
path: PathBuf,
}
const CONFIG_FILENAME: &str = "voro.toml";
impl AgentsConfig {
pub fn default_path() -> PathBuf {
let config_home = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.unwrap_or_else(|| {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_default();
home.join(".config")
});
config_home.join("voro").join(CONFIG_FILENAME)
}
pub fn load(path: &Path) -> Result<AgentsConfig> {
match std::fs::read_to_string(path) {
Ok(text) => AgentsConfig::parse(&text, path),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
Ok(AgentsConfig::builtin_only(path))
}
Err(e) => Err(Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: e.to_string(),
}),
}
}
fn builtin_only(path: &Path) -> AgentsConfig {
let agents = builtin_agents().clone();
let provenance = agents
.keys()
.map(|name| (name.clone(), Provenance::BuiltIn))
.collect();
AgentsConfig {
default: None,
agents,
provenance,
viewer: None,
viewers: BTreeMap::new(),
default_viewer: None,
path: path.to_path_buf(),
}
}
fn parse(text: &str, path: &Path) -> Result<AgentsConfig> {
let raw: RawConfig = toml::from_str(text).map_err(|e| Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: e.message().to_string(),
})?;
for (name, agent) in &raw.agents {
validate_agent(name, agent, path)?;
}
let mut agents = builtin_agents().clone();
let mut provenance: BTreeMap<String, Provenance> = agents
.keys()
.map(|name| (name.clone(), Provenance::BuiltIn))
.collect();
for (name, agent) in raw.agents {
let prov = if builtin_agents().contains_key(&name) {
Provenance::UserOverride
} else {
Provenance::User
};
provenance.insert(name.clone(), prov);
agents.insert(name, agent);
}
Ok(AgentsConfig {
default: raw.default_agent,
agents,
provenance,
viewer: raw.viewer,
viewers: raw.viewers,
default_viewer: raw.default_viewer,
path: path.to_path_buf(),
})
}
pub fn agent_names(&self) -> Vec<String> {
self.agents.keys().cloned().collect()
}
pub fn agent(&self, name: &str) -> Option<&AgentTemplate> {
self.agents.get(name)
}
pub fn resolve(&self, task_override: Option<&str>) -> Result<ResolvedAgent> {
self.resolve_with(task_override, &binary_on_path)
}
fn resolve_with(
&self,
task_override: Option<&str>,
probe: &dyn Fn(&str) -> bool,
) -> Result<ResolvedAgent> {
let (name, origin) = match task_override {
Some(name) => (name.to_string(), "task agent override"),
None => (self.effective_default(probe)?, "config default"),
};
let agent = self.agents.get(&name).ok_or_else(|| Error::UnknownAgent {
name: name.clone(),
origin,
path: self.path.clone(),
known: self.agents.keys().cloned().collect::<Vec<_>>().join(", "),
})?;
Ok(ResolvedAgent {
name,
dispatch: agent.dispatch().to_string(),
sessions: agent.sessions.clone(),
attach: agent.attach.clone(),
resume: agent.resume.clone(),
continue_cmd: agent.continue_.clone(),
plan: agent.plan.clone(),
})
}
fn effective_default(&self, probe: &dyn Fn(&str) -> bool) -> Result<String> {
if let Some(default) = &self.default {
return Ok(default.clone());
}
for candidate in DEFAULT_PROBE_ORDER {
if self.agents.contains_key(candidate) && probe(candidate) {
return Ok(candidate.to_string());
}
}
Err(Error::NoDefaultAgent {
probed: DEFAULT_PROBE_ORDER.join(", "),
path: self.path.clone(),
})
}
pub fn viewer_names(&self) -> Vec<String> {
self.viewers.keys().cloned().collect()
}
pub fn default_viewer_name(&self) -> Option<String> {
if self.default_viewer.is_some() {
return self.default_viewer.clone();
}
if self.viewer.is_none() && self.viewers.len() == 1 {
return self.viewers.keys().next().cloned();
}
None
}
pub fn viewer_cmd(&self, name: Option<&str>) -> Result<&str> {
let invalid = |message: String| Error::AgentConfigInvalid {
path: self.path.clone(),
message,
};
let named =
|name: &str| {
self.viewers.get(name).map(|v| v.cmd.as_str()).ok_or_else(|| {
let known = if self.viewers.is_empty() {
"none are defined".to_string()
} else {
format!("defined viewers: {}", self.viewer_names().join(", "))
};
invalid(format!(
"no viewer named '{name}' — {known}; add a [viewers.{name}] table with a \
cmd such as 'zed {{path}}' or 'git difftool -d'"
))
})
};
match name {
Some(name) => named(name),
None => match &self.default_viewer {
Some(default) => named(default),
None => self
.viewer
.as_ref()
.map(|v| v.cmd.as_str())
.or_else(|| match self.viewers.len() {
1 => self.viewers.values().next().map(|v| v.cmd.as_str()),
_ => None,
})
.ok_or_else(|| {
invalid(
"no viewer configured; add a [viewers.<name>] table with a cmd \
such as 'zed {path}' or 'git difftool -d' to see a task's diff \
(set `default_viewer` when defining several)"
.to_string(),
)
}),
},
}
}
pub fn default_name(&self) -> Option<String> {
self.effective_default(&binary_on_path).ok()
}
pub fn provenance(&self, name: &str) -> Option<Provenance> {
self.provenance.get(name).copied()
}
pub fn override_missing_verbs(&self, name: &str) -> Vec<&'static str> {
if self.provenance.get(name) != Some(&Provenance::UserOverride) {
return Vec::new();
}
let (Some(user), Some(builtin)) = (self.agents.get(name), builtin_agents().get(name))
else {
return Vec::new();
};
[
(
"sessions",
builtin.sessions.is_some(),
user.sessions.is_some(),
),
("attach", builtin.attach.is_some(), user.attach.is_some()),
("resume", builtin.resume.is_some(), user.resume.is_some()),
(
"continue",
builtin.continue_.is_some(),
user.continue_.is_some(),
),
("plan", builtin.plan.is_some(), user.plan.is_some()),
]
.into_iter()
.filter_map(|(verb, in_builtin, in_user)| (in_builtin && !in_user).then_some(verb))
.collect()
}
pub fn entries(&self) -> impl Iterator<Item = (&str, &AgentTemplate, Provenance)> {
self.agents.iter().map(|(name, agent)| {
let prov = self
.provenance
.get(name)
.copied()
.unwrap_or(Provenance::User);
(name.as_str(), agent, prov)
})
}
pub fn write_starter(path: &Path) -> Result<()> {
if path.exists() {
return Err(Error::Invalid(format!(
"{} already exists; edit it directly rather than reinitialising",
path.display()
)));
}
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: e.to_string(),
})?;
}
std::fs::write(path, starter_config()).map_err(|e| Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: e.to_string(),
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentSessionEntry {
pub session_ref: String,
pub short_id: Option<String>,
pub cwd: Option<String>,
pub started_at_ms: Option<i64>,
pub state: Option<String>,
}
impl AgentSessionEntry {
pub fn matches_ref(&self, session_ref: &str) -> bool {
self.session_ref == session_ref || self.short_id.as_deref() == Some(session_ref)
}
pub fn is_finished(&self) -> bool {
self.state.as_deref() == Some("done")
}
}
pub fn parse_sessions_json(json: &str) -> Result<Vec<AgentSessionEntry>> {
let value: serde_json::Value = serde_json::from_str(json)
.map_err(|e| Error::Invalid(format!("sessions output is not JSON: {e}")))?;
let array = value
.as_array()
.ok_or_else(|| Error::Invalid("sessions output is not a JSON array".into()))?;
let mut entries = Vec::new();
for item in array {
let get_str = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
let Some(session_ref) = get_str("sessionId").or_else(|| get_str("id")) else {
continue;
};
entries.push(AgentSessionEntry {
session_ref,
short_id: get_str("id"),
cwd: get_str("cwd"),
started_at_ms: item.get("startedAt").and_then(|v| v.as_i64()),
state: get_str("state"),
});
}
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
const CONFIG: &str = r#"
default_agent = "claude"
[agents.claude]
cmd = "claude -p --output-format stream-json {prompt_file}"
[agents.codex]
cmd = "codex exec {prompt_file}"
"#;
fn config() -> AgentsConfig {
AgentsConfig::parse(CONFIG, Path::new("/tmp/voro.toml")).unwrap()
}
#[test]
fn agent_names_lists_every_configured_agent() {
assert_eq!(config().agent_names(), vec!["claude", "codex"]);
}
#[test]
fn resolves_default_when_task_has_no_override() {
let resolved = config().resolve(None).unwrap();
assert_eq!(resolved.name, "claude");
assert_eq!(
resolved.dispatch,
"claude -p --output-format stream-json {prompt_file}"
);
}
#[test]
fn task_override_wins_over_default() {
let resolved = config().resolve(Some("codex")).unwrap();
assert_eq!(resolved.name, "codex");
assert_eq!(resolved.dispatch, "codex exec {prompt_file}");
}
#[test]
fn unknown_override_errors_at_resolution() {
let err = config().resolve(Some("gemini")).unwrap_err();
let message = err.to_string();
assert!(message.contains("gemini"), "{message}");
assert!(message.contains("task agent override"), "{message}");
assert!(message.contains("claude, codex"), "{message}");
}
#[test]
fn unknown_default_errors_at_resolution() {
let text = r#"
default_agent = "gemini"
[agents.claude]
cmd = "claude -p {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let message = config.resolve(None).unwrap_err().to_string();
assert!(message.contains("gemini"), "{message}");
assert!(message.contains("config default"), "{message}");
}
#[test]
fn cmd_without_prompt_file_placeholder_is_rejected() {
let text = r#"
default_agent = "claude"
[agents.claude]
cmd = "claude -p"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("{prompt_file}"), "{message}");
assert!(message.contains("claude"), "{message}");
}
#[test]
fn invalid_toml_names_the_file() {
let message = AgentsConfig::parse("default = ", Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("/tmp/voro.toml"), "{message}");
}
#[test]
fn loads_from_disk() {
let path = std::env::temp_dir().join(format!("voro-agents-{}.toml", std::process::id()));
std::fs::write(&path, CONFIG).unwrap();
let config = AgentsConfig::load(&path).unwrap();
std::fs::remove_file(&path).unwrap();
assert_eq!(config.resolve(None).unwrap().name, "claude");
}
#[test]
fn missing_file_loads_the_builtins() {
let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
assert_eq!(config.agent_names(), vec!["claude", "codex"]);
assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
let claude = config.agent("claude").unwrap();
assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
assert!(claude.sessions().is_some());
assert!(claude.attach().is_some());
assert!(claude.resume().is_some());
}
#[test]
fn builtins_layer_under_a_user_file() {
let text = r#"
[agents.mycustom]
dispatch = "mytool {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.agent_names(), vec!["claude", "codex", "mycustom"]);
assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
assert_eq!(config.provenance("mycustom"), Some(Provenance::User));
assert!(config.agent("claude").unwrap().sessions().is_some());
}
#[test]
fn a_user_table_overrides_a_builtin_wholesale() {
let text = r#"
[agents.claude]
cmd = "claude -p {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
let claude = config.agent("claude").unwrap();
assert_eq!(claude.dispatch(), "claude -p {prompt_file}");
assert_eq!(claude.sessions(), None, "override is not merged per-verb");
assert_eq!(claude.attach(), None);
assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
}
#[test]
fn override_dropping_verbs_is_reported() {
let text = r#"
[agents.claude]
cmd = "claude -p {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let missing = config.override_missing_verbs("claude");
assert!(missing.contains(&"sessions"), "{missing:?}");
assert!(missing.contains(&"attach"), "{missing:?}");
assert!(missing.contains(&"resume"), "{missing:?}");
assert!(config.override_missing_verbs("codex").is_empty());
}
#[test]
fn default_probes_path_when_the_user_sets_none() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let only_codex = |name: &str| name == "codex";
assert_eq!(
config.resolve_with(None, &only_codex).unwrap().name,
"codex"
);
let both = |_: &str| true;
assert_eq!(config.resolve_with(None, &both).unwrap().name, "claude");
}
#[test]
fn no_default_and_nothing_on_path_errors_with_guidance() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let none = |_: &str| false;
let message = config.resolve_with(None, &none).unwrap_err().to_string();
assert!(message.contains("no default agent"), "{message}");
assert!(message.contains("claude, codex"), "{message}");
}
#[test]
fn user_default_is_honoured_over_the_path_probe() {
let text = r#"
default_agent = "codex"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let both = |_: &str| true;
assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
}
const VERBS_CONFIG: &str = r#"
default_agent = "claude"
[agents.claude]
dispatch = "claude --bg \"$(cat {prompt_file})\""
sessions = "claude agents --json"
attach = "claude attach {session}"
resume = "claude --resume {session}"
[agents.codex]
dispatch = "codex exec {prompt_file}"
resume = "codex resume {session}"
continue = "codex exec resume {session} \"$(cat {prompt_file})\""
"#;
#[test]
fn verbs_parse_and_resolve() {
let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
let claude = config.resolve(None).unwrap();
assert_eq!(claude.sessions.as_deref(), Some("claude agents --json"));
assert_eq!(claude.attach.as_deref(), Some("claude attach {session}"));
assert_eq!(claude.resume.as_deref(), Some("claude --resume {session}"));
assert_eq!(claude.continue_cmd, None);
let codex = config.resolve(Some("codex")).unwrap();
assert_eq!(codex.sessions, None);
assert_eq!(codex.attach, None);
assert_eq!(
codex.continue_cmd.as_deref(),
Some("codex exec resume {session} \"$(cat {prompt_file})\"")
);
}
#[test]
fn cmd_alias_behaves_as_dispatch_with_every_verb_absent() {
let resolved = config().resolve(None).unwrap();
assert_eq!(
resolved.dispatch,
"claude -p --output-format stream-json {prompt_file}"
);
assert_eq!(resolved.sessions, None);
assert_eq!(resolved.attach, None);
assert_eq!(resolved.resume, None);
assert_eq!(resolved.continue_cmd, None);
}
#[test]
fn both_dispatch_and_cmd_is_rejected() {
let text = r#"
default_agent = "claude"
[agents.claude]
cmd = "claude -p {prompt_file}"
dispatch = "claude --bg {prompt_file}"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("alias"), "{message}");
}
#[test]
fn agent_without_dispatch_or_cmd_is_rejected() {
let text = r#"
default_agent = "claude"
[agents.claude]
sessions = "claude agents --json"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("dispatch"), "{message}");
}
#[test]
fn attach_resume_continue_require_the_session_placeholder() {
for verb in ["attach", "resume", "continue"] {
let text = format!(
"default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
{verb} = \"reopen {{prompt_file}}\"\n"
);
let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("{session}"), "{verb}: {message}");
assert!(message.contains(verb), "{verb}: {message}");
}
}
#[test]
fn plan_parses_resolves_and_is_optional() {
let text = r#"
default_agent = "a"
[agents.a]
dispatch = "run {prompt_file}"
plan = "run --interactive {prompt_file}"
[agents.b]
dispatch = "other {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let a = config.resolve(None).unwrap();
assert_eq!(a.plan.as_deref(), Some("run --interactive {prompt_file}"));
assert_eq!(config.agent("a").unwrap().plan(), a.plan.as_deref());
let b = config.resolve(Some("b")).unwrap();
assert_eq!(b.plan, None);
assert_eq!(config.agent("b").unwrap().plan(), None);
}
#[test]
fn plan_requires_the_prompt_file_placeholder() {
let text = r#"
default_agent = "a"
[agents.a]
dispatch = "run {prompt_file}"
plan = "run --interactive"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("{prompt_file}"), "{message}");
assert!(message.contains("plan"), "{message}");
}
#[test]
fn builtin_claude_defines_plan_and_an_override_dropping_it_is_reported() {
let agents = builtin_agents();
let plan = agents["claude"].plan().unwrap();
assert!(plan.contains(PROMPT_FILE_PLACEHOLDER), "{plan}");
assert!(
!plan.contains("--bg"),
"plan runs in the foreground: {plan}"
);
assert!(agents["codex"].plan().is_none());
let text = r#"
[agents.claude]
cmd = "claude -p {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert!(
config.override_missing_verbs("claude").contains(&"plan"),
"{:?}",
config.override_missing_verbs("claude")
);
}
#[test]
fn continue_requires_the_prompt_file_placeholder() {
let text = r#"
default_agent = "a"
[agents.a]
dispatch = "run {prompt_file}"
continue = "reopen {session}"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("{prompt_file}"), "{message}");
assert!(message.contains("continue"), "{message}");
}
#[test]
fn agent_looks_up_templates_by_name() {
let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
let claude = config.agent("claude").unwrap();
assert_eq!(claude.attach(), Some("claude attach {session}"));
assert_eq!(claude.sessions(), Some("claude agents --json"));
assert!(config.agent("gemini").is_none());
}
#[test]
fn parse_sessions_json_reads_the_listing_shape() {
let json = r#"[
{"pid": 4321, "id": "deadbeef", "cwd": "/tmp/proj", "kind": "background",
"startedAt": 1767950000000, "sessionId": "3f6c0e6e-1111-2222-3333-444455556666",
"name": "t", "status": "idle", "state": "done"},
{"id": "cafebabe", "cwd": "/tmp/other", "startedAt": 1767950001000},
{"pid": 1}
]"#;
let entries = parse_sessions_json(json).unwrap();
assert_eq!(entries.len(), 2, "the id-less entry is skipped");
assert_eq!(
entries[0].session_ref,
"3f6c0e6e-1111-2222-3333-444455556666"
);
assert_eq!(entries[0].short_id.as_deref(), Some("deadbeef"));
assert_eq!(entries[0].cwd.as_deref(), Some("/tmp/proj"));
assert_eq!(entries[0].started_at_ms, Some(1767950000000));
assert!(entries[0].is_finished());
assert!(entries[0].matches_ref("deadbeef"), "short id matches too");
assert!(entries[0].matches_ref("3f6c0e6e-1111-2222-3333-444455556666"));
assert_eq!(entries[1].session_ref, "cafebabe", "id is the fallback");
assert!(!entries[1].is_finished(), "no state means not finished");
}
#[test]
fn parse_sessions_json_rejects_non_arrays() {
assert!(parse_sessions_json("{}").is_err());
assert!(parse_sessions_json("not json").is_err());
assert_eq!(parse_sessions_json("[]").unwrap(), vec![]);
}
#[test]
fn viewer_resolution_errors_with_guidance_when_nothing_is_configured() {
let message = config().viewer_cmd(None).unwrap_err().to_string();
assert!(message.contains("no viewer configured"), "{message}");
assert!(message.contains("[viewers.<name>]"), "{message}");
assert!(message.contains("/tmp/voro.toml"), "{message}");
assert!(config().viewer_names().is_empty());
assert_eq!(config().default_viewer_name(), None);
}
#[test]
fn the_anonymous_viewer_table_is_the_default_viewer() {
let text = r#"
default_agent = "claude"
[agents.claude]
cmd = "claude -p {prompt_file}"
[viewer]
cmd = "zed {path}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
}
#[test]
fn named_viewers_resolve_by_name_and_default_viewer_picks_among_them() {
let text = r#"
default_viewer = "zed"
[viewers.zed]
cmd = "zed {path}"
[viewers.difftool]
cmd = "git difftool -d"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.viewer_names(), vec!["difftool", "zed"]);
assert_eq!(
config.viewer_cmd(Some("difftool")).unwrap(),
"git difftool -d"
);
assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
}
#[test]
fn a_sole_named_viewer_is_the_default_without_being_named() {
let text = r#"
[viewers.zed]
cmd = "zed {path}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
}
#[test]
fn several_named_viewers_without_a_default_error_with_guidance() {
let text = r#"
[viewers.zed]
cmd = "zed {path}"
[viewers.difftool]
cmd = "git difftool -d"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let message = config.viewer_cmd(None).unwrap_err().to_string();
assert!(message.contains("default_viewer"), "{message}");
}
#[test]
fn an_unknown_viewer_name_errors_listing_the_known_ones() {
let text = r#"
[viewers.zed]
cmd = "zed {path}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let message = config.viewer_cmd(Some("emacs")).unwrap_err().to_string();
assert!(message.contains("emacs"), "{message}");
assert!(message.contains("zed"), "{message}");
let text = r#"default_viewer = "gone""#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let message = config.viewer_cmd(None).unwrap_err().to_string();
assert!(message.contains("gone"), "{message}");
}
#[test]
fn starter_config_defines_nothing_and_leaves_the_builtins() {
let config = AgentsConfig::parse(&starter_config(), Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.agent_names(), vec!["claude", "codex"]);
assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
assert!(config.viewer_names().is_empty());
assert!(config.viewer_cmd(None).is_err());
let claude = config.agent("claude").unwrap();
assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
assert!(
claude.dispatch().contains("voro-{task_id}"),
"{}",
claude.dispatch()
);
assert!(claude.sessions().is_some());
assert!(claude.attach().is_some());
assert!(claude.resume().is_some());
}
#[test]
fn starter_config_reproduces_the_builtins_commented_for_copying() {
let skeleton = starter_config();
for line in BUILTIN_AGENTS.lines().filter(|l| !l.is_empty()) {
let commented = format!("# {line}");
assert!(
skeleton.contains(&commented),
"skeleton is missing built-in line: {commented}"
);
}
let uncommented: String = BUILTIN_AGENTS
.lines()
.take_while(|l| !l.starts_with("[agents.codex]"))
.collect::<Vec<_>>()
.join("\n");
let config = AgentsConfig::parse(&uncommented, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
assert!(config.override_missing_verbs("claude").is_empty());
}
#[test]
fn entries_carry_name_template_and_provenance() {
let config = config();
let entries: Vec<_> = config.entries().collect();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].0, "claude");
assert_eq!(
entries[0].1.dispatch(),
"claude -p --output-format stream-json {prompt_file}"
);
assert_eq!(entries[0].2, Provenance::UserOverride);
assert_eq!(entries[1].0, "codex");
assert_eq!(entries[1].1.dispatch(), "codex exec {prompt_file}");
assert_eq!(entries[1].2, Provenance::UserOverride);
}
#[test]
fn write_starter_creates_parent_and_refuses_to_clobber() {
let dir = std::env::temp_dir().join(format!("voro-init-{}", std::process::id()));
let path = dir.join("voro/voro.toml");
let _ = std::fs::remove_dir_all(&dir);
AgentsConfig::write_starter(&path).unwrap();
let config = AgentsConfig::load(&path).unwrap();
assert_eq!(config.agent_names(), vec!["claude", "codex"]);
let err = AgentsConfig::write_starter(&path).unwrap_err().to_string();
assert!(err.contains("already exists"), "{err}");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn builtins_parse_and_validate() {
let agents = builtin_agents();
assert!(agents.contains_key("claude"));
assert!(agents.contains_key("codex"));
assert!(agents["claude"].sessions().is_some());
assert!(agents["codex"].continue_cmd().is_some());
}
#[test]
fn builtin_claude_carries_per_purpose_model_defaults() {
let claude = &builtin_agents()["claude"];
assert!(
claude.dispatch().contains("--model opus"),
"{}",
claude.dispatch()
);
let plan = claude.plan().unwrap();
assert!(plan.contains("--model fable"), "{plan}");
}
#[test]
fn default_agent_key_sets_the_default() {
let text = r#"
default_agent = "codex"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let both = |_: &str| true;
assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
}
}