1use std::collections::BTreeMap;
15use std::path::{Path, PathBuf};
16use std::sync::LazyLock;
17
18use serde::Deserialize;
19
20use crate::error::{Error, Result};
21use crate::model::LivenessSource;
22use crate::scheduler::{AttentionCosts, DEFAULT_MAX_RUNNING};
23use crate::template::{render, shell_quote};
24
25pub const PROMPT_FILE_PLACEHOLDER: &str = "{prompt_file}";
29
30pub const TASK_ID_PLACEHOLDER: &str = "{task_id}";
35
36pub const SESSION_NAME_PLACEHOLDER: &str = "{session_name}";
42
43pub const SESSION_PLACEHOLDER: &str = "{session}";
47
48pub const NEW_SESSION_PLACEHOLDER: &str = "{new_session}";
57
58pub const MODEL_PLACEHOLDER: &str = "{model}";
64
65pub const VIEWER_PATH_PLACEHOLDER: &str = "{path}";
70
71pub const VIEWER_BRANCH_PLACEHOLDER: &str = "{branch}";
76
77pub const VIEWER_BASE_PLACEHOLDER: &str = "{base}";
80
81const BUILTIN_AGENTS: &str = "\
179[agents.claude]
180dispatch = \"claude --bg --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
181sessions = \"claude agents --json\"
182attach = \"claude attach {session}\"
183resume = \"claude --resume {session}\"
184message = \"claude -p --resume {session} --permission-mode auto \\\"$(cat {prompt_file})\\\"\"
185logs = \"claude logs \\\"$(printf %.8s {session})\\\" 2>/dev/null | tail -c 20000\"
186cap = '''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'''
187stop = \"claude stop \\\"$(printf %.8s {session})\\\"\"
188plan = \"claude --name \\\"{session_name}\\\" --permission-mode auto --model {model} \\\"$(cat {prompt_file})\\\"\"
189model = \"opus\"
190model_deep = \"fable\"
191model_plan = \"fable\"
192
193[agents.codex]
194dispatch = \"codex exec \\\"$(cat {prompt_file})\\\"\"
195resume = \"codex resume {session}\"
196";
197
198const DEFAULT_PROBE_ORDER: [&str; 2] = ["claude", "codex"];
201
202fn builtin_agents() -> &'static BTreeMap<String, AgentTemplate> {
206 static BUILTINS: LazyLock<BTreeMap<String, AgentTemplate>> = LazyLock::new(|| {
207 let raw: RawConfig = toml::from_str(BUILTIN_AGENTS).expect("built-in agents TOML parses");
208 for (name, agent) in &raw.agents {
209 validate_agent(name, agent, Path::new("<built-in>")).expect("built-in agent is valid");
210 }
211 raw.agents
212 });
213 &BUILTINS
214}
215
216const BUILTIN_VIEWERS: &str = "\
228[viewers.code]
229cmd = \"code -n {path}\"
230
231[viewers.cursor]
232cmd = \"cursor -n {path}\"
233
234[viewers.zed]
235cmd = \"zed {path}\"
236";
237
238pub const BUILTIN_VIEWER_NAMES: [&str; 3] = ["code", "cursor", "zed"];
244
245fn builtin_viewers() -> &'static BTreeMap<String, ViewerTemplate> {
249 static BUILTINS: LazyLock<BTreeMap<String, ViewerTemplate>> = LazyLock::new(|| {
250 let raw: RawConfig = toml::from_str(BUILTIN_VIEWERS).expect("built-in viewers TOML parses");
251 for (name, viewer) in &raw.viewers {
252 assert!(
253 !viewer.cmd.trim().is_empty(),
254 "built-in viewer '{name}' has a command"
255 );
256 }
257 for name in BUILTIN_VIEWER_NAMES {
258 assert!(
259 raw.viewers.contains_key(name),
260 "probe order names a built-in viewer, not '{name}'"
261 );
262 }
263 raw.viewers
264 });
265 &BUILTINS
266}
267
268pub fn is_builtin_viewer(name: &str) -> bool {
272 builtin_viewers().contains_key(name)
273}
274
275pub fn builtin_viewer_cmd(name: &str) -> Option<&'static str> {
278 builtin_viewers().get(name).map(|v| v.cmd.as_str())
279}
280
281const STARTER_HEADER: &str = r#"# Voro configuration (~/.config/voro/voro.toml).
284#
285# This file is OPTIONAL. Voro ships with built-in `claude` and `codex` agents
286# and built-in `code`, `cursor` and `zed` viewers, so a fresh install with any
287# of those on PATH dispatches and opens a diff with no config here. Run
288# `voro agent list` and `voro viewer list` to see the effective sets and where
289# each entry comes from.
290#
291# Use this file to extend or override the built-ins, and to set app options:
292#
293# * add your own agent — a new [agents.<name>] table. Only `dispatch` is
294# required (`cmd` is an alias): it starts a session on a task, with
295# `{prompt_file}` replaced by the prompt file's path, the optional
296# `{session_name}` by the name Voro composes for the session
297# (`voro-<id>-<title-slug>` for a dispatch, `voro-<id>-refine` for a
298# refine, `voro-plan-<project>` for planning, `voro-propose-<project>` for
299# a quick propose), and the optional `{task_id}`
300# by the task's numeric id.
301# The optional session verbs unlock attachable dispatch, and each degrades
302# gracefully when absent:
303# sessions list the agent's sessions as JSON (liveness + ref capture).
304# Each entry needs an id (`sessionId`, or `id`); `state`
305# (`done` once finished, `working` while going) and `pid` say
306# whether it is still live, `pid` deciding it where present.
307# An entry carrying neither reads as dead.
308# attach open a running session interactively ({session})
309# resume reopen a finished session interactively ({session})
310# message say one thing into a session headlessly, no terminal
311# ({session} and {prompt_file}, plus the optional
312# {new_session}: a fresh reference for an agent that can only
313# be joined by forking, which the session row then follows)
314# logs print a session's recent output ({session})
315# Read for one thing: whether the session is sitting on a
316# usage cap, which is badged on the running strip and used to
317# tell a capped death from an ordinary one. Tail it in the
318# template — Voro reads whatever it prints.
319# cap print when the account's usage window reopens, as a Unix
320# epoch, while the account is capped — and nothing when it is
321# not. Read instead of the clock time on a session's screen,
322# which carries no date. It may name {model} and nothing else:
323# a subscription meters each strong model separately, so the
324# window that refuses depends on which one asks. Costs whatever
325# asking the agent costs, so Voro asks only while a session is
326# badged capped.
327# stop retire a session from the agent's own registry ({session})
328# Fired when Voro closes the session's row, so the agent's
329# listing shows work actually in flight. Fire and forget: Voro
330# reads neither output nor status, and a session already gone
331# is not an error.
332# plan run an interactive foreground planning session ({prompt_file})
333# `plan` may carry `{session_name}` too, but not `{task_id}`: a planning
334# session drafts a task rather than naming one.
335# `dispatch` and `plan` may also carry `{model}`, filled from this agent's
336# own model keys: `model` normally, `model_deep` for a task flagged deep
337# (`voro set <id> --deep`), and `model_plan` when planning — the last two
338# falling back to `model`. The values are opaque names Voro pastes in and
339# never interprets, so an agent with no `{model}` takes no model direction
340# and a deep task dispatches with it unchanged.
341# See docs/agent-integration.md for the full contract.
342# * override a built-in — a table named `claude` or `codex` REPLACES that
343# built-in entirely (not per-verb), so copy every verb you still want. The
344# built-ins are reproduced below, commented out, ready to copy.
345# * set `default_agent` — used for tasks with no --agent override. When unset,
346# Voro picks the first built-in found on PATH (claude, then codex).
347# * set up viewers — [viewers.<name>] tables define how a task's diff is
348# shown locally by `voro open` (DESIGN.md §8). A viewer cmd may carry
349# `{path}` (the task's worktree, or the project checkout when it has none),
350# `{branch}` (the task's branch, or empty), and `{base}` (the checkout's
351# default branch); `{base}...{branch}` spells a diff range. Viewers are
352# built in like the agents — a table named `code`, `cursor` or `zed`
353# replaces that built-in wholesale, any other name adds a viewer.
354# `default_viewer` names the one used when a project does not pick a viewer
355# itself (`voro project viewer <p> <name>`); unset, Voro uses the sole
356# viewer defined here, else the first built-in found on PATH. A single
357# anonymous [viewer] table is the older, still-valid spelling of the
358# default. A viewer must open its own window: `voro open` spawns it
359# detached with no terminal, so a pager-driven command cannot draw.
360# * price the queue — `max_running` caps how many dispatches ride at once
361# (default 5; at the cap the queue offers no more), and a [costs] table
362# divides each row's score by what its action asks of you, so a cheap
363# decision outranks an expensive review of the same raw worth. Keep the
364# band narrow (DESIGN.md §7) — it is a nudge, not a re-ranking.
365"#;
366
367fn starter_config() -> String {
373 let mut out = String::from(STARTER_HEADER);
374 out.push_str(
375 "\n# --------------------------------------------------------------------------\n\
376 # Built-in agents and viewers, exactly as shipped. Uncomment a block and\n\
377 # edit it to override that entry wholesale; leave it commented to keep the\n\
378 # built-in, which updates with Voro. Copy a block to model one of your own.\n\
379 # --------------------------------------------------------------------------\n#\n",
380 );
381 for line in BUILTIN_AGENTS
382 .lines()
383 .chain([""])
384 .chain(BUILTIN_VIEWERS.lines())
385 {
386 if line.is_empty() {
387 out.push_str("#\n");
388 } else {
389 out.push_str("# ");
390 out.push_str(line);
391 out.push('\n');
392 }
393 }
394 out.push_str(
395 "\n# --------------------------------------------------------------------------\n\
396 # Examples (uncomment and tune):\n#\n\
397 # default_agent = \"claude\"\n#\n\
398 # [agents.mine]\n\
399 # dispatch = \"my-agent run {prompt_file}\"\n#\n\
400 # default_viewer = \"zed\"\n#\n\
401 # [viewers.difftool]\n\
402 # cmd = \"git -C {path} difftool -d {base}...{branch}\"\n#\n\
403 # max_running = 5\n#\n\
404 # [costs]\n\
405 # answer = 0.8\n\
406 # triage = 0.8\n\
407 # dispatch = 1.0\n\
408 # review = 1.4\n\
409 # do = 1.8\n",
410 );
411 out
412}
413
414#[derive(Debug, Clone, Deserialize)]
420#[serde(deny_unknown_fields)]
421pub struct AgentTemplate {
422 dispatch: Option<String>,
423 cmd: Option<String>,
425 sessions: Option<String>,
426 attach: Option<String>,
427 resume: Option<String>,
428 message: Option<String>,
433 logs: Option<String>,
440 cap: Option<String>,
455 stop: Option<String>,
463 plan: Option<String>,
467 model: Option<String>,
471 model_deep: Option<String>,
474 model_plan: Option<String>,
476}
477
478impl AgentTemplate {
479 pub fn dispatch(&self) -> &str {
482 self.dispatch
483 .as_deref()
484 .or(self.cmd.as_deref())
485 .expect("parse validates that dispatch or cmd is set")
486 }
487
488 pub fn sessions(&self) -> Option<&str> {
489 self.sessions.as_deref()
490 }
491
492 pub fn attach(&self) -> Option<&str> {
493 self.attach.as_deref()
494 }
495
496 pub fn resume(&self) -> Option<&str> {
497 self.resume.as_deref()
498 }
499
500 pub fn message(&self) -> Option<&str> {
501 self.message.as_deref()
502 }
503
504 pub fn logs(&self) -> Option<&str> {
505 self.logs.as_deref()
506 }
507
508 pub fn cap(&self) -> Option<&str> {
509 self.cap.as_deref()
510 }
511
512 pub fn stop(&self) -> Option<&str> {
513 self.stop.as_deref()
514 }
515
516 pub fn plan(&self) -> Option<&str> {
517 self.plan.as_deref()
518 }
519
520 pub fn model(&self) -> Option<&str> {
521 self.model.as_deref()
522 }
523
524 pub fn model_deep(&self) -> Option<&str> {
525 self.model_deep.as_deref()
526 }
527
528 pub fn model_plan(&self) -> Option<&str> {
529 self.model_plan.as_deref()
530 }
531
532 pub fn model_for(&self, deep: bool) -> Option<&str> {
537 model_for_depth(self.model(), self.model_deep(), deep)
538 }
539
540 pub fn verbs(&self) -> Vec<&'static str> {
546 OPTIONAL_VERBS
547 .iter()
548 .filter_map(|(verb, defined)| {
549 let template = defined(self)?;
550 Some(
551 if *verb == "message" && template.contains(NEW_SESSION_PLACEHOLDER) {
552 "message(fork)"
553 } else {
554 *verb
555 },
556 )
557 })
558 .collect()
559 }
560}
561
562type VerbAccessor = (&'static str, fn(&AgentTemplate) -> Option<&str>);
564
565const OPTIONAL_VERBS: [VerbAccessor; 8] = [
570 ("sessions", AgentTemplate::sessions),
571 ("attach", AgentTemplate::attach),
572 ("resume", AgentTemplate::resume),
573 ("message", AgentTemplate::message),
574 ("logs", AgentTemplate::logs),
575 ("cap", AgentTemplate::cap),
576 ("stop", AgentTemplate::stop),
577 ("plan", AgentTemplate::plan),
578];
579
580#[derive(Debug, Clone, PartialEq, Eq)]
594pub enum Launch {
595 Dispatch { task_id: i64, title: String },
598 Refine { task_id: i64 },
602 Plan { project: String },
606 Propose { project: String },
612}
613
614impl Launch {
615 pub fn session_name(&self) -> String {
623 match self {
624 Launch::Dispatch { task_id, title } => match title_slug(title) {
625 Some(slug) => format!("voro-{task_id}-{slug}"),
626 None => format!("voro-{task_id}"),
627 },
628 Launch::Refine { task_id } => format!("voro-{task_id}-refine"),
629 Launch::Plan { project } => format!("voro-plan-{}", sanitize_for_name(project)),
630 Launch::Propose { project } => format!("voro-propose-{}", sanitize_for_name(project)),
631 }
632 }
633
634 pub fn slug(&self) -> String {
637 match self {
638 Launch::Dispatch { task_id, .. } => format!("task-{task_id}"),
639 Launch::Refine { task_id } => format!("refine-{task_id}"),
640 Launch::Plan { project } => format!("plan-{}", sanitize_for_name(project)),
641 Launch::Propose { project } => format!("propose-{}", sanitize_for_name(project)),
642 }
643 }
644
645 pub fn task_id(&self) -> Option<i64> {
649 match self {
650 Launch::Dispatch { task_id, .. } | Launch::Refine { task_id } => Some(*task_id),
651 Launch::Plan { .. } | Launch::Propose { .. } => None,
652 }
653 }
654}
655
656fn sanitize_for_name(name: &str) -> String {
664 name.chars()
665 .map(|c| {
666 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
667 c
668 } else {
669 '-'
670 }
671 })
672 .collect()
673}
674
675const RESERVED_NAME_SUFFIXES: &[&str] = &["refine"];
680
681const TITLE_SLUG_BUDGET: usize = 24;
686
687fn title_slug(title: &str) -> Option<String> {
695 let words: Vec<String> = title
696 .split_whitespace()
697 .map(|word| tidy_dashes(&sanitize_for_name(&word.to_lowercase())))
698 .filter(|word| !word.is_empty())
699 .collect();
700
701 let mut slug = String::new();
702 let mut taken = 0;
703 for word in &words {
704 if !slug.is_empty() && slug.len() + 1 + word.len() > TITLE_SLUG_BUDGET {
705 break;
706 }
707 if !slug.is_empty() {
708 slug.push('-');
709 }
710 slug.push_str(word);
711 taken += 1;
712 }
713
714 if RESERVED_NAME_SUFFIXES.contains(&slug.as_str()) {
715 let next = words.get(taken)?;
718 slug.push('-');
719 slug.push_str(next);
720 }
721
722 (!slug.is_empty()).then_some(slug)
723}
724
725fn tidy_dashes(word: &str) -> String {
730 let mut out = String::with_capacity(word.len());
731 for c in word.chars() {
732 if c == '-' && (out.is_empty() || out.ends_with('-')) {
733 continue;
734 }
735 out.push(c);
736 }
737 out.trim_end_matches('-').to_string()
738}
739
740#[derive(Debug, Clone)]
746pub struct LaunchSpec<'a> {
747 pub launch: Launch,
748 pub prompt_file: &'a Path,
749 pub deep: bool,
752}
753
754fn render_launch(template: &str, spec: &LaunchSpec, model: Option<&str>) -> String {
759 let prompt_file = shell_quote(spec.prompt_file);
760 let session_name = spec.launch.session_name();
761 let task_id = spec.launch.task_id().map(|id| id.to_string());
762 let mut bindings = vec![
763 (PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
764 (SESSION_NAME_PLACEHOLDER, session_name.as_str()),
765 ];
766 if let Some(task_id) = &task_id {
767 bindings.push((TASK_ID_PLACEHOLDER, task_id.as_str()));
768 }
769 if let Some(model) = model {
770 bindings.push((MODEL_PLACEHOLDER, model));
771 }
772 render(template, &bindings)
773}
774
775#[derive(Debug, Clone, PartialEq, Eq)]
781pub struct RenderedMessage {
782 pub command: String,
783 pub new_session_ref: Option<String>,
786}
787
788pub fn model_for_depth<'a>(
798 model: Option<&'a str>,
799 model_deep: Option<&'a str>,
800 deep: bool,
801) -> Option<&'a str> {
802 if deep { model_deep.or(model) } else { model }
803}
804
805pub fn render_cap(template: &str, model: Option<&str>) -> String {
811 match model {
812 Some(model) => render(template, &[(MODEL_PLACEHOLDER, model)]),
813 None => template.to_string(),
814 }
815}
816
817pub fn render_session(template: &str, session_ref: &str) -> String {
818 let session = shell_quote(Path::new(session_ref));
819 render(template, &[(SESSION_PLACEHOLDER, session.as_str())])
820}
821
822pub fn render_message(template: &str, session_ref: &str, prompt_file: &Path) -> RenderedMessage {
828 let session = shell_quote(Path::new(session_ref));
829 let prompt_file = shell_quote(prompt_file);
830 let new_session_ref = template
831 .contains(NEW_SESSION_PLACEHOLDER)
832 .then(|| uuid::Uuid::new_v4().to_string());
833 let new_session = new_session_ref
834 .as_deref()
835 .map(|r| shell_quote(Path::new(r)));
836 let mut bindings = vec![
837 (SESSION_PLACEHOLDER, session.as_str()),
838 (PROMPT_FILE_PLACEHOLDER, prompt_file.as_str()),
839 ];
840 if let Some(new_session) = &new_session {
841 bindings.push((NEW_SESSION_PLACEHOLDER, new_session.as_str()));
842 }
843 RenderedMessage {
844 command: render(template, &bindings),
845 new_session_ref,
846 }
847}
848
849#[derive(Debug, Clone, Deserialize)]
856#[serde(deny_unknown_fields)]
857pub struct ViewerTemplate {
858 pub cmd: String,
859}
860
861#[derive(Debug, Clone, Copy, PartialEq, Eq)]
865pub enum Provenance {
866 BuiltIn,
868 User,
870 UserOverride,
872}
873
874impl Provenance {
875 pub fn label(self) -> &'static str {
877 match self {
878 Provenance::BuiltIn => "built-in",
879 Provenance::User => "user",
880 Provenance::UserOverride => "user override",
881 }
882 }
883}
884
885#[derive(Debug, Deserialize)]
889#[serde(deny_unknown_fields)]
890struct RawConfig {
891 #[serde(default)]
892 default_agent: Option<String>,
893 #[serde(default)]
894 agents: BTreeMap<String, AgentTemplate>,
895 #[serde(default)]
896 viewer: Option<ViewerTemplate>,
897 #[serde(default)]
898 viewers: BTreeMap<String, ViewerTemplate>,
899 #[serde(default)]
900 default_viewer: Option<String>,
901 #[serde(default)]
902 max_running: Option<i64>,
903 #[serde(default)]
904 costs: Option<RawCosts>,
905}
906
907#[derive(Debug, Deserialize)]
911#[serde(deny_unknown_fields)]
912struct RawCosts {
913 answer: Option<f64>,
914 triage: Option<f64>,
915 dispatch: Option<f64>,
916 review: Option<f64>,
917 #[serde(rename = "do")]
919 human_do: Option<f64>,
920}
921
922impl RawCosts {
923 fn resolve(self, path: &Path) -> Result<AttentionCosts> {
926 let defaults = AttentionCosts::default();
927 let checked = |name: &str, value: Option<f64>, default: f64| -> Result<f64> {
928 match value {
929 None => Ok(default),
930 Some(value) if value.is_finite() && value > 0.0 => Ok(value),
931 Some(value) => Err(Error::AgentConfigInvalid {
932 path: path.to_path_buf(),
933 message: format!(
934 "cost '{name}' is {value} — every [costs] divisor must be a positive \
935 number (the defaults sit between 0.8 and 1.8)"
936 ),
937 }),
938 }
939 };
940 Ok(AttentionCosts {
941 answer: checked("answer", self.answer, defaults.answer)?,
942 triage: checked("triage", self.triage, defaults.triage)?,
943 dispatch: checked("dispatch", self.dispatch, defaults.dispatch)?,
944 review: checked("review", self.review, defaults.review)?,
945 human_do: checked("do", self.human_do, defaults.human_do)?,
946 })
947 }
948}
949
950pub(crate) fn negative_max_running(n: i64) -> String {
955 format!(
956 "max_running is {n} — it counts dispatches in flight, so it cannot be negative (0 stops \
957 the queue offering dispatches at all)"
958 )
959}
960
961fn validate_agent(name: &str, agent: &AgentTemplate, path: &Path) -> Result<()> {
966 let invalid = |message: String| Error::AgentConfigInvalid {
967 path: path.to_path_buf(),
968 message,
969 };
970 let dispatch = match (&agent.dispatch, &agent.cmd) {
971 (Some(_), Some(_)) => {
972 return Err(invalid(format!(
973 "agent '{name}' sets both dispatch and cmd — cmd is an alias for \
974 dispatch, keep one"
975 )));
976 }
977 (Some(d), None) => d,
978 (None, Some(c)) => c,
979 (None, None) => {
980 return Err(invalid(format!(
981 "agent '{name}' is missing a dispatch (or cmd) template"
982 )));
983 }
984 };
985 if !dispatch.contains(PROMPT_FILE_PLACEHOLDER) {
986 return Err(invalid(format!(
987 "agent '{name}' cmd is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
988 )));
989 }
990 for (verb, template) in [
991 ("attach", &agent.attach),
992 ("resume", &agent.resume),
993 ("message", &agent.message),
994 ("logs", &agent.logs),
995 ("stop", &agent.stop),
996 ] {
997 if let Some(template) = template
998 && !template.contains(SESSION_PLACEHOLDER)
999 {
1000 return Err(invalid(format!(
1001 "agent '{name}' {verb} is missing the {SESSION_PLACEHOLDER} placeholder"
1002 )));
1003 }
1004 }
1005 for (verb, template) in [("plan", &agent.plan), ("message", &agent.message)] {
1008 if let Some(template) = template
1009 && !template.contains(PROMPT_FILE_PLACEHOLDER)
1010 {
1011 return Err(invalid(format!(
1012 "agent '{name}' {verb} is missing the {PROMPT_FILE_PLACEHOLDER} placeholder"
1013 )));
1014 }
1015 }
1016 for (verb, template) in [
1022 ("sessions", &agent.sessions),
1023 ("attach", &agent.attach),
1024 ("resume", &agent.resume),
1025 ("message", &agent.message),
1026 ("logs", &agent.logs),
1027 ("stop", &agent.stop),
1028 ] {
1029 let Some(template) = template else { continue };
1030 if template.contains(MODEL_PLACEHOLDER) {
1031 return Err(invalid(format!(
1032 "agent '{name}' {verb} carries {MODEL_PLACEHOLDER}, which is resolved only on \
1033 dispatch and plan — a session verb reuses the model its session started with"
1034 )));
1035 }
1036 for placeholder in [SESSION_NAME_PLACEHOLDER, TASK_ID_PLACEHOLDER] {
1037 if template.contains(placeholder) {
1038 return Err(invalid(format!(
1039 "agent '{name}' {verb} carries {placeholder}, which is resolved only on \
1040 dispatch and plan — a session verb names its session with {SESSION_PLACEHOLDER}, \
1041 the reference Voro captured at launch"
1042 )));
1043 }
1044 }
1045 }
1046 if let Some(template) = &agent.cap {
1051 for placeholder in [
1052 SESSION_PLACEHOLDER,
1053 PROMPT_FILE_PLACEHOLDER,
1054 SESSION_NAME_PLACEHOLDER,
1055 TASK_ID_PLACEHOLDER,
1056 NEW_SESSION_PLACEHOLDER,
1057 ] {
1058 if template.contains(placeholder) {
1059 return Err(invalid(format!(
1060 "agent '{name}' cap carries {placeholder}, but a cap reading is about the \
1061 account rather than any one session or launch — {MODEL_PLACEHOLDER} is the \
1062 only placeholder it may name"
1063 )));
1064 }
1065 }
1066 }
1067 for (verb, template) in [
1071 ("dispatch", Some(dispatch.as_str())),
1072 ("sessions", agent.sessions.as_deref()),
1073 ("attach", agent.attach.as_deref()),
1074 ("resume", agent.resume.as_deref()),
1075 ("logs", agent.logs.as_deref()),
1076 ("stop", agent.stop.as_deref()),
1077 ("plan", agent.plan.as_deref()),
1078 ] {
1079 if template.is_some_and(|t| t.contains(NEW_SESSION_PLACEHOLDER)) {
1080 return Err(invalid(format!(
1081 "agent '{name}' {verb} carries {NEW_SESSION_PLACEHOLDER}, which is bound only on \
1082 message — it names the session a headless send forks into"
1083 )));
1084 }
1085 }
1086 if let Some(template) = &agent.plan
1090 && template.contains(TASK_ID_PLACEHOLDER)
1091 {
1092 return Err(invalid(format!(
1093 "agent '{name}' plan carries {TASK_ID_PLACEHOLDER}, but a planning session drafts a \
1094 task rather than naming one — use {SESSION_NAME_PLACEHOLDER}, which Voro composes \
1095 for every launch"
1096 )));
1097 }
1098 if agent.model.is_none()
1102 && [
1103 dispatch.as_str(),
1104 agent.plan.as_deref().unwrap_or_default(),
1105 agent.cap.as_deref().unwrap_or_default(),
1106 ]
1107 .iter()
1108 .any(|t| t.contains(MODEL_PLACEHOLDER))
1109 {
1110 return Err(invalid(format!(
1111 "agent '{name}' uses {MODEL_PLACEHOLDER} but sets no model — add model = \
1112 \"<model name>\" to its [agents.{name}] table (optionally model_deep for deep \
1113 tasks and model_plan for planning), or drop the placeholder"
1114 )));
1115 }
1116 Ok(())
1117}
1118
1119fn binary_on_path(name: &str) -> bool {
1123 let Some(paths) = std::env::var_os("PATH") else {
1124 return false;
1125 };
1126 std::env::split_paths(&paths).any(|dir| dir.join(name).is_file())
1127}
1128
1129fn probed_builtin_viewer(probe: &dyn Fn(&str) -> bool) -> Option<&'static str> {
1134 BUILTIN_VIEWER_NAMES.into_iter().find(|name| probe(name))
1135}
1136
1137#[derive(Debug, Clone, PartialEq, Eq)]
1147pub struct ResolvedAgent {
1148 pub name: String,
1149 pub dispatch: String,
1150 pub sessions: Option<String>,
1151 pub attach: Option<String>,
1152 pub resume: Option<String>,
1153 pub message: Option<String>,
1154 pub logs: Option<String>,
1155 pub cap: Option<String>,
1156 pub stop: Option<String>,
1157 pub plan: Option<String>,
1158 pub model: Option<String>,
1159 pub model_deep: Option<String>,
1160 pub model_plan: Option<String>,
1161}
1162
1163impl ResolvedAgent {
1164 pub fn launch_command(&self, spec: &LaunchSpec) -> String {
1172 let model = model_for_depth(self.model.as_deref(), self.model_deep.as_deref(), spec.deep);
1173 render_launch(&self.dispatch, spec, model)
1174 }
1175
1176 pub fn dispatch_liveness_source(&self) -> LivenessSource {
1188 match self.sessions {
1189 Some(_) => LivenessSource::Listing,
1190 None => LivenessSource::Pid,
1191 }
1192 }
1193
1194 pub fn plan_launch_command(&self, spec: &LaunchSpec) -> Option<String> {
1199 let model = self.model_plan.as_deref().or(self.model.as_deref());
1200 self.plan
1201 .as_deref()
1202 .map(|template| render_launch(template, spec, model))
1203 }
1204}
1205
1206#[derive(Debug, Clone)]
1210pub struct AgentsConfig {
1211 default: Option<String>,
1213 agents: BTreeMap<String, AgentTemplate>,
1214 provenance: BTreeMap<String, Provenance>,
1215 viewer: Option<ViewerTemplate>,
1218 viewers: BTreeMap<String, ViewerTemplate>,
1221 default_viewer: Option<String>,
1223 costs: AttentionCosts,
1226 max_running: Option<i64>,
1231 path: PathBuf,
1232}
1233
1234const CONFIG_FILENAME: &str = "voro.toml";
1236
1237impl AgentsConfig {
1238 pub fn default_path() -> PathBuf {
1242 let config_home = std::env::var_os("XDG_CONFIG_HOME")
1243 .map(PathBuf::from)
1244 .filter(|p| p.is_absolute())
1245 .unwrap_or_else(|| {
1246 let home = std::env::var_os("HOME")
1247 .map(PathBuf::from)
1248 .unwrap_or_default();
1249 home.join(".config")
1250 });
1251 config_home.join("voro").join(CONFIG_FILENAME)
1252 }
1253
1254 pub fn load(path: &Path) -> Result<AgentsConfig> {
1258 match std::fs::read_to_string(path) {
1259 Ok(text) => AgentsConfig::parse(&text, path),
1260 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
1261 Ok(AgentsConfig::builtin_only(path))
1262 }
1263 Err(e) => Err(Error::AgentConfigInvalid {
1264 path: path.to_path_buf(),
1265 message: e.to_string(),
1266 }),
1267 }
1268 }
1269
1270 fn builtin_only(path: &Path) -> AgentsConfig {
1273 let agents = builtin_agents().clone();
1274 let provenance = agents
1275 .keys()
1276 .map(|name| (name.clone(), Provenance::BuiltIn))
1277 .collect();
1278 AgentsConfig {
1279 default: None,
1280 agents,
1281 provenance,
1282 viewer: None,
1283 viewers: BTreeMap::new(),
1284 default_viewer: None,
1285 costs: AttentionCosts::default(),
1286 max_running: None,
1287 path: path.to_path_buf(),
1288 }
1289 }
1290
1291 fn parse(text: &str, path: &Path) -> Result<AgentsConfig> {
1296 let raw: RawConfig = toml::from_str(text).map_err(|e| Error::AgentConfigInvalid {
1297 path: path.to_path_buf(),
1298 message: e.message().to_string(),
1299 })?;
1300 for (name, agent) in &raw.agents {
1301 validate_agent(name, agent, path)?;
1302 }
1303 let mut agents = builtin_agents().clone();
1304 let mut provenance: BTreeMap<String, Provenance> = agents
1305 .keys()
1306 .map(|name| (name.clone(), Provenance::BuiltIn))
1307 .collect();
1308 for (name, agent) in raw.agents {
1309 let prov = if builtin_agents().contains_key(&name) {
1310 Provenance::UserOverride
1311 } else {
1312 Provenance::User
1313 };
1314 provenance.insert(name.clone(), prov);
1315 agents.insert(name, agent);
1316 }
1317 if let Some(n) = raw.max_running
1318 && n < 0
1319 {
1320 return Err(Error::AgentConfigInvalid {
1321 path: path.to_path_buf(),
1322 message: negative_max_running(n),
1323 });
1324 }
1325 let costs = match raw.costs {
1326 Some(costs) => costs.resolve(path)?,
1327 None => AttentionCosts::default(),
1328 };
1329 Ok(AgentsConfig {
1330 default: raw.default_agent,
1331 agents,
1332 provenance,
1333 viewer: raw.viewer,
1334 viewers: raw.viewers,
1335 default_viewer: raw.default_viewer,
1336 costs,
1337 max_running: raw.max_running,
1338 path: path.to_path_buf(),
1339 })
1340 }
1341
1342 pub fn costs(&self) -> AttentionCosts {
1344 self.costs
1345 }
1346
1347 pub fn max_running(&self) -> i64 {
1350 self.max_running.unwrap_or(DEFAULT_MAX_RUNNING)
1351 }
1352
1353 pub fn max_running_from_file(&self) -> Option<i64> {
1358 self.max_running
1359 }
1360
1361 pub fn default_agent_from_file(&self) -> Option<&str> {
1364 self.default.as_deref()
1365 }
1366
1367 pub fn default_viewer_from_file(&self) -> Option<&str> {
1370 self.default_viewer.as_deref()
1371 }
1372
1373 pub fn agent_names(&self) -> Vec<String> {
1376 self.agents.keys().cloned().collect()
1377 }
1378
1379 pub fn agent(&self, name: &str) -> Option<&AgentTemplate> {
1383 self.agents.get(name)
1384 }
1385
1386 pub fn resolve(&self, task_override: Option<&str>) -> Result<ResolvedAgent> {
1390 self.resolve_with(task_override, &binary_on_path)
1391 }
1392
1393 fn resolve_with(
1397 &self,
1398 task_override: Option<&str>,
1399 probe: &dyn Fn(&str) -> bool,
1400 ) -> Result<ResolvedAgent> {
1401 let (name, origin) = match task_override {
1402 Some(name) => (name.to_string(), "task agent override"),
1403 None => (self.effective_default(probe)?, "config default"),
1404 };
1405 let agent = self.agents.get(&name).ok_or_else(|| Error::UnknownAgent {
1406 name: name.clone(),
1407 origin,
1408 path: self.path.clone(),
1409 known: self.agents.keys().cloned().collect::<Vec<_>>().join(", "),
1410 })?;
1411 Ok(ResolvedAgent {
1412 name,
1413 dispatch: agent.dispatch().to_string(),
1414 sessions: agent.sessions.clone(),
1415 attach: agent.attach.clone(),
1416 resume: agent.resume.clone(),
1417 message: agent.message.clone(),
1418 logs: agent.logs.clone(),
1419 cap: agent.cap.clone(),
1420 stop: agent.stop.clone(),
1421 plan: agent.plan.clone(),
1422 model: agent.model.clone(),
1423 model_deep: agent.model_deep.clone(),
1424 model_plan: agent.model_plan.clone(),
1425 })
1426 }
1427
1428 fn effective_default(&self, probe: &dyn Fn(&str) -> bool) -> Result<String> {
1433 if let Some(default) = &self.default {
1434 return Ok(default.clone());
1435 }
1436 for candidate in DEFAULT_PROBE_ORDER {
1437 if self.agents.contains_key(candidate) && probe(candidate) {
1438 return Ok(candidate.to_string());
1439 }
1440 }
1441 Err(Error::NoDefaultAgent {
1442 probed: DEFAULT_PROBE_ORDER.join(", "),
1443 path: self.path.clone(),
1444 })
1445 }
1446
1447 pub fn viewer_names(&self) -> Vec<String> {
1452 self.viewers.keys().cloned().collect()
1453 }
1454
1455 pub fn viewer_entries(&self) -> Vec<(&str, &str, Provenance)> {
1461 let mut merged: BTreeMap<&str, (&str, Provenance)> = builtin_viewers()
1462 .iter()
1463 .map(|(name, viewer)| (name.as_str(), (viewer.cmd.as_str(), Provenance::BuiltIn)))
1464 .collect();
1465 for (name, viewer) in &self.viewers {
1466 let prov = if is_builtin_viewer(name) {
1467 Provenance::UserOverride
1468 } else {
1469 Provenance::User
1470 };
1471 merged.insert(name.as_str(), (viewer.cmd.as_str(), prov));
1472 }
1473 merged
1474 .into_iter()
1475 .map(|(name, (cmd, prov))| (name, cmd, prov))
1476 .collect()
1477 }
1478
1479 pub fn anonymous_viewer_cmd(&self) -> Option<&str> {
1483 self.viewer.as_ref().map(|v| v.cmd.as_str())
1484 }
1485
1486 pub fn default_viewer_name(&self) -> Option<String> {
1493 self.default_viewer_name_with(&binary_on_path)
1494 }
1495
1496 fn default_viewer_name_with(&self, probe: &dyn Fn(&str) -> bool) -> Option<String> {
1500 if self.default_viewer.is_some() {
1501 return self.default_viewer.clone();
1502 }
1503 if self.viewer.is_some() {
1504 return None;
1505 }
1506 if self.viewers.len() == 1 {
1507 return self.viewers.keys().next().cloned();
1508 }
1509 probed_builtin_viewer(probe).map(str::to_string)
1510 }
1511
1512 pub fn viewer_cmd(&self, name: Option<&str>) -> Result<&str> {
1518 self.viewer_cmd_with(name, &binary_on_path)
1519 }
1520
1521 fn viewer_cmd_with(&self, name: Option<&str>, probe: &dyn Fn(&str) -> bool) -> Result<&str> {
1525 match name {
1526 Some(name) => self.named_viewer(name),
1527 None => match &self.default_viewer {
1528 Some(default) => self.named_viewer(default),
1529 None => self
1530 .viewer
1531 .as_ref()
1532 .map(|v| v.cmd.as_str())
1533 .or_else(|| match self.viewers.len() {
1534 1 => self.viewers.values().next().map(|v| v.cmd.as_str()),
1535 _ => None,
1536 })
1537 .or_else(|| {
1538 probed_builtin_viewer(probe).and_then(|name| self.named_viewer(name).ok())
1539 })
1540 .ok_or_else(|| Error::NoViewer {
1541 probed: BUILTIN_VIEWER_NAMES.join("/"),
1542 }),
1543 },
1544 }
1545 }
1546
1547 fn named_viewer(&self, name: &str) -> Result<&str> {
1550 self.viewers
1551 .get(name)
1552 .or_else(|| builtin_viewers().get(name))
1553 .map(|v| v.cmd.as_str())
1554 .ok_or_else(|| Error::UnknownViewer {
1555 name: name.to_string(),
1556 known: self
1557 .viewer_entries()
1558 .iter()
1559 .map(|(name, _, _)| *name)
1560 .collect::<Vec<_>>()
1561 .join(", "),
1562 path: self.path.clone(),
1563 })
1564 }
1565
1566 pub fn default_name(&self) -> Option<String> {
1570 self.effective_default(&binary_on_path).ok()
1571 }
1572
1573 pub fn provenance(&self, name: &str) -> Option<Provenance> {
1575 self.provenance.get(name).copied()
1576 }
1577
1578 pub fn override_missing_verbs(&self, name: &str) -> Vec<&'static str> {
1583 if self.provenance.get(name) != Some(&Provenance::UserOverride) {
1584 return Vec::new();
1585 }
1586 let (Some(user), Some(builtin)) = (self.agents.get(name), builtin_agents().get(name))
1587 else {
1588 return Vec::new();
1589 };
1590 OPTIONAL_VERBS
1591 .iter()
1592 .filter(|(_, defined)| defined(builtin).is_some() && defined(user).is_none())
1593 .map(|(verb, _)| *verb)
1594 .collect()
1595 }
1596
1597 pub fn entries(&self) -> impl Iterator<Item = (&str, &AgentTemplate, Provenance)> {
1600 self.agents.iter().map(|(name, agent)| {
1601 let prov = self
1602 .provenance
1603 .get(name)
1604 .copied()
1605 .unwrap_or(Provenance::User);
1606 (name.as_str(), agent, prov)
1607 })
1608 }
1609
1610 pub fn write_starter(path: &Path) -> Result<()> {
1614 if path.exists() {
1615 return Err(Error::Invalid(format!(
1616 "{} already exists; edit it directly rather than reinitialising",
1617 path.display()
1618 )));
1619 }
1620 if let Some(parent) = path.parent() {
1621 std::fs::create_dir_all(parent).map_err(|e| Error::AgentConfigInvalid {
1622 path: path.to_path_buf(),
1623 message: e.to_string(),
1624 })?;
1625 }
1626 std::fs::write(path, starter_config()).map_err(|e| Error::AgentConfigInvalid {
1627 path: path.to_path_buf(),
1628 message: e.to_string(),
1629 })
1630 }
1631}
1632
1633#[derive(Debug, Clone, PartialEq, Eq)]
1640pub struct AgentSessionEntry {
1641 pub session_ref: String,
1642 pub short_id: Option<String>,
1643 pub cwd: Option<String>,
1644 pub started_at_ms: Option<i64>,
1645 pub state: Option<String>,
1646 pub pid: Option<i64>,
1650}
1651
1652#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1657pub enum SessionLiveness {
1658 Live,
1660 WhileProcessLives(i64),
1662 Dead,
1664}
1665
1666impl AgentSessionEntry {
1667 pub fn matches_ref(&self, session_ref: &str) -> bool {
1670 self.session_ref == session_ref || self.short_id.as_deref() == Some(session_ref)
1671 }
1672
1673 pub fn liveness(&self) -> SessionLiveness {
1682 match (self.state.as_deref(), self.pid) {
1683 (Some("done"), _) => SessionLiveness::Dead,
1684 (_, Some(pid)) => SessionLiveness::WhileProcessLives(pid),
1685 (Some("working"), None) => SessionLiveness::Live,
1686 _ => SessionLiveness::Dead,
1687 }
1688 }
1689
1690 pub fn at_rest(&self) -> bool {
1699 self.state.as_deref() == Some("done")
1700 }
1701}
1702
1703pub fn parse_sessions_json(json: &str) -> Result<Vec<AgentSessionEntry>> {
1708 let value: serde_json::Value = serde_json::from_str(json)
1709 .map_err(|e| Error::Invalid(format!("sessions output is not JSON: {e}")))?;
1710 let array = value
1711 .as_array()
1712 .ok_or_else(|| Error::Invalid("sessions output is not a JSON array".into()))?;
1713 let mut entries = Vec::new();
1714 for item in array {
1715 let get_str = |key: &str| item.get(key).and_then(|v| v.as_str()).map(str::to_string);
1716 let Some(session_ref) = get_str("sessionId").or_else(|| get_str("id")) else {
1717 continue;
1718 };
1719 entries.push(AgentSessionEntry {
1720 session_ref,
1721 short_id: get_str("id"),
1722 cwd: get_str("cwd"),
1723 started_at_ms: item.get("startedAt").and_then(|v| v.as_i64()),
1724 state: get_str("state"),
1725 pid: item.get("pid").and_then(|v| v.as_i64()),
1726 });
1727 }
1728 Ok(entries)
1729}
1730
1731#[cfg(test)]
1732mod tests {
1733 use super::*;
1734
1735 const CONFIG: &str = r#"
1736 default_agent = "claude"
1737
1738 [agents.claude]
1739 cmd = "claude -p --output-format stream-json {prompt_file}"
1740
1741 [agents.codex]
1742 cmd = "codex exec {prompt_file}"
1743 "#;
1744
1745 fn config() -> AgentsConfig {
1746 AgentsConfig::parse(CONFIG, Path::new("/tmp/voro.toml")).unwrap()
1747 }
1748
1749 fn parse(text: &str) -> Result<AgentsConfig> {
1750 AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1751 }
1752
1753 fn none_installed(_: &str) -> bool {
1756 false
1757 }
1758
1759 fn only(installed: &'static str) -> impl Fn(&str) -> bool {
1761 move |name| name == installed
1762 }
1763
1764 #[test]
1765 fn absent_costs_and_max_running_take_the_defaults() {
1766 for config in [
1769 config(),
1770 AgentsConfig::builtin_only(Path::new("/tmp/voro.toml")),
1771 ] {
1772 assert_eq!(config.costs(), AttentionCosts::default());
1773 assert_eq!(config.max_running(), DEFAULT_MAX_RUNNING);
1774 }
1775 }
1776
1777 #[test]
1778 fn costs_table_overrides_only_the_actions_it_names() {
1779 let config = parse(
1780 r#"
1781 max_running = 3
1782
1783 [costs]
1784 review = 2.5
1785 do = 4.0
1786 "#,
1787 )
1788 .unwrap();
1789 let costs = config.costs();
1790 assert_eq!(costs.review, 2.5);
1791 assert_eq!(costs.human_do, 4.0);
1792 assert_eq!(costs.answer, AttentionCosts::default().answer);
1794 assert_eq!(costs.triage, AttentionCosts::default().triage);
1795 assert_eq!(costs.dispatch, AttentionCosts::default().dispatch);
1796 assert_eq!(config.max_running(), 3);
1797 }
1798
1799 #[test]
1800 fn a_non_positive_cost_is_refused() {
1801 for text in [
1804 "[costs]\nreview = 0",
1805 "[costs]\nanswer = -1.0",
1806 "[costs]\ntriage = nan",
1807 ] {
1808 let e = parse(text).unwrap_err().to_string();
1809 assert!(e.contains("must be a positive number"), "{text}: {e}");
1810 }
1811 }
1812
1813 #[test]
1814 fn a_negative_max_running_is_refused() {
1815 let e = parse("max_running = -1").unwrap_err().to_string();
1816 assert!(e.contains("cannot be negative"), "{e}");
1817 assert_eq!(parse("max_running = 0").unwrap().max_running(), 0);
1820 }
1821
1822 #[test]
1823 fn an_unknown_cost_key_is_refused_rather_than_ignored() {
1824 let e = parse("[costs]\nredispatch = 1.0").unwrap_err().to_string();
1825 assert!(e.contains("redispatch"), "{e}");
1826 }
1827
1828 #[test]
1829 fn agent_names_lists_every_configured_agent() {
1830 assert_eq!(config().agent_names(), vec!["claude", "codex"]);
1831 }
1832
1833 #[test]
1834 fn resolves_default_when_task_has_no_override() {
1835 let resolved = config().resolve(None).unwrap();
1836 assert_eq!(resolved.name, "claude");
1837 assert_eq!(
1838 resolved.dispatch,
1839 "claude -p --output-format stream-json {prompt_file}"
1840 );
1841 }
1842
1843 #[test]
1844 fn task_override_wins_over_default() {
1845 let resolved = config().resolve(Some("codex")).unwrap();
1846 assert_eq!(resolved.name, "codex");
1847 assert_eq!(resolved.dispatch, "codex exec {prompt_file}");
1848 }
1849
1850 #[test]
1851 fn unknown_override_errors_at_resolution() {
1852 let err = config().resolve(Some("gemini")).unwrap_err();
1853 let message = err.to_string();
1854 assert!(message.contains("gemini"), "{message}");
1855 assert!(message.contains("task agent override"), "{message}");
1856 assert!(message.contains("claude, codex"), "{message}");
1857 }
1858
1859 #[test]
1860 fn unknown_default_errors_at_resolution() {
1861 let text = r#"
1862 default_agent = "gemini"
1863
1864 [agents.claude]
1865 cmd = "claude -p {prompt_file}"
1866 "#;
1867 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1868 let message = config.resolve(None).unwrap_err().to_string();
1869 assert!(message.contains("gemini"), "{message}");
1870 assert!(message.contains("config default"), "{message}");
1871 }
1872
1873 #[test]
1874 fn cmd_without_prompt_file_placeholder_is_rejected() {
1875 let text = r#"
1876 default_agent = "claude"
1877
1878 [agents.claude]
1879 cmd = "claude -p"
1880 "#;
1881 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
1882 .unwrap_err()
1883 .to_string();
1884 assert!(message.contains("{prompt_file}"), "{message}");
1885 assert!(message.contains("claude"), "{message}");
1886 }
1887
1888 #[test]
1889 fn invalid_toml_names_the_file() {
1890 let message = AgentsConfig::parse("default = ", Path::new("/tmp/voro.toml"))
1891 .unwrap_err()
1892 .to_string();
1893 assert!(message.contains("/tmp/voro.toml"), "{message}");
1894 }
1895
1896 #[test]
1897 fn loads_from_disk() {
1898 let path = std::env::temp_dir().join(format!("voro-agents-{}.toml", std::process::id()));
1899 std::fs::write(&path, CONFIG).unwrap();
1900 let config = AgentsConfig::load(&path).unwrap();
1901 std::fs::remove_file(&path).unwrap();
1902 assert_eq!(config.resolve(None).unwrap().name, "claude");
1903 }
1904
1905 #[test]
1906 fn missing_file_loads_the_builtins() {
1907 let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
1908 assert_eq!(config.agent_names(), vec!["claude", "codex"]);
1909 assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
1910 let claude = config.agent("claude").unwrap();
1911 assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
1912 assert!(claude.sessions().is_some());
1913 assert!(claude.attach().is_some());
1914 assert!(claude.resume().is_some());
1915 }
1916
1917 #[test]
1918 fn builtins_layer_under_a_user_file() {
1919 let text = r#"
1920 [agents.mycustom]
1921 dispatch = "mytool {prompt_file}"
1922 "#;
1923 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1924 assert_eq!(config.agent_names(), vec!["claude", "codex", "mycustom"]);
1925 assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
1926 assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
1927 assert_eq!(config.provenance("mycustom"), Some(Provenance::User));
1928 assert!(config.agent("claude").unwrap().sessions().is_some());
1929 }
1930
1931 #[test]
1932 fn a_user_table_overrides_a_builtin_wholesale() {
1933 let text = r#"
1934 [agents.claude]
1935 cmd = "claude -p {prompt_file}"
1936 "#;
1937 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1938 assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
1939 let claude = config.agent("claude").unwrap();
1940 assert_eq!(claude.dispatch(), "claude -p {prompt_file}");
1941 assert_eq!(claude.sessions(), None, "override is not merged per-verb");
1942 assert_eq!(claude.attach(), None);
1943 assert_eq!(config.provenance("codex"), Some(Provenance::BuiltIn));
1944 }
1945
1946 #[test]
1947 fn override_dropping_verbs_is_reported() {
1948 let text = r#"
1949 [agents.claude]
1950 cmd = "claude -p {prompt_file}"
1951 "#;
1952 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1953 let missing = config.override_missing_verbs("claude");
1954 assert!(missing.contains(&"sessions"), "{missing:?}");
1955 assert!(missing.contains(&"attach"), "{missing:?}");
1956 assert!(missing.contains(&"resume"), "{missing:?}");
1957 assert!(config.override_missing_verbs("codex").is_empty());
1958 }
1959
1960 #[test]
1961 fn default_probes_path_when_the_user_sets_none() {
1962 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
1963 let only_codex = |name: &str| name == "codex";
1964 assert_eq!(
1965 config.resolve_with(None, &only_codex).unwrap().name,
1966 "codex"
1967 );
1968 let both = |_: &str| true;
1969 assert_eq!(config.resolve_with(None, &both).unwrap().name, "claude");
1970 }
1971
1972 #[test]
1973 fn no_default_and_nothing_on_path_errors_with_guidance() {
1974 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
1975 let none = |_: &str| false;
1976 let message = config.resolve_with(None, &none).unwrap_err().to_string();
1977 assert!(message.contains("no default agent"), "{message}");
1978 assert!(message.contains("claude, codex"), "{message}");
1979 }
1980
1981 #[test]
1982 fn user_default_is_honoured_over_the_path_probe() {
1983 let text = r#"
1984 default_agent = "codex"
1985 "#;
1986 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
1987 let both = |_: &str| true;
1988 assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
1989 }
1990
1991 const VERBS_CONFIG: &str = r#"
1994 default_agent = "claude"
1995
1996 [agents.claude]
1997 dispatch = "claude --bg \"$(cat {prompt_file})\""
1998 sessions = "claude agents --json"
1999 attach = "claude attach {session}"
2000 resume = "claude --resume {session}"
2001
2002 [agents.codex]
2003 dispatch = "codex exec {prompt_file}"
2004 resume = "codex resume {session}"
2005 "#;
2006
2007 #[test]
2008 fn verbs_parse_and_resolve() {
2009 let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
2010 let claude = config.resolve(None).unwrap();
2011 assert_eq!(claude.sessions.as_deref(), Some("claude agents --json"));
2012 assert_eq!(claude.attach.as_deref(), Some("claude attach {session}"));
2013 assert_eq!(claude.resume.as_deref(), Some("claude --resume {session}"));
2014
2015 let codex = config.resolve(Some("codex")).unwrap();
2016 assert_eq!(codex.sessions, None);
2017 assert_eq!(codex.attach, None);
2018 assert_eq!(codex.resume.as_deref(), Some("codex resume {session}"));
2019 }
2020
2021 #[test]
2022 fn cmd_alias_behaves_as_dispatch_with_every_verb_absent() {
2023 let resolved = config().resolve(None).unwrap();
2024 assert_eq!(
2025 resolved.dispatch,
2026 "claude -p --output-format stream-json {prompt_file}"
2027 );
2028 assert_eq!(resolved.sessions, None);
2029 assert_eq!(resolved.attach, None);
2030 assert_eq!(resolved.resume, None);
2031 }
2032
2033 #[test]
2034 fn both_dispatch_and_cmd_is_rejected() {
2035 let text = r#"
2036 default_agent = "claude"
2037
2038 [agents.claude]
2039 cmd = "claude -p {prompt_file}"
2040 dispatch = "claude --bg {prompt_file}"
2041 "#;
2042 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2043 .unwrap_err()
2044 .to_string();
2045 assert!(message.contains("alias"), "{message}");
2046 }
2047
2048 #[test]
2049 fn agent_without_dispatch_or_cmd_is_rejected() {
2050 let text = r#"
2051 default_agent = "claude"
2052
2053 [agents.claude]
2054 sessions = "claude agents --json"
2055 "#;
2056 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2057 .unwrap_err()
2058 .to_string();
2059 assert!(message.contains("dispatch"), "{message}");
2060 }
2061
2062 #[test]
2063 fn attach_and_resume_require_the_session_placeholder() {
2064 for verb in ["attach", "resume"] {
2065 let text = format!(
2066 "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2067 {verb} = \"reopen {{prompt_file}}\"\n"
2068 );
2069 let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2070 .unwrap_err()
2071 .to_string();
2072 assert!(message.contains("{session}"), "{verb}: {message}");
2073 assert!(message.contains(verb), "{verb}: {message}");
2074 }
2075 }
2076
2077 #[test]
2080 fn plan_parses_resolves_and_is_optional() {
2081 let text = r#"
2082 default_agent = "a"
2083
2084 [agents.a]
2085 dispatch = "run {prompt_file}"
2086 plan = "run --interactive {prompt_file}"
2087
2088 [agents.b]
2089 dispatch = "other {prompt_file}"
2090 "#;
2091 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2092 let a = config.resolve(None).unwrap();
2093 assert_eq!(a.plan.as_deref(), Some("run --interactive {prompt_file}"));
2094 assert_eq!(config.agent("a").unwrap().plan(), a.plan.as_deref());
2095 let b = config.resolve(Some("b")).unwrap();
2097 assert_eq!(b.plan, None);
2098 assert_eq!(config.agent("b").unwrap().plan(), None);
2099 }
2100
2101 #[test]
2102 fn plan_requires_the_prompt_file_placeholder() {
2103 let text = r#"
2104 default_agent = "a"
2105
2106 [agents.a]
2107 dispatch = "run {prompt_file}"
2108 plan = "run --interactive"
2109 "#;
2110 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
2111 .unwrap_err()
2112 .to_string();
2113 assert!(message.contains("{prompt_file}"), "{message}");
2114 assert!(message.contains("plan"), "{message}");
2115 }
2116
2117 #[test]
2118 fn builtin_claude_defines_plan_and_an_override_dropping_it_is_reported() {
2119 let agents = builtin_agents();
2120 let plan = agents["claude"].plan().unwrap();
2121 assert!(plan.contains(PROMPT_FILE_PLACEHOLDER), "{plan}");
2122 assert!(
2123 !plan.contains("--bg"),
2124 "plan runs in the foreground: {plan}"
2125 );
2126 assert!(agents["codex"].plan().is_none());
2127
2128 let text = r#"
2129 [agents.claude]
2130 cmd = "claude -p {prompt_file}"
2131 "#;
2132 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2133 assert!(
2134 config.override_missing_verbs("claude").contains(&"plan"),
2135 "{:?}",
2136 config.override_missing_verbs("claude")
2137 );
2138 }
2139
2140 #[test]
2143 fn message_parses_resolves_and_is_optional() {
2144 let text = r#"
2145 default_agent = "a"
2146
2147 [agents.a]
2148 dispatch = "run {prompt_file}"
2149 message = "say --into {session} {prompt_file}"
2150
2151 [agents.b]
2152 dispatch = "other {prompt_file}"
2153 "#;
2154 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2155 let a = config.resolve(None).unwrap();
2156 assert_eq!(
2157 a.message.as_deref(),
2158 Some("say --into {session} {prompt_file}")
2159 );
2160 assert_eq!(config.agent("a").unwrap().message(), a.message.as_deref());
2161 let b = config.resolve(Some("b")).unwrap();
2162 assert_eq!(b.message, None);
2163 assert_eq!(config.agent("b").unwrap().message(), None);
2164 }
2165
2166 #[test]
2169 fn message_requires_both_the_session_and_prompt_file_placeholders() {
2170 for (template, missing) in [
2171 ("say {prompt_file}", SESSION_PLACEHOLDER),
2172 ("say --into {session}", PROMPT_FILE_PLACEHOLDER),
2173 ] {
2174 let text = format!(
2175 "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2176 message = \"{template}\"\n"
2177 );
2178 let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2179 .unwrap_err()
2180 .to_string();
2181 assert!(e.contains(missing), "{template}: {e}");
2182 assert!(e.contains("message"), "{template}: {e}");
2183 }
2184 }
2185
2186 #[test]
2189 fn message_refuses_the_launch_placeholders() {
2190 for placeholder in [
2191 MODEL_PLACEHOLDER,
2192 SESSION_NAME_PLACEHOLDER,
2193 TASK_ID_PLACEHOLDER,
2194 ] {
2195 let text = format!(
2196 "default_agent = \"a\"\n\n[agents.a]\ndispatch = \"run {{prompt_file}}\"\n\
2197 model = \"m\"\n\
2198 message = \"say --into {{session}} --as {placeholder} {{prompt_file}}\"\n"
2199 );
2200 let e = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
2201 .unwrap_err()
2202 .to_string();
2203 assert!(e.contains(placeholder), "{placeholder}: {e}");
2204 assert!(e.contains("message"), "{placeholder}: {e}");
2205 }
2206 }
2207
2208 #[test]
2209 fn builtin_claude_defines_message_and_an_override_dropping_it_is_reported() {
2210 let agents = builtin_agents();
2211 let message = agents["claude"].message().unwrap();
2212 assert!(message.contains(SESSION_PLACEHOLDER), "{message}");
2213 assert!(message.contains(PROMPT_FILE_PLACEHOLDER), "{message}");
2214 assert!(
2215 message.contains("-p"),
2216 "message is headless, not a terminal round-trip: {message}"
2217 );
2218 assert!(agents["codex"].message().is_none());
2221
2222 let text = r#"
2223 [agents.claude]
2224 cmd = "claude -p {prompt_file}"
2225 "#;
2226 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2227 assert!(
2228 config.override_missing_verbs("claude").contains(&"message"),
2229 "{:?}",
2230 config.override_missing_verbs("claude")
2231 );
2232 }
2233
2234 #[test]
2237 fn verbs_lists_every_optional_verb_and_marks_a_forking_message() {
2238 let agents = builtin_agents();
2239 assert_eq!(
2241 agents["claude"].verbs(),
2242 vec![
2243 "sessions", "attach", "resume", "message", "logs", "cap", "stop", "plan"
2244 ]
2245 );
2246 assert_eq!(agents["codex"].verbs(), vec!["resume"]);
2247
2248 let text = r#"
2251 [agents.a]
2252 dispatch = "run {prompt_file}"
2253 message = "say --into {session} --as {new_session} {prompt_file}"
2254 "#;
2255 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2256 assert_eq!(config.agent("a").unwrap().verbs(), vec!["message(fork)"]);
2257 }
2258
2259 #[test]
2264 fn the_listing_and_the_dropped_verb_warning_cover_the_same_verbs() {
2265 let text = r#"
2266 [agents.claude]
2267 cmd = "claude -p {prompt_file}"
2268 "#;
2269 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2270 let dropped = config.override_missing_verbs("claude");
2271 let listed: Vec<&str> = builtin_agents()["claude"]
2272 .verbs()
2273 .into_iter()
2274 .map(|verb| verb.split('(').next().expect("a verb name"))
2275 .collect();
2276 assert_eq!(dropped, listed);
2277 assert!(config.agent("claude").unwrap().verbs().is_empty());
2278 }
2279
2280 #[test]
2281 fn render_message_binds_both_placeholders_shell_quoted() {
2282 let rendered = render_message(
2283 "claude -p --resume {session} \"$(cat {prompt_file})\"",
2284 "3f6c-1111",
2285 Path::new("/run/msg-1.md"),
2286 );
2287 assert_eq!(
2288 rendered.command,
2289 "claude -p --resume '3f6c-1111' \"$(cat '/run/msg-1.md')\""
2290 );
2291 assert_eq!(rendered.new_session_ref, None);
2293 }
2294
2295 #[test]
2299 fn render_message_binds_a_fresh_reference_for_a_forking_verb() {
2300 let rendered = render_message(
2301 "claude -p --resume {session} --fork-session --session-id {new_session} \
2302 \"$(cat {prompt_file})\"",
2303 "3f6c-1111",
2304 Path::new("/run/msg-1.md"),
2305 );
2306 let new_ref = rendered.new_session_ref.expect("a fresh reference");
2307 assert_ne!(new_ref, "3f6c-1111");
2308 assert_eq!(new_ref.len(), 36, "a v4 uuid: {new_ref}");
2309 assert!(
2310 rendered
2311 .command
2312 .contains(&format!("--session-id '{new_ref}'")),
2313 "{}",
2314 rendered.command
2315 );
2316 let again = render_message(
2318 "claude --session-id {new_session} --resume {session} {prompt_file}",
2319 "3f6c-1111",
2320 Path::new("/run/msg-2.md"),
2321 );
2322 assert_ne!(again.new_session_ref, Some(new_ref));
2323 }
2324
2325 #[test]
2332 fn every_builtin_claude_launch_that_prompts_carries_a_permission_mode() {
2333 let claude = &builtin_agents()["claude"];
2334 for (verb, template) in [
2335 ("dispatch", claude.dispatch()),
2336 ("message", claude.message().expect("claude defines message")),
2337 ("plan", claude.plan().expect("claude defines plan")),
2338 ] {
2339 assert!(
2340 template.contains(PROMPT_FILE_PLACEHOLDER),
2341 "{verb} is meant to be a prompted launch: {template}"
2342 );
2343 assert!(
2344 template.contains("--permission-mode auto"),
2345 "{verb} asks an agent to act, so it cannot stop for an approval \
2346 nobody is there to give: {template}"
2347 );
2348 }
2349 let resume = claude.resume().expect("claude defines resume");
2350 assert!(!resume.contains(PROMPT_FILE_PLACEHOLDER), "{resume}");
2351 assert!(!resume.contains("--permission-mode"), "{resume}");
2352 }
2353
2354 #[test]
2357 fn new_session_is_refused_outside_the_message_verb() {
2358 for (verb, template) in [
2359 ("attach", "join {session} {new_session}"),
2360 ("resume", "reopen {session} {new_session}"),
2361 ("logs", "tail {session} {new_session}"),
2362 ("plan", "plan --session-id {new_session} {prompt_file}"),
2363 ] {
2364 let text = format!(
2365 "[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = \"{template}\"\n"
2366 );
2367 let e = parse(&text).unwrap_err().to_string();
2368 assert!(e.contains("{new_session}"), "{verb}: {e}");
2369 assert!(e.contains(verb), "{verb}: {e}");
2370 }
2371 let e = parse("[agents.a]\ndispatch = \"run --session-id {new_session} {prompt_file}\"\n")
2374 .unwrap_err()
2375 .to_string();
2376 assert!(e.contains("dispatch carries {new_session}"), "{e}");
2377 }
2378
2379 #[test]
2384 fn the_builtin_claude_message_verb_resumes_in_place() {
2385 let message = builtin_agents()["claude"].message().unwrap();
2386 assert!(!message.contains("--fork-session"), "{message}");
2387 assert!(!message.contains(NEW_SESSION_PLACEHOLDER), "{message}");
2388 assert!(message.contains("-p --resume {session}"), "{message}");
2389 let rendered = render_message(message, "uuid-1", Path::new("/tmp/p.txt"));
2392 assert_eq!(rendered.new_session_ref, None);
2393 assert!(
2394 rendered.command.contains("--resume 'uuid-1'"),
2395 "{rendered:?}"
2396 );
2397 }
2398
2399 #[test]
2400 fn render_session_binds_the_reference_shell_quoted() {
2401 assert_eq!(
2402 render_session("claude logs \"$(printf %.8s {session})\"", "3f6c-1111"),
2403 "claude logs \"$(printf %.8s '3f6c-1111')\""
2404 );
2405 }
2406
2407 #[test]
2411 fn only_the_claude_builtin_defines_logs() {
2412 let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2413 let claude = config.agent("claude").expect("the built-in claude");
2414 assert!(claude.logs().expect("a logs verb").contains("claude logs"));
2415 assert!(config.agent("codex").expect("codex").logs().is_none());
2416 }
2417
2418 #[test]
2422 fn logs_is_validated_as_a_session_verb() {
2423 for (logs, expected) in [
2424 ("agent-logs --tail", SESSION_PLACEHOLDER),
2425 ("agent-logs {session} --model {model}", MODEL_PLACEHOLDER),
2426 ("agent-logs {session} --task {task_id}", TASK_ID_PLACEHOLDER),
2427 (
2428 "agent-logs {session} --name {session_name}",
2429 SESSION_NAME_PLACEHOLDER,
2430 ),
2431 ] {
2432 let toml = format!(
2433 "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nlogs = \"{logs}\"\n"
2434 );
2435 let raw: RawConfig = toml::from_str(&toml).unwrap();
2436 let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2437 .expect_err("{logs} is refused");
2438 let message = err.to_string();
2439 assert!(message.contains("logs"), "{message}");
2440 assert!(message.contains(expected), "{message}");
2441 }
2442 }
2443
2444 #[test]
2449 fn only_the_claude_builtin_defines_cap() {
2450 let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2451 let cap = config
2452 .agent("claude")
2453 .expect("the built-in claude")
2454 .cap()
2455 .expect("a cap verb");
2456 assert!(cap.contains("rejected"), "{cap}");
2457 assert!(cap.contains("resetsAt"), "{cap}");
2458 assert!(cap.contains(MODEL_PLACEHOLDER), "{cap}");
2459 assert!(cap.contains("timeout"), "{cap}");
2460 assert!(config.agent("codex").expect("codex").cap().is_none());
2461 }
2462
2463 #[test]
2468 fn cap_names_the_model_and_nothing_else() {
2469 for placeholder in [
2470 SESSION_PLACEHOLDER,
2471 PROMPT_FILE_PLACEHOLDER,
2472 SESSION_NAME_PLACEHOLDER,
2473 TASK_ID_PLACEHOLDER,
2474 NEW_SESSION_PLACEHOLDER,
2475 ] {
2476 let toml = format!(
2477 "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\n\
2478 cap = \"agent-cap {placeholder}\"\n"
2479 );
2480 let raw: RawConfig = toml::from_str(&toml).unwrap();
2481 let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2482 .expect_err("a placeholder on cap is refused");
2483 let message = err.to_string();
2484 assert!(message.contains("cap"), "{message}");
2485 assert!(message.contains(placeholder), "{message}");
2486 }
2487 for cap in ["agent-cap --model {model}", "agent-cap --epoch"] {
2490 let toml = format!(
2491 "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\ncap = \"{cap}\"\n"
2492 );
2493 let raw: RawConfig = toml::from_str(&toml).unwrap();
2494 validate_agent("a", &raw.agents["a"], Path::new("/c.toml")).expect("a valid cap");
2495 }
2496 let toml = "[agents.a]\ndispatch = \"run {prompt_file}\"\ncap = \"agent-cap {model}\"\n";
2498 let raw: RawConfig = toml::from_str(toml).unwrap();
2499 let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2500 .expect_err("{model} with no model key is refused");
2501 assert!(err.to_string().contains("sets no model"), "{err}");
2502 }
2503
2504 #[test]
2508 fn a_cap_asks_on_the_model_its_session_ran() {
2509 let text = "[agents.a]\ndispatch = \"run {prompt_file} --model {model}\"\n\
2510 cap = \"agent-cap --model {model}\"\nmodel = \"workhorse\"\n\
2511 model_deep = \"strongest\"\n";
2512 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2513 let agent = config.agent("a").expect("agent a");
2514 assert_eq!(agent.model_for(false), Some("workhorse"));
2515 assert_eq!(agent.model_for(true), Some("strongest"));
2516 assert_eq!(
2517 render_cap(agent.cap().unwrap(), agent.model_for(true)),
2518 "agent-cap --model strongest"
2519 );
2520 assert_eq!(model_for_depth(Some("only"), None, true), Some("only"));
2523 assert_eq!(render_cap("agent-cap --epoch", None), "agent-cap --epoch");
2524 }
2525
2526 #[test]
2531 fn the_claude_stop_verb_renders_the_short_id() {
2532 let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2533 let stop = config
2534 .agent("claude")
2535 .expect("the built-in claude")
2536 .stop()
2537 .expect("a stop verb");
2538 assert_eq!(
2539 render_session(stop, "3f6c1111-2222-3333-4444-555555555555"),
2540 "claude stop \"$(printf %.8s '3f6c1111-2222-3333-4444-555555555555')\""
2541 );
2542 }
2543
2544 #[test]
2547 fn the_codex_builtin_defines_no_stop() {
2548 let config = AgentsConfig::load(Path::new("/nonexistent/voro.toml")).unwrap();
2549 assert!(config.agent("codex").expect("codex").stop().is_none());
2550 }
2551
2552 #[test]
2556 fn stop_is_validated_as_a_session_verb() {
2557 for (stop, expected) in [
2558 ("agent-stop --all", SESSION_PLACEHOLDER),
2559 ("agent-stop {session} --model {model}", MODEL_PLACEHOLDER),
2560 ("agent-stop {session} --task {task_id}", TASK_ID_PLACEHOLDER),
2561 (
2562 "agent-stop {session} --name {session_name}",
2563 SESSION_NAME_PLACEHOLDER,
2564 ),
2565 (
2566 "agent-stop {session} --into {new_session}",
2567 NEW_SESSION_PLACEHOLDER,
2568 ),
2569 ] {
2570 let toml = format!(
2571 "[agents.a]\ndispatch = \"run {{prompt_file}}\"\nmodel = \"m\"\nstop = \"{stop}\"\n"
2572 );
2573 let raw: RawConfig = toml::from_str(&toml).unwrap();
2574 let err = validate_agent("a", &raw.agents["a"], Path::new("/c.toml"))
2575 .expect_err("{stop} is refused");
2576 let message = err.to_string();
2577 assert!(message.contains("stop"), "{message}");
2578 assert!(message.contains(expected), "{message}");
2579 }
2580 }
2581
2582 #[test]
2586 fn an_override_dropping_stop_is_reported() {
2587 let config = AgentsConfig::parse(
2588 "[agents.claude]\ndispatch = \"claude {prompt_file}\"\n",
2589 Path::new("/c.toml"),
2590 )
2591 .unwrap();
2592 assert!(config.override_missing_verbs("claude").contains(&"stop"));
2593 }
2594
2595 #[test]
2598 fn render_message_does_not_rescan_a_bound_value() {
2599 let rendered = render_message(
2600 "say {session} {prompt_file}",
2601 "{prompt_file}",
2602 Path::new("/run/m.md"),
2603 );
2604 assert_eq!(rendered.command, "say '{prompt_file}' '/run/m.md'");
2605 }
2606
2607 #[test]
2611 fn a_continue_verb_is_now_an_unknown_field() {
2612 let text = r#"
2613 default_agent = "a"
2614
2615 [agents.a]
2616 dispatch = "run {prompt_file}"
2617 continue = "reopen {session} {prompt_file}"
2618 "#;
2619 assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
2620 }
2621
2622 #[test]
2623 fn agent_looks_up_templates_by_name() {
2624 let config = AgentsConfig::parse(VERBS_CONFIG, Path::new("/tmp/voro.toml")).unwrap();
2625 let claude = config.agent("claude").unwrap();
2626 assert_eq!(claude.attach(), Some("claude attach {session}"));
2627 assert_eq!(claude.sessions(), Some("claude agents --json"));
2628 assert!(config.agent("gemini").is_none());
2629 }
2630
2631 #[test]
2632 fn parse_sessions_json_reads_the_listing_shape() {
2633 let json = r#"[
2634 {"pid": 4321, "id": "deadbeef", "cwd": "/tmp/proj", "kind": "background",
2635 "startedAt": 1767950000000, "sessionId": "3f6c0e6e-1111-2222-3333-444455556666",
2636 "name": "t", "status": "idle", "state": "done"},
2637 {"id": "cafebabe", "cwd": "/tmp/other", "startedAt": 1767950001000},
2638 {"pid": 1}
2639 ]"#;
2640 let entries = parse_sessions_json(json).unwrap();
2641 assert_eq!(entries.len(), 2, "the id-less entry is skipped");
2642 assert_eq!(
2643 entries[0].session_ref,
2644 "3f6c0e6e-1111-2222-3333-444455556666"
2645 );
2646 assert_eq!(entries[0].short_id.as_deref(), Some("deadbeef"));
2647 assert_eq!(entries[0].cwd.as_deref(), Some("/tmp/proj"));
2648 assert_eq!(entries[0].started_at_ms, Some(1767950000000));
2649 assert_eq!(entries[0].pid, Some(4321));
2650 assert_eq!(entries[0].liveness(), SessionLiveness::Dead);
2651 assert!(entries[0].matches_ref("deadbeef"), "short id matches too");
2652 assert!(entries[0].matches_ref("3f6c0e6e-1111-2222-3333-444455556666"));
2653
2654 assert_eq!(entries[1].session_ref, "cafebabe", "id is the fallback");
2655 assert_eq!(entries[1].pid, None, "the field is optional");
2656 assert_eq!(
2657 entries[1].liveness(),
2658 SessionLiveness::Dead,
2659 "an entry saying neither state nor pid claims nothing, so it is not live"
2660 );
2661 }
2662
2663 #[test]
2669 fn liveness_takes_done_first_then_pid_then_state() {
2670 let entry = |json: &str| {
2671 let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
2672 parse_sessions_json(&listing).unwrap().remove(0).liveness()
2673 };
2674 assert_eq!(
2677 entry(r#""state": "done", "pid": 4321"#),
2678 SessionLiveness::Dead
2679 );
2680 assert_eq!(entry(r#""state": "done""#), SessionLiveness::Dead);
2681 for state in ["\"state\": \"blocked\", ", "\"state\": \"working\", ", ""] {
2683 assert_eq!(
2684 entry(&format!("{state}\"pid\": 4321")),
2685 SessionLiveness::WhileProcessLives(4321),
2686 "{state}"
2687 );
2688 }
2689 assert_eq!(entry(r#""state": "working""#), SessionLiveness::Live);
2691 assert_eq!(entry(r#""state": "blocked""#), SessionLiveness::Dead);
2692 assert_eq!(entry(r#""state": "idle""#), SessionLiveness::Dead);
2693 }
2694
2695 #[test]
2701 fn at_rest_is_done_alone() {
2702 let entry = |json: &str| {
2703 let listing = format!("[{{\"sessionId\": \"u\", {json}}}]");
2704 parse_sessions_json(&listing).unwrap().remove(0)
2705 };
2706 assert!(entry(r#""state": "done""#).at_rest());
2707 assert!(entry(r#""state": "done", "pid": 4321"#).at_rest());
2709 for json in [
2710 r#""state": "blocked""#,
2711 r#""state": "blocked", "pid": 4321"#,
2712 r#""state": "working""#,
2713 r#""state": "idle""#,
2714 r#""state": "something-new""#,
2715 r#""pid": 4321"#,
2716 r#""cwd": "/tmp""#,
2717 ] {
2718 assert!(!entry(json).at_rest(), "{json}");
2719 }
2720 }
2721
2722 #[test]
2723 fn parse_sessions_json_rejects_non_arrays() {
2724 assert!(parse_sessions_json("{}").is_err());
2725 assert!(parse_sessions_json("not json").is_err());
2726 assert_eq!(parse_sessions_json("[]").unwrap(), vec![]);
2727 }
2728
2729 #[test]
2735 fn viewer_resolution_errors_with_guidance_when_nothing_resolves() {
2736 let message = config()
2737 .viewer_cmd_with(None, &none_installed)
2738 .unwrap_err()
2739 .to_string();
2740 assert!(
2741 message.starts_with("no viewer set up — run `voro viewer add"),
2742 "{message}"
2743 );
2744 assert!(message.contains("'zed {path}'"), "{message}");
2745 let (action, diagnosis) = message.split_once("; ").unwrap();
2747 assert!(diagnosis.contains("code/cursor/zed"), "{message}");
2748 assert!(!action.contains("install"), "{message}");
2749 assert!(!message.contains("invalid"), "{message}");
2750 assert!(config().viewer_names().is_empty());
2751 assert_eq!(config().default_viewer_name_with(&none_installed), None);
2752 }
2753
2754 #[test]
2757 fn a_built_in_viewer_resolves_with_no_viewer_configured() {
2758 let config = config();
2759 assert_eq!(
2760 config.viewer_cmd_with(None, &only("zed")).unwrap(),
2761 "zed {path}"
2762 );
2763 assert_eq!(
2764 config.default_viewer_name_with(&only("zed")).as_deref(),
2765 Some("zed")
2766 );
2767 assert_eq!(
2769 config
2770 .viewer_cmd_with(None, &|name| matches!(name, "cursor" | "zed"))
2771 .unwrap(),
2772 "cursor -n {path}"
2773 );
2774 assert_eq!(config.viewer_cmd(Some("code")).unwrap(), "code -n {path}");
2777 }
2778
2779 #[test]
2781 fn user_viewers_outrank_the_probed_built_in() {
2782 let installed = |_: &str| true;
2783
2784 let sole = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
2785 assert_eq!(
2786 sole.viewer_cmd_with(None, &installed).unwrap(),
2787 "mine {path}"
2788 );
2789 assert_eq!(
2790 sole.default_viewer_name_with(&installed).as_deref(),
2791 Some("mine")
2792 );
2793
2794 let anonymous = parse("[viewer]\ncmd = \"anon {path}\"").unwrap();
2795 assert_eq!(
2796 anonymous.viewer_cmd_with(None, &installed).unwrap(),
2797 "anon {path}"
2798 );
2799 assert_eq!(anonymous.default_viewer_name_with(&installed), None);
2801
2802 let named = parse(
2803 "default_viewer = \"mine\"\n[viewers.mine]\ncmd = \"mine {path}\"\n\
2804 [viewers.other]\ncmd = \"other {path}\"",
2805 )
2806 .unwrap();
2807 assert_eq!(
2808 named.viewer_cmd_with(None, &installed).unwrap(),
2809 "mine {path}"
2810 );
2811 }
2812
2813 #[test]
2817 fn a_user_table_overrides_a_built_in_viewer_wholesale() {
2818 let config = parse("[viewers.code]\ncmd = \"code --wait {path}\"").unwrap();
2819 assert_eq!(
2820 config.viewer_cmd(Some("code")).unwrap(),
2821 "code --wait {path}"
2822 );
2823 assert_eq!(
2825 config.viewer_cmd_with(None, &only("code")).unwrap(),
2826 "code --wait {path}"
2827 );
2828 let entries = config.viewer_entries();
2829 let code = entries.iter().find(|(name, ..)| *name == "code").unwrap();
2830 assert_eq!(code.2, Provenance::UserOverride);
2831 }
2832
2833 #[test]
2834 fn viewer_entries_layer_the_built_ins_under_the_user_tables() {
2835 let config = parse("[viewers.mine]\ncmd = \"mine {path}\"").unwrap();
2836 let entries: Vec<(&str, Provenance)> = config
2837 .viewer_entries()
2838 .into_iter()
2839 .map(|(name, _, prov)| (name, prov))
2840 .collect();
2841 assert_eq!(
2842 entries,
2843 vec![
2844 ("code", Provenance::BuiltIn),
2845 ("cursor", Provenance::BuiltIn),
2846 ("mine", Provenance::User),
2847 ("zed", Provenance::BuiltIn),
2848 ]
2849 );
2850 assert_eq!(config.viewer_names(), vec!["mine"]);
2852 }
2853
2854 #[test]
2855 fn the_anonymous_viewer_table_is_the_default_viewer() {
2856 let text = r#"
2857 default_agent = "claude"
2858
2859 [agents.claude]
2860 cmd = "claude -p {prompt_file}"
2861
2862 [viewer]
2863 cmd = "zed {path}"
2864 "#;
2865 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2866 assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2867 }
2868
2869 #[test]
2870 fn named_viewers_resolve_by_name_and_default_viewer_picks_among_them() {
2871 let text = r#"
2872 default_viewer = "zed"
2873
2874 [viewers.zed]
2875 cmd = "zed {path}"
2876
2877 [viewers.difftool]
2878 cmd = "git difftool -d"
2879 "#;
2880 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2881 assert_eq!(config.viewer_names(), vec!["difftool", "zed"]);
2882 assert_eq!(
2883 config.viewer_cmd(Some("difftool")).unwrap(),
2884 "git difftool -d"
2885 );
2886 assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2887 assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
2888 }
2889
2890 #[test]
2891 fn a_sole_named_viewer_is_the_default_without_being_named() {
2892 let text = r#"
2893 [viewers.zed]
2894 cmd = "zed {path}"
2895 "#;
2896 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2897 assert_eq!(config.viewer_cmd(None).unwrap(), "zed {path}");
2898 assert_eq!(config.default_viewer_name().as_deref(), Some("zed"));
2899 }
2900
2901 #[test]
2905 fn several_named_viewers_without_a_default_fall_through_to_the_built_ins() {
2906 let text = r#"
2907 [viewers.mine]
2908 cmd = "mine {path}"
2909
2910 [viewers.difftool]
2911 cmd = "git difftool -d"
2912 "#;
2913 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2914 assert_eq!(
2915 config.viewer_cmd_with(None, &only("zed")).unwrap(),
2916 "zed {path}"
2917 );
2918 let message = config
2919 .viewer_cmd_with(None, &none_installed)
2920 .unwrap_err()
2921 .to_string();
2922 assert!(message.contains("no viewer set up"), "{message}");
2923 }
2924
2925 #[test]
2926 fn an_unknown_viewer_name_errors_listing_the_known_ones() {
2927 let text = r#"
2928 [viewers.zed]
2929 cmd = "zed {path}"
2930 "#;
2931 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2932 let message = config.viewer_cmd(Some("emacs")).unwrap_err().to_string();
2933 assert!(
2934 message.starts_with("no viewer named 'emacs' — run"),
2935 "{message}"
2936 );
2937 assert!(message.contains("code, cursor, zed"), "{message}");
2939 assert!(!message.contains("invalid"), "{message}");
2940 let text = r#"default_viewer = "gone""#;
2942 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
2943 let message = config.viewer_cmd(None).unwrap_err().to_string();
2944 assert!(message.contains("gone"), "{message}");
2945 }
2946
2947 #[test]
2948 fn starter_config_defines_nothing_and_leaves_the_builtins() {
2949 let config = AgentsConfig::parse(&starter_config(), Path::new("/tmp/voro.toml")).unwrap();
2950 assert_eq!(config.agent_names(), vec!["claude", "codex"]);
2951 assert_eq!(config.provenance("claude"), Some(Provenance::BuiltIn));
2952 assert!(config.viewer_names().is_empty());
2953 assert!(config.viewer_cmd_with(None, &none_installed).is_err());
2954 let claude = config.agent("claude").unwrap();
2955 assert!(claude.dispatch().contains("--bg"), "{}", claude.dispatch());
2956 assert!(
2957 claude.dispatch().contains(SESSION_NAME_PLACEHOLDER),
2958 "{}",
2959 claude.dispatch()
2960 );
2961 assert!(claude.sessions().is_some());
2962 assert!(claude.attach().is_some());
2963 assert!(claude.resume().is_some());
2964 }
2965
2966 #[test]
2967 fn starter_config_reproduces_the_builtins_commented_for_copying() {
2968 let skeleton = starter_config();
2969 for line in BUILTIN_AGENTS
2970 .lines()
2971 .chain(BUILTIN_VIEWERS.lines())
2972 .filter(|l| !l.is_empty())
2973 {
2974 let commented = format!("# {line}");
2975 assert!(
2976 skeleton.contains(&commented),
2977 "skeleton is missing built-in line: {commented}"
2978 );
2979 }
2980 let uncommented: String = BUILTIN_AGENTS
2982 .lines()
2983 .take_while(|l| !l.starts_with("[agents.codex]"))
2984 .collect::<Vec<_>>()
2985 .join("\n");
2986 let config = AgentsConfig::parse(&uncommented, Path::new("/tmp/voro.toml")).unwrap();
2987 assert_eq!(config.provenance("claude"), Some(Provenance::UserOverride));
2988 assert!(config.override_missing_verbs("claude").is_empty());
2989 }
2990
2991 #[test]
2992 fn entries_carry_name_template_and_provenance() {
2993 let config = config();
2995 let entries: Vec<_> = config.entries().collect();
2996 assert_eq!(entries.len(), 2);
2997 assert_eq!(entries[0].0, "claude");
2998 assert_eq!(
2999 entries[0].1.dispatch(),
3000 "claude -p --output-format stream-json {prompt_file}"
3001 );
3002 assert_eq!(entries[0].2, Provenance::UserOverride);
3003 assert_eq!(entries[1].0, "codex");
3004 assert_eq!(entries[1].1.dispatch(), "codex exec {prompt_file}");
3005 assert_eq!(entries[1].2, Provenance::UserOverride);
3006 }
3007
3008 #[test]
3009 fn write_starter_creates_parent_and_refuses_to_clobber() {
3010 let dir = std::env::temp_dir().join(format!("voro-init-{}", std::process::id()));
3011 let path = dir.join("voro/voro.toml");
3012 let _ = std::fs::remove_dir_all(&dir);
3013
3014 AgentsConfig::write_starter(&path).unwrap();
3015 let config = AgentsConfig::load(&path).unwrap();
3016 assert_eq!(config.agent_names(), vec!["claude", "codex"]);
3017
3018 let err = AgentsConfig::write_starter(&path).unwrap_err().to_string();
3019 assert!(err.contains("already exists"), "{err}");
3020
3021 std::fs::remove_dir_all(&dir).unwrap();
3022 }
3023
3024 #[test]
3025 fn builtins_parse_and_validate() {
3026 let agents = builtin_agents();
3027 assert!(agents.contains_key("claude"));
3028 assert!(agents.contains_key("codex"));
3029 assert!(agents["claude"].sessions().is_some());
3030 assert!(agents["codex"].resume().is_some());
3031 }
3032
3033 fn spec(deep: bool) -> LaunchSpec<'static> {
3038 LaunchSpec {
3039 launch: Launch::Dispatch {
3040 task_id: 7,
3041 title: "Widen the strip".into(),
3042 },
3043 prompt_file: Path::new("/tmp/p.md"),
3044 deep,
3045 }
3046 }
3047
3048 #[test]
3049 fn a_launch_names_its_session_and_its_files() {
3050 let dispatch = Launch::Dispatch {
3051 task_id: 42,
3052 title: "Widen the strip".into(),
3053 };
3054 let refine = Launch::Refine { task_id: 42 };
3055 let plan = Launch::Plan {
3056 project: "mote".into(),
3057 };
3058 assert_eq!(dispatch.session_name(), "voro-42-widen-the-strip");
3063 assert_eq!(refine.session_name(), "voro-42-refine");
3064 assert_eq!(plan.session_name(), "voro-plan-mote");
3065 assert_ne!(dispatch.session_name(), refine.session_name());
3066 assert_eq!(dispatch.slug(), "task-42");
3069 assert_eq!(refine.slug(), "refine-42");
3070 assert_eq!(plan.slug(), "plan-mote");
3071 assert_eq!(dispatch.task_id(), Some(42));
3072 assert_eq!(refine.task_id(), Some(42));
3073 assert_eq!(plan.task_id(), None);
3074 }
3075
3076 #[test]
3077 fn a_quick_propose_names_its_project_and_has_no_task() {
3078 let propose = Launch::Propose {
3082 project: "mote".into(),
3083 };
3084 assert_eq!(propose.session_name(), "voro-propose-mote");
3085 assert_eq!(propose.slug(), "propose-mote");
3086 assert_eq!(propose.task_id(), None);
3087 assert_ne!(
3088 propose.session_name(),
3089 Launch::Dispatch {
3090 task_id: 2,
3091 title: "Propose".into(),
3092 }
3093 .session_name()
3094 );
3095 }
3096
3097 #[test]
3101 fn a_dispatch_names_its_session_for_the_task_title() {
3102 let named = |id: i64, title: &str| {
3103 Launch::Dispatch {
3104 task_id: id,
3105 title: title.into(),
3106 }
3107 .session_name()
3108 };
3109 assert_eq!(
3110 named(428, "Deliver quick messages"),
3111 "voro-428-deliver-quick-messages"
3112 );
3113 assert_eq!(
3116 named(1, "Make the score decomposition legible"),
3117 "voro-1-make-the-score"
3118 );
3119 assert_eq!(
3122 named(2, "Internationalisation everywhere"),
3123 "voro-2-internationalisation"
3124 );
3125 for name in [named(7, "Anything at all"), named(7, "")] {
3127 assert!(name.starts_with("voro-7"), "{name}");
3128 }
3129 }
3130
3131 #[test]
3132 fn a_dispatch_slug_survives_punctuation_and_unsanitizable_titles() {
3133 let named = |title: &str| {
3134 Launch::Dispatch {
3135 task_id: 9,
3136 title: title.into(),
3137 }
3138 .session_name()
3139 };
3140 assert_eq!(named("It's \"fine\"; rm -rf /"), "voro-9-it-s-fine-rm-rf");
3144 assert_eq!(named("Fix voro-core_v1.2"), "voro-9-fix-voro-core_v1.2");
3145 assert_eq!(named("ODM handoff"), "voro-9-odm-handoff");
3147 assert_eq!(named(""), "voro-9");
3150 assert_eq!(named(" "), "voro-9");
3151 assert_eq!(named("!!! ???"), "voro-9");
3152 assert_eq!(named("日本語"), "voro-9");
3153 }
3154
3155 #[test]
3157 fn a_dispatch_cannot_slug_onto_a_kind_suffix() {
3158 let named = |title: &str| {
3159 Launch::Dispatch {
3160 task_id: 42,
3161 title: title.into(),
3162 }
3163 .session_name()
3164 };
3165 let refine = Launch::Refine { task_id: 42 }.session_name();
3166 assert_eq!(named("Refine the rewrite"), "voro-42-refine-the-rewrite");
3168 assert_eq!(
3171 named("Refine internationalisation"),
3172 "voro-42-refine-internationalisation"
3173 );
3174 assert_eq!(
3175 named("Refine"),
3176 "voro-42",
3177 "a one-word title has no next word to extend with"
3178 );
3179 for title in ["Refine the rewrite", "Refine", "refine!", " refine "] {
3180 assert_ne!(named(title), refine, "collided on {title}");
3181 }
3182 }
3183
3184 #[test]
3185 fn a_task_less_launch_sanitizes_its_project_name() {
3186 let plan = |name: &str| Launch::Plan {
3191 project: name.to_string(),
3192 };
3193 assert_eq!(plan("odm 2").session_name(), "voro-plan-odm-2");
3194 assert_eq!(plan("odm 2").slug(), "plan-odm-2");
3195 assert_eq!(plan("ODM").session_name(), "voro-plan-ODM");
3196 assert_eq!(
3197 plan("it's \"fine\"; rm -rf /").session_name(),
3198 "voro-plan-it-s--fine---rm--rf--"
3199 );
3200 assert_eq!(plan("a/b").slug(), "plan-a-b");
3201 assert_eq!(plan("voro-core_v1.2").slug(), "plan-voro-core_v1.2");
3203
3204 let propose = |name: &str| Launch::Propose {
3207 project: name.to_string(),
3208 };
3209 assert_eq!(propose("odm 2").session_name(), "voro-propose-odm-2");
3210 assert_eq!(propose("odm 2").slug(), "propose-odm-2");
3211 assert_eq!(propose("ODM").session_name(), "voro-propose-ODM");
3212 assert_eq!(
3213 propose("it's \"fine\"; rm -rf /").session_name(),
3214 "voro-propose-it-s--fine---rm--rf--"
3215 );
3216 assert_eq!(propose("a/b").slug(), "propose-a-b");
3217 assert_eq!(propose("voro-core_v1.2").slug(), "propose-voro-core_v1.2");
3218 }
3219
3220 #[test]
3221 fn builtin_claude_names_the_session_from_the_launch() {
3222 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3223 let claude = config.resolve(Some("claude")).unwrap();
3224 let dispatch = claude.launch_command(&spec(false));
3225 assert!(
3226 dispatch.contains("--name \"voro-7-widen-the-strip\""),
3227 "{dispatch}"
3228 );
3229
3230 let refined = claude.launch_command(&LaunchSpec {
3231 launch: Launch::Refine { task_id: 7 },
3232 ..spec(false)
3233 });
3234 assert!(refined.contains("--name \"voro-7-refine\""), "{refined}");
3235 assert_ne!(dispatch, refined);
3236
3237 let planned = claude
3240 .plan_launch_command(&LaunchSpec {
3241 launch: Launch::Plan {
3242 project: "mote".into(),
3243 },
3244 ..spec(false)
3245 })
3246 .unwrap();
3247 assert!(planned.contains("--name \"voro-plan-mote\""), "{planned}");
3248 assert!(!planned.contains("--bg"), "{planned}");
3249
3250 for rendered in [dispatch, refined, planned] {
3252 assert!(!rendered.contains('{'), "unsubstituted: {rendered}");
3253 }
3254 }
3255
3256 #[test]
3257 fn the_prompt_file_is_shell_quoted_into_the_command() {
3258 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3259 let claude = config.resolve(Some("claude")).unwrap();
3260 let rendered = claude.launch_command(&LaunchSpec {
3261 prompt_file: Path::new("/tmp/a dir/p.md"),
3262 ..spec(false)
3263 });
3264 assert!(rendered.contains("cat '/tmp/a dir/p.md'"), "{rendered}");
3265 }
3266
3267 #[test]
3268 fn builtin_claude_renders_a_model_per_purpose_and_depth() {
3269 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3270 let claude = config.resolve(Some("claude")).unwrap();
3271 assert!(
3275 claude.launch_command(&spec(false)).contains("--model opus"),
3276 "{}",
3277 claude.launch_command(&spec(false))
3278 );
3279 assert!(
3280 claude.launch_command(&spec(true)).contains("--model fable"),
3281 "{}",
3282 claude.launch_command(&spec(true))
3283 );
3284 let planned = claude.plan_launch_command(&spec(false)).unwrap();
3285 assert!(planned.contains("--model fable"), "{planned}");
3286 for rendered in [
3287 claude.launch_command(&spec(false)),
3288 claude.launch_command(&spec(true)),
3289 planned,
3290 ] {
3291 assert!(
3292 !rendered.contains(MODEL_PLACEHOLDER),
3293 "placeholder left unresolved: {rendered}"
3294 );
3295 }
3296 }
3297
3298 #[test]
3299 fn an_agent_without_the_placeholder_ignores_depth_entirely() {
3300 let config = AgentsConfig::builtin_only(Path::new("/tmp/voro.toml"));
3301 let codex = config.resolve(Some("codex")).unwrap();
3302 assert_eq!(
3303 codex.launch_command(&spec(true)),
3304 codex.launch_command(&spec(false))
3305 );
3306 assert_eq!(
3307 codex.launch_command(&spec(true)),
3308 "codex exec \"$(cat '/tmp/p.md')\""
3309 );
3310 assert_eq!(codex.plan_launch_command(&spec(false)), None);
3311 }
3312
3313 #[test]
3314 fn model_deep_and_model_plan_fall_back_to_model() {
3315 let text = r#"
3316 [agents.a]
3317 dispatch = "run --model {model} {prompt_file}"
3318 plan = "run -i --model {model} {prompt_file}"
3319 model = "workhorse"
3320 "#;
3321 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3322 let a = config.resolve(Some("a")).unwrap();
3323 assert_eq!(
3324 a.launch_command(&spec(false)),
3325 "run --model workhorse '/tmp/p.md'"
3326 );
3327 assert_eq!(
3328 a.launch_command(&spec(true)),
3329 "run --model workhorse '/tmp/p.md'"
3330 );
3331 assert_eq!(
3332 a.plan_launch_command(&spec(false)).unwrap(),
3333 "run -i --model workhorse '/tmp/p.md'"
3334 );
3335 }
3336
3337 #[test]
3338 fn the_placeholder_without_a_model_key_is_a_config_error() {
3339 let text = r#"
3340 [agents.a]
3341 dispatch = "run --model {model} {prompt_file}"
3342 "#;
3343 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3344 .unwrap_err()
3345 .to_string();
3346 assert!(message.contains("{model}"), "{message}");
3347 assert!(message.contains("model = "), "{message}");
3348 assert!(message.contains("'a'"), "{message}");
3349
3350 let text = r#"
3352 [agents.a]
3353 dispatch = "run {prompt_file}"
3354 plan = "run -i --model {model} {prompt_file}"
3355 "#;
3356 assert!(AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).is_err());
3357 }
3358
3359 #[test]
3362 fn model_keys_without_the_placeholder_are_inert_not_an_error() {
3363 let text = r#"
3364 [agents.claude]
3365 dispatch = "claude -p {prompt_file}"
3366 model = "opus"
3367 model_deep = "fable"
3368 "#;
3369 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3370 let claude = config.resolve(Some("claude")).unwrap();
3371 assert_eq!(claude.launch_command(&spec(true)), "claude -p '/tmp/p.md'");
3372 assert_eq!(claude.launch_command(&spec(false)), "claude -p '/tmp/p.md'");
3373 }
3374
3375 #[test]
3376 fn a_session_verb_carrying_the_placeholder_is_rejected() {
3377 let text = r#"
3378 [agents.a]
3379 dispatch = "run {prompt_file}"
3380 attach = "reopen --model {model} {session}"
3381 model = "workhorse"
3382 "#;
3383 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3384 .unwrap_err()
3385 .to_string();
3386 assert!(message.contains("attach"), "{message}");
3387 assert!(message.contains("{model}"), "{message}");
3388 }
3389
3390 #[test]
3394 fn launch_placeholders_are_refused_on_the_verbs_that_cannot_bind_them() {
3395 for (verb, template) in [
3396 ("sessions", "list --name {session_name}"),
3397 ("attach", "reopen --name {session_name} {session}"),
3398 ("resume", "reopen {session} --for {task_id}"),
3399 ] {
3400 let text =
3401 format!("[agents.a]\ndispatch = \"run {{prompt_file}}\"\n{verb} = '{template}'\n");
3402 let message = AgentsConfig::parse(&text, Path::new("/tmp/voro.toml"))
3403 .unwrap_err()
3404 .to_string();
3405 assert!(message.contains(verb), "{verb}: {message}");
3406 assert!(message.contains("dispatch and plan"), "{verb}: {message}");
3407 }
3408
3409 let text = r#"
3412 [agents.a]
3413 dispatch = "run {prompt_file}"
3414 plan = "run -i --name \"voro-{task_id}\" {prompt_file}"
3415 "#;
3416 let message = AgentsConfig::parse(text, Path::new("/tmp/voro.toml"))
3417 .unwrap_err()
3418 .to_string();
3419 assert!(message.contains("plan"), "{message}");
3420 assert!(message.contains(TASK_ID_PLACEHOLDER), "{message}");
3421 assert!(message.contains(SESSION_NAME_PLACEHOLDER), "{message}");
3422 }
3423
3424 #[test]
3425 fn the_session_name_placeholder_is_accepted_on_dispatch_and_plan() {
3426 let text = r#"
3427 [agents.a]
3428 dispatch = "run --name {session_name} --for {task_id} {prompt_file}"
3429 plan = "run -i --name {session_name} {prompt_file}"
3430 "#;
3431 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3432 let a = config.resolve(Some("a")).unwrap();
3433 assert_eq!(
3434 a.launch_command(&spec(false)),
3435 "run --name voro-7-widen-the-strip --for 7 '/tmp/p.md'"
3436 );
3437 assert_eq!(
3438 a.plan_launch_command(&LaunchSpec {
3439 launch: Launch::Plan {
3440 project: "mote".into(),
3441 },
3442 ..spec(false)
3443 })
3444 .unwrap(),
3445 "run -i --name voro-plan-mote '/tmp/p.md'"
3446 );
3447 }
3448
3449 #[test]
3454 fn a_sessions_verb_makes_a_launch_listing_authoritative() {
3455 let text = r#"
3456 [agents.supervised]
3457 dispatch = "run --bg {prompt_file}"
3458 sessions = "run sessions --json"
3459
3460 [agents.plain]
3461 dispatch = "run {prompt_file}"
3462 "#;
3463 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3464 assert_eq!(
3465 config
3466 .resolve(Some("supervised"))
3467 .unwrap()
3468 .dispatch_liveness_source(),
3469 LivenessSource::Listing
3470 );
3471 assert_eq!(
3472 config
3473 .resolve(Some("plain"))
3474 .unwrap()
3475 .dispatch_liveness_source(),
3476 LivenessSource::Pid
3477 );
3478 }
3479
3480 #[test]
3481 fn default_agent_key_sets_the_default() {
3482 let text = r#"
3483 default_agent = "codex"
3484 "#;
3485 let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
3486 let both = |_: &str| true;
3487 assert_eq!(config.resolve_with(None, &both).unwrap().name, "codex");
3488 }
3489}