use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
use serde::Deserialize;
use crate::error::{Error, Result};
use crate::model::LivenessSource;
use crate::scheduler::{AttentionCosts, DEFAULT_MAX_RUNNING};
use crate::template::{render, shell_quote};
pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
pub const SESSION_NAME_PLACEHOLDER: &str = "{session_name}";
pub const SESSION_PLACEHOLDER: &str = "{session}";
pub const NEW_SESSION_PLACEHOLDER: &str = "{new_session}";
pub const MODEL_PLACEHOLDER: &str = "{model}";
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 \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
sessions = \"claude agents --json\"
attach = \"claude attach {session}\"
resume = \"claude --resume {session}\"
message = \"claude -p --resume {session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\"
logs = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\"
cap = '''timeout 120 claude -p --output-format stream-json --verbose --model {model} hi 2>/dev/null | grep -o '\"status\":\"rejected\"[^}]*\"resetsAt\":[0-9]*' | grep -o '[0-9][0-9]*$' | tail -1'''
stop = \"claude stop \\\"$(printf %.8s {session})\\\"\"
plan = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
model = \"opus\"
model_deep = \"fable\"
model_plan = \"fable\"
[agents.codex]
dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\"
resume = \"codex resume {session}\"
";
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 BUILTIN_VIEWERS: &str = "\
[viewers.code]
cmd = \"code -n {path}\"
[viewers.cursor]
cmd = \"cursor -n {path}\"
[viewers.zed]
cmd = \"zed {path}\"
";
pub const BUILTIN_VIEWER_NAMES: [&str; 3] = ["code", "cursor", "zed"];
fn builtin_viewers() -> &'static BTreeMap<String, ViewerTemplate> {
static BUILTINS: LazyLock<BTreeMap<String, ViewerTemplate>> = LazyLock::new(|| {
let raw: RawConfig = toml::from_str(BUILTIN_VIEWERS).expect("built-in viewers TOML parses");
for (name, viewer) in &raw.viewers {
assert!(
!viewer.cmd.trim().is_empty(),
"built-in viewer '{name}' has a command"
);
}
for name in BUILTIN_VIEWER_NAMES {
assert!(
raw.viewers.contains_key(name),
"probe order names a built-in viewer, not '{name}'"
);
}
raw.viewers
});
&BUILTINS
}
pub fn is_builtin_viewer(name: &str) -> bool {
builtin_viewers().contains_key(name)
}
pub fn builtin_viewer_cmd(name: &str) -> Option<&'static str> {
builtin_viewers().get(name).map(|v| v.cmd.as_str())
}
const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml).
#
# This file is OPTIONAL. Voro ships with built-in `claude` and `codex` agents
# and built-in `code`, `cursor` and `zed` viewers, so a fresh install with any
# of those on PATH dispatches and opens a diff with no config here. Run
# `voro agent list` and `voro viewer list` to see the effective sets and where
# each entry 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, the optional
# `{session_name}` by the name Voro composes for the session
# (`voro-<id>-<title-slug>` for a dispatch, `voro-<id>-refine` for a
# refine, `voro-plan-<project>` for planning, `voro-propose-<project>` for
# a quick propose), 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).
# Each entry needs an id (`sessionId`, or `id`); `state`
# (`done` once finished, `working` while going) and `pid` say
# whether it is still live, `pid` deciding it where present.
# An entry carrying neither reads as dead.
# attach open a running session interactively ({session})
# resume reopen a finished session interactively ({session})
# message say one thing into a session headlessly, no terminal
# ({session} and {prompt_file}, plus the optional
# {new_session}: a fresh reference for an agent that can only
# be joined by forking, which the session row then follows)
# logs print a session's recent output ({session})
# Read for one thing: whether the session is sitting on a
# usage cap, which is badged on the running strip and used to
# tell a capped death from an ordinary one. Tail it in the
# template — Voro reads whatever it prints.
# cap print when the account's usage window reopens, as a Unix
# epoch, while the account is capped — and nothing when it is
# not. Read instead of the clock time on a session's screen,
# which carries no date. It may name {model} and nothing else:
# a subscription meters each strong model separately, so the
# window that refuses depends on which one asks. Costs whatever
# asking the agent costs, so Voro asks only while a session is
# badged capped.
# stop retire a session from the agent's own registry ({session})
# Fired when Voro closes the session's row, so the agent's
# listing shows work actually in flight. Fire and forget: Voro
# reads neither output nor status, and a session already gone
# is not an error.
# plan run an interactive foreground planning session ({prompt_file})
# `plan` may carry `{session_name}` too, but not `{task_id}`: a planning
# session drafts a task rather than naming one.
# `dispatch` and `plan` may also carry `{model}`, filled from this agent's
# own model keys: `model` normally, `model_deep` for a task flagged deep
# (`voro set <id> --deep`), and `model_plan` when planning — the last two
# falling back to `model`. The values are opaque names Voro pastes in and
# never interprets, so an agent with no `{model}` takes no model direction
# and a deep task dispatches with it unchanged.
# 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 by `voro open` (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. Viewers are
# built in like the agents — a table named `code`, `cursor` or `zed`
# replaces that built-in wholesale, any other name adds a viewer.
# `default_viewer` names the one used when a project does not pick a viewer
# itself (`voro project viewer <p> <name>`); unset, Voro uses the sole
# viewer defined here, else the first built-in found on PATH. A single
# anonymous [viewer] table is the older, still-valid spelling of the
# default. A viewer must open its own window: `voro open` spawns it
# detached with no terminal, so a pager-driven command cannot draw.
# * price the queue — `max_running` caps how many dispatches ride at once
# (default 5; at the cap the queue offers no more), and a [costs] table
# divides each row's score by what its action asks of you, so a cheap
# decision outranks an expensive review of the same raw worth. Keep the
# band narrow (DESIGN.md §7) — it is a nudge, not a re-ranking.
"#;
fn starter_config() -> String {
let mut out = String::from(STARTER_HEADER);
out.push_str(
"\n# --------------------------------------------------------------------------\n\
# Built-in agents and viewers, exactly as shipped. Uncomment a block and\n\
# edit it to override that entry wholesale; leave it commented to keep the\n\
# built-in, which updates with Voro. Copy a block to model one of your own.\n\
# --------------------------------------------------------------------------\n#\n",
);
for line in BUILTIN_AGENTS
.lines()
.chain([""])
.chain(BUILTIN_VIEWERS.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.difftool]\n\
# cmd = \"git -C {path} difftool -d {base}...{branch}\"\n#\n\
# max_running = 5\n#\n\
# [costs]\n\
# answer = 0.8\n\
# triage = 0.8\n\
# dispatch = 1.0\n\
# review = 1.4\n\
# do = 1.8\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>,
message: Option<String>,
logs: Option<String>,
cap: Option<String>,
stop: Option<String>,
plan: Option<String>,
model: Option<String>,
model_deep: Option<String>,
model_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 message(&self) -> Option<&str> {
self.message.as_deref()
}
pub fn logs(&self) -> Option<&str> {
self.logs.as_deref()
}
pub fn cap(&self) -> Option<&str> {
self.cap.as_deref()
}
pub fn stop(&self) -> Option<&str> {
self.stop.as_deref()
}
pub fn plan(&self) -> Option<&str> {
self.plan.as_deref()
}
pub fn model(&self) -> Option<&str> {
self.model.as_deref()
}
pub fn model_deep(&self) -> Option<&str> {
self.model_deep.as_deref()
}
pub fn model_plan(&self) -> Option<&str> {
self.model_plan.as_deref()
}
pub fn model_for(&self, deep: bool) -> Option<&str> {
model_for_depth(self.model(), self.model_deep(), deep)
}
pub fn verbs(&self) -> Vec<&'static str> {
OPTIONAL_VERBS
.iter()
.filter_map(|(verb, defined)| {
let template = defined(self)?;
Some(
if *verb == "message" && template.contains(NEW_SESSION_PLACEHOLDER) {
"message(fork)"
} else {
*verb
},
)
})
.collect()
}
}
type VerbAccessor = (&'static str, fn(&AgentTemplate) -> Option<&str>);
const OPTIONAL_VERBS: [VerbAccessor; 8] = [
("sessions", AgentTemplate::sessions),
("attach", AgentTemplate::attach),
("resume", AgentTemplate::resume),
("message", AgentTemplate::message),
("logs", AgentTemplate::logs),
("cap", AgentTemplate::cap),
("stop", AgentTemplate::stop),
("plan", AgentTemplate::plan),
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Launch {
Dispatch { task_id: i64, title: String },
Refine { task_id: i64 },
Plan { project: String },
Propose { project: String },
}
impl Launch {
pub fn session_name(&self) -> String {
match self {
Launch::Dispatch { task_id, title } => match title_slug(title) {
Some(slug) => format!("voro-{task_id}-{slug}"),
None => format!("voro-{task_id}"),
},
Launch::Refine { task_id } => format!("voro-{task_id}-refine"),
Launch::Plan { project } => format!("voro-plan-{}", sanitize_for_name(project)),
Launch::Propose { project } => format!("voro-propose-{}", sanitize_for_name(project)),
}
}
pub fn slug(&self) -> String {
match self {
Launch::Dispatch { task_id, .. } => format!("task-{task_id}"),
Launch::Refine { task_id } => format!("refine-{task_id}"),
Launch::Plan { project } => format!("plan-{}", sanitize_for_name(project)),
Launch::Propose { project } => format!("propose-{}", sanitize_for_name(project)),
}
}
pub fn task_id(&self) -> Option<i64> {
match self {
Launch::Dispatch { task_id, .. } | Launch::Refine { task_id } => Some(*task_id),
Launch::Plan { .. } | Launch::Propose { .. } => None,
}
}
}
fn sanitize_for_name(name: &str) -> String {
name.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
c
} else {
'-'
}
})
.collect()
}
const RESERVED_NAME_SUFFIXES: &[&str] = &["refine"];
const TITLE_SLUG_BUDGET: usize = 24;
fn title_slug(title: &str) -> Option<String> {
let words: Vec<String> = title
.split_whitespace()
.map(|word| tidy_dashes(&sanitize_for_name(&word.to_lowercase())))
.filter(|word| !word.is_empty())
.collect();
let mut slug = String::new();
let mut taken = 0;
for word in &words {
if !slug.is_empty() && slug.len() + 1 + word.len() > TITLE_SLUG_BUDGET {
break;
}
if !slug.is_empty() {
slug.push('-');
}
slug.push_str(word);
taken += 1;
}
if RESERVED_NAME_SUFFIXES.contains(&slug.as_str()) {
let next = words.get(taken)?;
slug.push('-');
slug.push_str(next);
}
(!slug.is_empty()).then_some(slug)
}
fn tidy_dashes(word: &str) -> String {
let mut out = String::with_capacity(word.len());
for c in word.chars() {
if c == '-' && (out.is_empty() || out.ends_with('-')) {
continue;
}
out.push(c);
}
out.trim_end_matches('-').to_string()
}
#[derive(Debug, Clone)]
pub struct LaunchSpec<'a> {
pub launch: Launch,
pub prompt_file: &'a Path,
pub deep: bool,
}
fn render_launch(template: &str, spec: &LaunchSpec, model: Option<&str>) -> String {
let prompt_file = shell_quote(spec.prompt_file);
let session_name = spec.launch.session_name();
let task_id = spec.launch.task_id().map(|id| id.to_string());
let mut bindings = vec![
(PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
(SESSION_NAME_PLACEHOLDER, session_name.as_str()),
];
if let Some(task_id) = &task_id {
bindings.push((TASK_ID_PLACEHOLDER, task_id.as_str()));
}
if let Some(model) = model {
bindings.push((MODEL_PLACEHOLDER, model));
}
render(template, &bindings)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedMessage {
pub command: String,
pub new_session_ref: Option<String>,
}
pub fn model_for_depth<'a>(
model: Option<&'a str>,
model_deep: Option<&'a str>,
deep: bool,
) -> Option<&'a str> {
if deep { model_deep.or(model) } else { model }
}
pub fn render_cap(template: &str, model: Option<&str>) -> String {
match model {
Some(model) => render(template, &[(MODEL_PLACEHOLDER, model)]),
None => template.to_string(),
}
}
pub fn render_session(template: &str, session_ref: &str) -> String {
let session = shell_quote(Path::new(session_ref));
render(template, &[(SESSION_PLACEHOLDER, session.as_str())])
}
pub fn render_message(template: &str, session_ref: &str, prompt_file: &Path) -> RenderedMessage {
let session = shell_quote(Path::new(session_ref));
let prompt_file = shell_quote(prompt_file);
let new_session_ref = template
.contains(NEW_SESSION_PLACEHOLDER)
.then(|| uuid::Uuid::new_v4().to_string());
let new_session = new_session_ref
.as_deref()
.map(|r| shell_quote(Path::new(r)));
let mut bindings = vec![
(SESSION_PLACEHOLDER, session.as_str()),
(PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
];
if let Some(new_session) = &new_session {
bindings.push((NEW_SESSION_PLACEHOLDER, new_session.as_str()));
}
RenderedMessage {
command: render(template, &bindings),
new_session_ref,
}
}
#[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>,
#[serde(default)]
max_running: Option<i64>,
#[serde(default)]
costs: Option<RawCosts>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawCosts {
answer: Option<f64>,
triage: Option<f64>,
dispatch: Option<f64>,
review: Option<f64>,
#[serde(rename = "do")]
human_do: Option<f64>,
}
impl RawCosts {
fn resolve(self, path: &Path) -> Result<AttentionCosts> {
let defaults = AttentionCosts::default();
let checked = |name: &str, value: Option<f64>, default: f64| -> Result<f64> {
match value {
None => Ok(default),
Some(value) if value.is_finite() && value > 0.0 => Ok(value),
Some(value) => Err(Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: format!(
"cost '{name}' is {value} — every [costs] divisor must be a positive \
number (the defaults sit between 0.8 and 1.8)"
),
}),
}
};
Ok(AttentionCosts {
answer: checked("answer", self.answer, defaults.answer)?,
triage: checked("triage", self.triage, defaults.triage)?,
dispatch: checked("dispatch", self.dispatch, defaults.dispatch)?,
review: checked("review", self.review, defaults.review)?,
human_do: checked("do", self.human_do, defaults.human_do)?,
})
}
}
pub(crate) fn negative_max_running(n: i64) -> String {
format!(
"max_running is {n} — it counts dispatches in flight, so it cannot be negative (0 stops \
the queue offering dispatches at all)"
)
}
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),
("message", &agent.message),
("logs", &agent.logs),
("stop", &agent.stop),
] {
if let Some(template) = template
&& !template.contains(SESSION_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' {verb} is missing the {SESSION_PLACEHOLDER} placeholder"
)));
}
}
for (verb, template) in [("plan", &agent.plan), ("message", &agent.message)] {
if let Some(template) = template
&& !template.contains(PROMPT_FILE_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' {verb} is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
)));
}
}
for (verb, template) in [
("sessions", &agent.sessions),
("attach", &agent.attach),
("resume", &agent.resume),
("message", &agent.message),
("logs", &agent.logs),
("stop", &agent.stop),
] {
let Some(template) = template else { continue };
if template.contains(MODEL_PLACEHOLDER) {
return Err(invalid(format!(
"agent '{name}' {verb} carries {MODEL_PLACEHOLDER}, which is resolved only on \
dispatch and plan — a session verb reuses the model its session started with"
)));
}
for placeholder in [SESSION_NAME_PLACEHOLDER, TASK_ID_PLACEHOLDER] {
if template.contains(placeholder) {
return Err(invalid(format!(
"agent '{name}' {verb} carries {placeholder}, which is resolved only on \
dispatch and plan — a session verb names its session with {SESSION_PLACEHOLDER}, \
the reference Voro captured at launch"
)));
}
}
}
if let Some(template) = &agent.cap {
for placeholder in [
SESSION_PLACEHOLDER,
PROMPT_FILE_PLACEHOLDER,
SESSION_NAME_PLACEHOLDER,
TASK_ID_PLACEHOLDER,
NEW_SESSION_PLACEHOLDER,
] {
if template.contains(placeholder) {
return Err(invalid(format!(
"agent '{name}' cap carries {placeholder}, but a cap reading is about the \
account rather than any one session or launch — {MODEL_PLACEHOLDER} is the \
only placeholder it may name"
)));
}
}
}
for (verb, template) in [
("dispatch", Some(dispatch.as_str())),
("sessions", agent.sessions.as_deref()),
("attach", agent.attach.as_deref()),
("resume", agent.resume.as_deref()),
("logs", agent.logs.as_deref()),
("stop", agent.stop.as_deref()),
("plan", agent.plan.as_deref()),
] {
if template.is_some_and(|t| t.contains(NEW_SESSION_PLACEHOLDER)) {
return Err(invalid(format!(
"agent '{name}' {verb} carries {NEW_SESSION_PLACEHOLDER}, which is bound only on \
message — it names the session a headless send forks into"
)));
}
}
if let Some(template) = &agent.plan
&& template.contains(TASK_ID_PLACEHOLDER)
{
return Err(invalid(format!(
"agent '{name}' plan carries {TASK_ID_PLACEHOLDER}, but a planning session drafts a \
task rather than naming one — use {SESSION_NAME_PLACEHOLDER}, which Voro composes \
for every launch"
)));
}
if agent.model.is_none()
&& [
dispatch.as_str(),
agent.plan.as_deref().unwrap_or_default(),
agent.cap.as_deref().unwrap_or_default(),
]
.iter()
.any(|t| t.contains(MODEL_PLACEHOLDER))
{
return Err(invalid(format!(
"agent '{name}' uses {MODEL_PLACEHOLDER} but sets no model — add model = \
\"<model name>\" to its [agents.{name}] table (optionally model_deep for deep \
tasks and model_plan for planning), or drop the 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())
}
fn probed_builtin_viewer(probe: &dyn Fn(&str) -> bool) -> Option<&'static str> {
BUILTIN_VIEWER_NAMES.into_iter().find(|name| probe(name))
}
#[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 message: Option<String>,
pub logs: Option<String>,
pub cap: Option<String>,
pub stop: Option<String>,
pub plan: Option<String>,
pub model: Option<String>,
pub model_deep: Option<String>,
pub model_plan: Option<String>,
}
impl ResolvedAgent {
pub fn launch_command(&self, spec: &LaunchSpec) -> String {
let model = model_for_depth(self.model.as_deref(), self.model_deep.as_deref(), spec.deep);
render_launch(&self.dispatch, spec, model)
}
pub fn dispatch_liveness_source(&self) -> LivenessSource {
match self.sessions {
Some(_) => LivenessSource::Listing,
None => LivenessSource::Pid,
}
}
pub fn plan_launch_command(&self, spec: &LaunchSpec) -> Option<String> {
let model = self.model_plan.as_deref().or(self.model.as_deref());
self.plan
.as_deref()
.map(|template| render_launch(template, spec, model))
}
}
#[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>,
costs: AttentionCosts,
max_running: Option<i64>,
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,
costs: AttentionCosts::default(),
max_running: 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);
}
if let Some(n) = raw.max_running
&& n < 0
{
return Err(Error::AgentConfigInvalid {
path: path.to_path_buf(),
message: negative_max_running(n),
});
}
let costs = match raw.costs {
Some(costs) => costs.resolve(path)?,
None => AttentionCosts::default(),
};
Ok(AgentsConfig {
default: raw.default_agent,
agents,
provenance,
viewer: raw.viewer,
viewers: raw.viewers,
default_viewer: raw.default_viewer,
costs,
max_running: raw.max_running,
path: path.to_path_buf(),
})
}
pub fn costs(&self) -> AttentionCosts {
self.costs
}
pub fn max_running(&self) -> i64 {
self.max_running.unwrap_or(DEFAULT_MAX_RUNNING)
}
pub fn max_running_from_file(&self) -> Option<i64> {
self.max_running
}
pub fn default_agent_from_file(&self) -> Option<&str> {
self.default.as_deref()
}
pub fn default_viewer_from_file(&self) -> Option<&str> {
self.default_viewer.as_deref()
}
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(),
message: agent.message.clone(),
logs: agent.logs.clone(),
cap: agent.cap.clone(),
stop: agent.stop.clone(),
plan: agent.plan.clone(),
model: agent.model.clone(),
model_deep: agent.model_deep.clone(),
model_plan: agent.model_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 viewer_entries(&self) -> Vec<(&str, &str, Provenance)> {
let mut merged: BTreeMap<&str, (&str, Provenance)> = builtin_viewers()
.iter()
.map(|(name, viewer)| (name.as_str(), (viewer.cmd.as_str(), Provenance::BuiltIn)))
.collect();
for (name, viewer) in &self.viewers {
let prov = if is_builtin_viewer(name) {
Provenance::UserOverride
} else {
Provenance::User
};
merged.insert(name.as_str(), (viewer.cmd.as_str(), prov));
}
merged
.into_iter()
.map(|(name, (cmd, prov))| (name, cmd, prov))
.collect()
}
pub fn anonymous_viewer_cmd(&self) -> Option<&str> {
self.viewer.as_ref().map(|v| v.cmd.as_str())
}
pub fn default_viewer_name(&self) -> Option<String> {
self.default_viewer_name_with(&binary_on_path)
}
fn default_viewer_name_with(&self, probe: &dyn Fn(&str) -> bool) -> Option<String> {
if self.default_viewer.is_some() {
return self.default_viewer.clone();
}
if self.viewer.is_some() {
return None;
}
if self.viewers.len() == 1 {
return self.viewers.keys().next().cloned();
}
probed_builtin_viewer(probe).map(str::to_string)
}
pub fn viewer_cmd(&self, name: Option<&str>) -> Result<&str> {
self.viewer_cmd_with(name, &binary_on_path)
}
fn viewer_cmd_with(&self, name: Option<&str>, probe: &dyn Fn(&str) -> bool) -> Result<&str> {
match name {
Some(name) => self.named_viewer(name),
None => match &self.default_viewer {
Some(default) => self.named_viewer(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,
})
.or_else(|| {
probed_builtin_viewer(probe).and_then(|name| self.named_viewer(name).ok())
})
.ok_or_else(|| Error::NoViewer {
probed: BUILTIN_VIEWER_NAMES.join("/"),
}),
},
}
}
fn named_viewer(&self, name: &str) -> Result<&str> {
self.viewers
.get(name)
.or_else(|| builtin_viewers().get(name))
.map(|v| v.cmd.as_str())
.ok_or_else(|| Error::UnknownViewer {
name: name.to_string(),
known: self
.viewer_entries()
.iter()
.map(|(name, _, _)| *name)
.collect::<Vec<_>>()
.join(", "),
path: self.path.clone(),
})
}
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();
};
OPTIONAL_VERBS
.iter()
.filter(|(_, defined)| defined(builtin).is_some() && defined(user).is_none())
.map(|(verb, _)| *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>,
pub pid: Option<i64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SessionLiveness {
Live,
WhileProcessLives(i64),
Dead,
}
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 liveness(&self) -> SessionLiveness {
match (self.state.as_deref(), self.pid) {
(Some("done"), _) => SessionLiveness::Dead,
(_, Some(pid)) => SessionLiveness::WhileProcessLives(pid),
(Some("working"), None) => SessionLiveness::Live,
_ => SessionLiveness::Dead,
}
}
pub fn at_rest(&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"),
pid: item.get("pid").and_then(|v| v.as_i64()),
});
}
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()
}
fn parse(text: &str) -> Result<AgentsConfig> {
AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
}
fn none_installed(_: &str) -> bool {
false
}
fn only(installed: &'static str) -> impl Fn(&str) -> bool {
move |name| name == installed
}
#[test]
fn absent_costs_and_max_running_take_the_defaults() {
for config in [
config(),
AgentsConfig::builtin_only(Path::new("/tmp/voro.toml")),
] {
assert_eq!(config.costs(), AttentionCosts::default());
assert_eq!(config.max_running(), DEFAULT_MAX_RUNNING);
}
}
#[test]
fn costs_table_overrides_only_the_actions_it_names() {
let config = parse(
r#"
max_running = 3
[costs]
review = 2.5
do = 4.0
"#,
)
.unwrap();
let costs = config.costs();
assert_eq!(costs.review, 2.5);
assert_eq!(costs.human_do, 4.0);
assert_eq!(costs.answer, AttentionCosts::default().answer);
assert_eq!(costs.triage, AttentionCosts::default().triage);
assert_eq!(costs.dispatch, AttentionCosts::default().dispatch);
assert_eq!(config.max_running(), 3);
}
#[test]
fn a_non_positive_cost_is_refused() {
for text in [
"[costs]\nreview = 0",
"[costs]\nanswer = -1.0",
"[costs]\ntriage = nan",
] {
let e = parse(text).unwrap_err().to_string();
assert!(e.contains("must be a positive number"), "{text}: {e}");
}
}
#[test]
fn a_negative_max_running_is_refused() {
let e = parse("max_running = -1").unwrap_err().to_string();
assert!(e.contains("cannot be negative"), "{e}");
assert_eq!(parse("max_running = 0").unwrap().max_running(), 0);
}
#[test]
fn an_unknown_cost_key_is_refused_rather_than_ignored() {
let e = parse("[costs]\nredispatch = 1.0").unwrap_err().to_string();
assert!(e.contains("redispatch"), "{e}");
}
#[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}"
"#;
#[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}"));
let codex = config.resolve(Some("codex")).unwrap();
assert_eq!(codex.sessions, None);
assert_eq!(codex.attach, None);
assert_eq!(codex.resume.as_deref(), Some("codex resume {session}"));
}
#[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);
}
#[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_and_resume_require_the_session_placeholder() {
for verb in ["attach", "resume"] {
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 message_parses_resolves_and_is_optional() {
let text = r#"
default_agent = "a"
[agents.a]
dispatch = "run {prompt_file}"
message = "say --into {session} {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.message.as_deref(),
Some("say --into {session} {prompt_file}")
);
assert_eq!(config.agent("a").unwrap().message(), a.message.as_deref());
let b = config.resolve(Some("b")).unwrap();
assert_eq!(b.message, None);
assert_eq!(config.agent("b").unwrap().message(), None);
}
#[test]
fn message_requires_both_the_session_and_prompt_file_placeholders() {
for (template, missing) in [
("say {prompt_file}", SESSION_PLACEHOLDER),
("say --into {session}", PROMPT_FILE_PLACEHOLDER),
] {
let text = format!(
"default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
message = \"{template}\"\n"
);
let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(e.contains(missing), "{template}: {e}");
assert!(e.contains("message"), "{template}: {e}");
}
}
#[test]
fn message_refuses_the_launch_placeholders() {
for placeholder in [
MODEL_PLACEHOLDER,
SESSION_NAME_PLACEHOLDER,
TASK_ID_PLACEHOLDER,
] {
let text = format!(
"default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
model = \"m\"\n\
message = \"say --into {{session}} --as {placeholder} {{prompt_file}}\"\n"
);
let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(e.contains(placeholder), "{placeholder}: {e}");
assert!(e.contains("message"), "{placeholder}: {e}");
}
}
#[test]
fn builtin_claude_defines_message_and_an_override_dropping_it_is_reported() {
let agents = builtin_agents();
let message = agents["claude"].message().unwrap();
assert!(message.contains(SESSION_PLACEHOLDER), "{message}");
assert!(message.contains(PROMPT_FILE_PLACEHOLDER), "{message}");
assert!(
message.contains("-p"),
"message is headless, not a terminal round-trip: {message}"
);
assert!(agents["codex"].message().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(&"message"),
"{:?}",
config.override_missing_verbs("claude")
);
}
#[test]
fn verbs_lists_every_optional_verb_and_marks_a_forking_message() {
let agents = builtin_agents();
assert_eq!(
agents["claude"].verbs(),
vec![
"sessions", "attach", "resume", "message", "logs", "cap", "stop", "plan"
]
);
assert_eq!(agents["codex"].verbs(), vec!["resume"]);
let text = r#"
[agents.a]
dispatch = "run {prompt_file}"
message = "say --into {session} --as {new_session} {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.agent("a").unwrap().verbs(), vec!["message(fork)"]);
}
#[test]
fn the_listing_and_the_dropped_verb_warning_cover_the_same_verbs() {
let text = r#"
[agents.claude]
cmd = "claude -p {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let dropped = config.override_missing_verbs("claude");
let listed: Vec<&str> = builtin_agents()["claude"]
.verbs()
.into_iter()
.map(|verb| verb.split('(').next().expect("a verb name"))
.collect();
assert_eq!(dropped, listed);
assert!(config.agent("claude").unwrap().verbs().is_empty());
}
#[test]
fn render_message_binds_both_placeholders_shell_quoted() {
let rendered = render_message(
"claude -p --resume {session} \"$(cat {prompt_file})\"",
"3f6c-1111",
Path::new("/run/msg-1.md"),
);
assert_eq!(
rendered.command,
"claude -p --resume '3f6c-1111' \"$(cat '/run/msg-1.md')\""
);
assert_eq!(rendered.new_session_ref, None);
}
#[test]
fn render_message_binds_a_fresh_reference_for_a_forking_verb() {
let rendered = render_message(
"claude -p --resume {session} --fork-session --session-id {new_session} \
\"$(cat {prompt_file})\"",
"3f6c-1111",
Path::new("/run/msg-1.md"),
);
let new_ref = rendered.new_session_ref.expect("a fresh reference");
assert_ne!(new_ref, "3f6c-1111");
assert_eq!(new_ref.len(), 36, "a v4 uuid: {new_ref}");
assert!(
rendered
.command
.contains(&format!("--session-id '{new_ref}'")),
"{}",
rendered.command
);
let again = render_message(
"claude --session-id {new_session} --resume {session} {prompt_file}",
"3f6c-1111",
Path::new("/run/msg-2.md"),
);
assert_ne!(again.new_session_ref, Some(new_ref));
}
#[test]
fn every_builtin_claude_launch_that_prompts_carries_a_permission_mode() {
let claude = &builtin_agents()["claude"];
for (verb, template) in [
("dispatch", claude.dispatch()),
("message", claude.message().expect("claude defines message")),
("plan", claude.plan().expect("claude defines plan")),
] {
assert!(
template.contains(PROMPT_FILE_PLACEHOLDER),
"{verb} is meant to be a prompted launch: {template}"
);
assert!(
template.contains("--permission-mode auto"),
"{verb} asks an agent to act, so it cannot stop for an approval \
nobody is there to give: {template}"
);
}
let resume = claude.resume().expect("claude defines resume");
assert!(!resume.contains(PROMPT_FILE_PLACEHOLDER), "{resume}");
assert!(!resume.contains("--permission-mode"), "{resume}");
}
#[test]
fn new_session_is_refused_outside_the_message_verb() {
for (verb, template) in [
("attach", "join {session} {new_session}"),
("resume", "reopen {session} {new_session}"),
("logs", "tail {session} {new_session}"),
("plan", "plan --session-id {new_session} {prompt_file}"),
] {
let text = format!(
"[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = \"{template}\"\n"
);
let e = parse(&text).unwrap_err().to_string();
assert!(e.contains("{new_session}"), "{verb}: {e}");
assert!(e.contains(verb), "{verb}: {e}");
}
let e = parse("[agents.a]\ndispatch = \"run --session-id {new_session} {prompt_file}\"\n")
.unwrap_err()
.to_string();
assert!(e.contains("dispatch carries {new_session}"), "{e}");
}
#[test]
fn the_builtin_claude_message_verb_resumes_in_place() {
let message = builtin_agents()["claude"].message().unwrap();
assert!(!message.contains("--fork-session"), "{message}");
assert!(!message.contains(NEW_SESSION_PLACEHOLDER), "{message}");
assert!(message.contains("-p --resume {session}"), "{message}");
let rendered = render_message(message, "uuid-1", Path::new("/tmp/p.txt"));
assert_eq!(rendered.new_session_ref, None);
assert!(
rendered.command.contains("--resume 'uuid-1'"),
"{rendered:?}"
);
}
#[test]
fn render_session_binds_the_reference_shell_quoted() {
assert_eq!(
render_session("claude logs \"$(printf %.8s {session})\"", "3f6c-1111"),
"claude logs \"$(printf %.8s '3f6c-1111')\""
);
}
#[test]
fn only_the_claude_builtin_defines_logs() {
let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
let claude = config.agent("claude").expect("the built-in claude");
assert!(claude.logs().expect("a logs verb").contains("claude logs"));
assert!(config.agent("codex").expect("codex").logs().is_none());
}
#[test]
fn logs_is_validated_as_a_session_verb() {
for (logs, expected) in [
("agent-logs --tail", SESSION_PLACEHOLDER),
("agent-logs {session} --model {model}", MODEL_PLACEHOLDER),
("agent-logs {session} --task {task_id}", TASK_ID_PLACEHOLDER),
(
"agent-logs {session} --name {session_name}",
SESSION_NAME_PLACEHOLDER,
),
] {
let toml = format!(
"[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nlogs = \"{logs}\"\n"
);
let raw: RawConfig = toml::from_str(&toml).unwrap();
let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
.expect_err("{logs} is refused");
let message = err.to_string();
assert!(message.contains("logs"), "{message}");
assert!(message.contains(expected), "{message}");
}
}
#[test]
fn only_the_claude_builtin_defines_cap() {
let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
let cap = config
.agent("claude")
.expect("the built-in claude")
.cap()
.expect("a cap verb");
assert!(cap.contains("rejected"), "{cap}");
assert!(cap.contains("resetsAt"), "{cap}");
assert!(cap.contains(MODEL_PLACEHOLDER), "{cap}");
assert!(cap.contains("timeout"), "{cap}");
assert!(config.agent("codex").expect("codex").cap().is_none());
}
#[test]
fn cap_names_the_model_and_nothing_else() {
for placeholder in [
SESSION_PLACEHOLDER,
PROMPT_FILE_PLACEHOLDER,
SESSION_NAME_PLACEHOLDER,
TASK_ID_PLACEHOLDER,
NEW_SESSION_PLACEHOLDER,
] {
let toml = format!(
"[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\n\
cap = \"agent-cap {placeholder}\"\n"
);
let raw: RawConfig = toml::from_str(&toml).unwrap();
let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
.expect_err("a placeholder on cap is refused");
let message = err.to_string();
assert!(message.contains("cap"), "{message}");
assert!(message.contains(placeholder), "{message}");
}
for cap in ["agent-cap --model {model}", "agent-cap --epoch"] {
let toml = format!(
"[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\ncap = \"{cap}\"\n"
);
let raw: RawConfig = toml::from_str(&toml).unwrap();
validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a valid cap");
}
let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap {model}\"\n";
let raw: RawConfig = toml::from_str(toml).unwrap();
let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
.expect_err("{model} with no model key is refused");
assert!(err.to_string().contains("sets no model"), "{err}");
}
#[test]
fn a_cap_asks_on_the_model_its_session_ran() {
let text = "[agents.a]\ndispatch = \"run {prompt_file} --model {model}\"\n\
cap = \"agent-cap --model {model}\"\nmodel = \"workhorse\"\n\
model_deep = \"strongest\"\n";
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let agent = config.agent("a").expect("agent a");
assert_eq!(agent.model_for(false), Some("workhorse"));
assert_eq!(agent.model_for(true), Some("strongest"));
assert_eq!(
render_cap(agent.cap().unwrap(), agent.model_for(true)),
"agent-cap --model strongest"
);
assert_eq!(model_for_depth(Some("only"), None, true), Some("only"));
assert_eq!(render_cap("agent-cap --epoch", None), "agent-cap --epoch");
}
#[test]
fn the_claude_stop_verb_renders_the_short_id() {
let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
let stop = config
.agent("claude")
.expect("the built-in claude")
.stop()
.expect("a stop verb");
assert_eq!(
render_session(stop, "3f6c1111-2222-3333-4444-555555555555"),
"claude stop \"$(printf %.8s '3f6c1111-2222-3333-4444-555555555555')\""
);
}
#[test]
fn the_codex_builtin_defines_no_stop() {
let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
assert!(config.agent("codex").expect("codex").stop().is_none());
}
#[test]
fn stop_is_validated_as_a_session_verb() {
for (stop, expected) in [
("agent-stop --all", SESSION_PLACEHOLDER),
("agent-stop {session} --model {model}", MODEL_PLACEHOLDER),
("agent-stop {session} --task {task_id}", TASK_ID_PLACEHOLDER),
(
"agent-stop {session} --name {session_name}",
SESSION_NAME_PLACEHOLDER,
),
(
"agent-stop {session} --into {new_session}",
NEW_SESSION_PLACEHOLDER,
),
] {
let toml = format!(
"[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nstop = \"{stop}\"\n"
);
let raw: RawConfig = toml::from_str(&toml).unwrap();
let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
.expect_err("{stop} is refused");
let message = err.to_string();
assert!(message.contains("stop"), "{message}");
assert!(message.contains(expected), "{message}");
}
}
#[test]
fn an_override_dropping_stop_is_reported() {
let config = AgentsConfig::parse(
"[agents.claude]\ndispatch = \"claude {prompt_file}\"\n",
Path::new("/c.toml"),
)
.unwrap();
assert!(config.override_missing_verbs("claude").contains(&"stop"));
}
#[test]
fn render_message_does_not_rescan_a_bound_value() {
let rendered = render_message(
"say {session} {prompt_file}",
"{prompt_file}",
Path::new("/run/m.md"),
);
assert_eq!(rendered.command, "say '{prompt_file}' '/run/m.md'");
}
#[test]
fn a_continue_verb_is_now_an_unknown_field() {
let text = r#"
default_agent = "a"
[agents.a]
dispatch = "run {prompt_file}"
continue = "reopen {session} {prompt_file}"
"#;
assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
}
#[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_eq!(entries[0].pid, Some(4321));
assert_eq!(entries[0].liveness(), SessionLiveness::Dead);
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_eq!(entries[1].pid, None, "the field is optional");
assert_eq!(
entries[1].liveness(),
SessionLiveness::Dead,
"an entry saying neither state nor pid claims nothing, so it is not live"
);
}
#[test]
fn liveness_takes_done_first_then_pid_then_state() {
let entry = |json: &str| {
let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
parse_sessions_json(&listing).unwrap().remove(0).liveness()
};
assert_eq!(
entry(r#""state": "done", "pid": 4321"#),
SessionLiveness::Dead
);
assert_eq!(entry(r#""state": "done""#), SessionLiveness::Dead);
for state in ["\"state\": \"blocked\", ", "\"state\": \"working\", ", ""] {
assert_eq!(
entry(&format!("{state}\"pid\": 4321")),
SessionLiveness::WhileProcessLives(4321),
"{state}"
);
}
assert_eq!(entry(r#""state": "working""#), SessionLiveness::Live);
assert_eq!(entry(r#""state": "blocked""#), SessionLiveness::Dead);
assert_eq!(entry(r#""state": "idle""#), SessionLiveness::Dead);
}
#[test]
fn at_rest_is_done_alone() {
let entry = |json: &str| {
let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
parse_sessions_json(&listing).unwrap().remove(0)
};
assert!(entry(r#""state": "done""#).at_rest());
assert!(entry(r#""state": "done", "pid": 4321"#).at_rest());
for json in [
r#""state": "blocked""#,
r#""state": "blocked", "pid": 4321"#,
r#""state": "working""#,
r#""state": "idle""#,
r#""state": "something-new""#,
r#""pid": 4321"#,
r#""cwd": "/tmp""#,
] {
assert!(!entry(json).at_rest(), "{json}");
}
}
#[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_resolves() {
let message = config()
.viewer_cmd_with(None, &none_installed)
.unwrap_err()
.to_string();
assert!(
message.starts_with("no viewer set up — run `voro viewer add"),
"{message}"
);
assert!(message.contains("'zed {path}'"), "{message}");
let (action, diagnosis) = message.split_once("; ").unwrap();
assert!(diagnosis.contains("code/cursor/zed"), "{message}");
assert!(!action.contains("install"), "{message}");
assert!(!message.contains("invalid"), "{message}");
assert!(config().viewer_names().is_empty());
assert_eq!(config().default_viewer_name_with(&none_installed), None);
}
#[test]
fn a_built_in_viewer_resolves_with_no_viewer_configured() {
let config = config();
assert_eq!(
config.viewer_cmd_with(None, &only("zed")).unwrap(),
"zed {path}"
);
assert_eq!(
config.default_viewer_name_with(&only("zed")).as_deref(),
Some("zed")
);
assert_eq!(
config
.viewer_cmd_with(None, &|name| matches!(name, "cursor" | "zed"))
.unwrap(),
"cursor -n {path}"
);
assert_eq!(config.viewer_cmd(Some("code")).unwrap(), "code -n {path}");
}
#[test]
fn user_viewers_outrank_the_probed_built_in() {
let installed = |_: &str| true;
let sole = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
assert_eq!(
sole.viewer_cmd_with(None, &installed).unwrap(),
"mine {path}"
);
assert_eq!(
sole.default_viewer_name_with(&installed).as_deref(),
Some("mine")
);
let anonymous = parse("[viewer]\ncmd = \"anon {path}\"").unwrap();
assert_eq!(
anonymous.viewer_cmd_with(None, &installed).unwrap(),
"anon {path}"
);
assert_eq!(anonymous.default_viewer_name_with(&installed), None);
let named = parse(
"default_viewer = \"mine\"\n[viewers.mine]\ncmd = \"mine {path}\"\n\
[viewers.other]\ncmd = \"other {path}\"",
)
.unwrap();
assert_eq!(
named.viewer_cmd_with(None, &installed).unwrap(),
"mine {path}"
);
}
#[test]
fn a_user_table_overrides_a_built_in_viewer_wholesale() {
let config = parse("[viewers.code]\ncmd = \"code --wait {path}\"").unwrap();
assert_eq!(
config.viewer_cmd(Some("code")).unwrap(),
"code --wait {path}"
);
assert_eq!(
config.viewer_cmd_with(None, &only("code")).unwrap(),
"code --wait {path}"
);
let entries = config.viewer_entries();
let code = entries.iter().find(|(name, ..)| *name == "code").unwrap();
assert_eq!(code.2, Provenance::UserOverride);
}
#[test]
fn viewer_entries_layer_the_built_ins_under_the_user_tables() {
let config = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
let entries: Vec<(&str, Provenance)> = config
.viewer_entries()
.into_iter()
.map(|(name, _, prov)| (name, prov))
.collect();
assert_eq!(
entries,
vec![
("code", Provenance::BuiltIn),
("cursor", Provenance::BuiltIn),
("mine", Provenance::User),
("zed", Provenance::BuiltIn),
]
);
assert_eq!(config.viewer_names(), vec!["mine"]);
}
#[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_fall_through_to_the_built_ins() {
let text = r#"
[viewers.mine]
cmd = "mine {path}"
[viewers.difftool]
cmd = "git difftool -d"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(
config.viewer_cmd_with(None, &only("zed")).unwrap(),
"zed {path}"
);
let message = config
.viewer_cmd_with(None, &none_installed)
.unwrap_err()
.to_string();
assert!(message.contains("no viewer set up"), "{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.starts_with("no viewer named 'emacs' — run"),
"{message}"
);
assert!(message.contains("code, cursor, zed"), "{message}");
assert!(!message.contains("invalid"), "{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_with(None, &none_installed).is_err());
let claude = config.agent("claude").unwrap();
assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
assert!(
claude.dispatch().contains(SESSION_NAME_PLACEHOLDER),
"{}",
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()
.chain(BUILTIN_VIEWERS.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"].resume().is_some());
}
fn spec(deep: bool) -> LaunchSpec<'static> {
LaunchSpec {
launch: Launch::Dispatch {
task_id: 7,
title: "Widen the strip".into(),
},
prompt_file: Path::new("/tmp/p.md"),
deep,
}
}
#[test]
fn a_launch_names_its_session_and_its_files() {
let dispatch = Launch::Dispatch {
task_id: 42,
title: "Widen the strip".into(),
};
let refine = Launch::Refine { task_id: 42 };
let plan = Launch::Plan {
project: "mote".into(),
};
assert_eq!(dispatch.session_name(), "voro-42-widen-the-strip");
assert_eq!(refine.session_name(), "voro-42-refine");
assert_eq!(plan.session_name(), "voro-plan-mote");
assert_ne!(dispatch.session_name(), refine.session_name());
assert_eq!(dispatch.slug(), "task-42");
assert_eq!(refine.slug(), "refine-42");
assert_eq!(plan.slug(), "plan-mote");
assert_eq!(dispatch.task_id(), Some(42));
assert_eq!(refine.task_id(), Some(42));
assert_eq!(plan.task_id(), None);
}
#[test]
fn a_quick_propose_names_its_project_and_has_no_task() {
let propose = Launch::Propose {
project: "mote".into(),
};
assert_eq!(propose.session_name(), "voro-propose-mote");
assert_eq!(propose.slug(), "propose-mote");
assert_eq!(propose.task_id(), None);
assert_ne!(
propose.session_name(),
Launch::Dispatch {
task_id: 2,
title: "Propose".into(),
}
.session_name()
);
}
#[test]
fn a_dispatch_names_its_session_for_the_task_title() {
let named = |id: i64, title: &str| {
Launch::Dispatch {
task_id: id,
title: title.into(),
}
.session_name()
};
assert_eq!(
named(428, "Deliver quick messages"),
"voro-428-deliver-quick-messages"
);
assert_eq!(
named(1, "Make the score decomposition legible"),
"voro-1-make-the-score"
);
assert_eq!(
named(2, "Internationalisation everywhere"),
"voro-2-internationalisation"
);
for name in [named(7, "Anything at all"), named(7, "")] {
assert!(name.starts_with("voro-7"), "{name}");
}
}
#[test]
fn a_dispatch_slug_survives_punctuation_and_unsanitizable_titles() {
let named = |title: &str| {
Launch::Dispatch {
task_id: 9,
title: title.into(),
}
.session_name()
};
assert_eq!(named("It's \"fine\"; rm -rf /"), "voro-9-it-s-fine-rm-rf");
assert_eq!(named("Fix voro-core_v1.2"), "voro-9-fix-voro-core_v1.2");
assert_eq!(named("ODM handoff"), "voro-9-odm-handoff");
assert_eq!(named(""), "voro-9");
assert_eq!(named(" "), "voro-9");
assert_eq!(named("!!! ???"), "voro-9");
assert_eq!(named("日本語"), "voro-9");
}
#[test]
fn a_dispatch_cannot_slug_onto_a_kind_suffix() {
let named = |title: &str| {
Launch::Dispatch {
task_id: 42,
title: title.into(),
}
.session_name()
};
let refine = Launch::Refine { task_id: 42 }.session_name();
assert_eq!(named("Refine the rewrite"), "voro-42-refine-the-rewrite");
assert_eq!(
named("Refine internationalisation"),
"voro-42-refine-internationalisation"
);
assert_eq!(
named("Refine"),
"voro-42",
"a one-word title has no next word to extend with"
);
for title in ["Refine the rewrite", "Refine", "refine!", " refine "] {
assert_ne!(named(title), refine, "collided on {title}");
}
}
#[test]
fn a_task_less_launch_sanitizes_its_project_name() {
let plan = |name: &str| Launch::Plan {
project: name.to_string(),
};
assert_eq!(plan("odm 2").session_name(), "voro-plan-odm-2");
assert_eq!(plan("odm 2").slug(), "plan-odm-2");
assert_eq!(plan("ODM").session_name(), "voro-plan-ODM");
assert_eq!(
plan("it's \"fine\"; rm -rf /").session_name(),
"voro-plan-it-s--fine---rm--rf--"
);
assert_eq!(plan("a/b").slug(), "plan-a-b");
assert_eq!(plan("voro-core_v1.2").slug(), "plan-voro-core_v1.2");
let propose = |name: &str| Launch::Propose {
project: name.to_string(),
};
assert_eq!(propose("odm 2").session_name(), "voro-propose-odm-2");
assert_eq!(propose("odm 2").slug(), "propose-odm-2");
assert_eq!(propose("ODM").session_name(), "voro-propose-ODM");
assert_eq!(
propose("it's \"fine\"; rm -rf /").session_name(),
"voro-propose-it-s--fine---rm--rf--"
);
assert_eq!(propose("a/b").slug(), "propose-a-b");
assert_eq!(propose("voro-core_v1.2").slug(), "propose-voro-core_v1.2");
}
#[test]
fn builtin_claude_names_the_session_from_the_launch() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let claude = config.resolve(Some("claude")).unwrap();
let dispatch = claude.launch_command(&spec(false));
assert!(
dispatch.contains("--name \"voro-7-widen-the-strip\""),
"{dispatch}"
);
let refined = claude.launch_command(&LaunchSpec {
launch: Launch::Refine { task_id: 7 },
..spec(false)
});
assert!(refined.contains("--name \"voro-7-refine\""), "{refined}");
assert_ne!(dispatch, refined);
let planned = claude
.plan_launch_command(&LaunchSpec {
launch: Launch::Plan {
project: "mote".into(),
},
..spec(false)
})
.unwrap();
assert!(planned.contains("--name \"voro-plan-mote\""), "{planned}");
assert!(!planned.contains("--bg"), "{planned}");
for rendered in [dispatch, refined, planned] {
assert!(!rendered.contains('{'), "unsubstituted: {rendered}");
}
}
#[test]
fn the_prompt_file_is_shell_quoted_into_the_command() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let claude = config.resolve(Some("claude")).unwrap();
let rendered = claude.launch_command(&LaunchSpec {
prompt_file: Path::new("/tmp/a dir/p.md"),
..spec(false)
});
assert!(rendered.contains("cat '/tmp/a dir/p.md'"), "{rendered}");
}
#[test]
fn builtin_claude_renders_a_model_per_purpose_and_depth() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let claude = config.resolve(Some("claude")).unwrap();
assert!(
claude.launch_command(&spec(false)).contains("--model opus"),
"{}",
claude.launch_command(&spec(false))
);
assert!(
claude.launch_command(&spec(true)).contains("--model fable"),
"{}",
claude.launch_command(&spec(true))
);
let planned = claude.plan_launch_command(&spec(false)).unwrap();
assert!(planned.contains("--model fable"), "{planned}");
for rendered in [
claude.launch_command(&spec(false)),
claude.launch_command(&spec(true)),
planned,
] {
assert!(
!rendered.contains(MODEL_PLACEHOLDER),
"placeholder left unresolved: {rendered}"
);
}
}
#[test]
fn an_agent_without_the_placeholder_ignores_depth_entirely() {
let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
let codex = config.resolve(Some("codex")).unwrap();
assert_eq!(
codex.launch_command(&spec(true)),
codex.launch_command(&spec(false))
);
assert_eq!(
codex.launch_command(&spec(true)),
"codex exec \"$(cat '/tmp/p.md')\""
);
assert_eq!(codex.plan_launch_command(&spec(false)), None);
}
#[test]
fn model_deep_and_model_plan_fall_back_to_model() {
let text = r#"
[agents.a]
dispatch = "run --model {model} {prompt_file}"
plan = "run -i --model {model} {prompt_file}"
model = "workhorse"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let a = config.resolve(Some("a")).unwrap();
assert_eq!(
a.launch_command(&spec(false)),
"run --model workhorse '/tmp/p.md'"
);
assert_eq!(
a.launch_command(&spec(true)),
"run --model workhorse '/tmp/p.md'"
);
assert_eq!(
a.plan_launch_command(&spec(false)).unwrap(),
"run -i --model workhorse '/tmp/p.md'"
);
}
#[test]
fn the_placeholder_without_a_model_key_is_a_config_error() {
let text = r#"
[agents.a]
dispatch = "run --model {model} {prompt_file}"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("{model}"), "{message}");
assert!(message.contains("model = "), "{message}");
assert!(message.contains("'a'"), "{message}");
let text = r#"
[agents.a]
dispatch = "run {prompt_file}"
plan = "run -i --model {model} {prompt_file}"
"#;
assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
}
#[test]
fn model_keys_without_the_placeholder_are_inert_not_an_error() {
let text = r#"
[agents.claude]
dispatch = "claude -p {prompt_file}"
model = "opus"
model_deep = "fable"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let claude = config.resolve(Some("claude")).unwrap();
assert_eq!(claude.launch_command(&spec(true)), "claude -p '/tmp/p.md'");
assert_eq!(claude.launch_command(&spec(false)), "claude -p '/tmp/p.md'");
}
#[test]
fn a_session_verb_carrying_the_placeholder_is_rejected() {
let text = r#"
[agents.a]
dispatch = "run {prompt_file}"
attach = "reopen --model {model} {session}"
model = "workhorse"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("attach"), "{message}");
assert!(message.contains("{model}"), "{message}");
}
#[test]
fn launch_placeholders_are_refused_on_the_verbs_that_cannot_bind_them() {
for (verb, template) in [
("sessions", "list --name {session_name}"),
("attach", "reopen --name {session_name} {session}"),
("resume", "reopen {session} --for {task_id}"),
] {
let text =
format!("[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = '{template}'\n");
let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains(verb), "{verb}: {message}");
assert!(message.contains("dispatch and plan"), "{verb}: {message}");
}
let text = r#"
[agents.a]
dispatch = "run {prompt_file}"
plan = "run -i --name \"voro-{task_id}\" {prompt_file}"
"#;
let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
.unwrap_err()
.to_string();
assert!(message.contains("plan"), "{message}");
assert!(message.contains(TASK_ID_PLACEHOLDER), "{message}");
assert!(message.contains(SESSION_NAME_PLACEHOLDER), "{message}");
}
#[test]
fn the_session_name_placeholder_is_accepted_on_dispatch_and_plan() {
let text = r#"
[agents.a]
dispatch = "run --name {session_name} --for {task_id} {prompt_file}"
plan = "run -i --name {session_name} {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
let a = config.resolve(Some("a")).unwrap();
assert_eq!(
a.launch_command(&spec(false)),
"run --name voro-7-widen-the-strip --for 7 '/tmp/p.md'"
);
assert_eq!(
a.plan_launch_command(&LaunchSpec {
launch: Launch::Plan {
project: "mote".into(),
},
..spec(false)
})
.unwrap(),
"run -i --name voro-plan-mote '/tmp/p.md'"
);
}
#[test]
fn a_sessions_verb_makes_a_launch_listing_authoritative() {
let text = r#"
[agents.supervised]
dispatch = "run --bg {prompt_file}"
sessions = "run sessions --json"
[agents.plain]
dispatch = "run {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(
config
.resolve(Some("supervised"))
.unwrap()
.dispatch_liveness_source(),
LivenessSource::Listing
);
assert_eq!(
config
.resolve(Some("plain"))
.unwrap()
.dispatch_liveness_source(),
LivenessSource::Pid
);
}
#[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");
}
}