Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 94 additions & 16 deletions crates/voro-core/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -464,8 +464,46 @@ impl AgentTemplate {
pub fn model_plan(&self) -> Option<&str> {
self.model_plan.as_deref()
}

/// The optional verbs this agent defines, in roster order, as `agent list`
/// and the Config screen name them (DESIGN.md §8). A `message` that carries
/// [`NEW_SESSION_PLACEHOLDER`] reads `message(fork)`, because forking is
/// what a send into a supervisor-held session needs and it moves the
/// session reference the row afterwards addresses.
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()
}
}

/// A verb's name beside the accessor for its template.
type VerbAccessor = (&'static str, fn(&AgentTemplate) -> Option<&str>);

/// Every verb an agent may define beyond `dispatch`, in the order they are
/// listed to the operator. One roster serves both the positive listing and the
/// dropped-verb warning under it, so the two lines cannot disagree about the
/// same agent.
const OPTIONAL_VERBS: [VerbAccessor; 7] = [
("sessions", AgentTemplate::sessions),
("attach", AgentTemplate::attach),
("resume", AgentTemplate::resume),
("message", AgentTemplate::message),
("logs", AgentTemplate::logs),
("stop", AgentTemplate::stop),
("plan", AgentTemplate::plan),
];

/// What a launch *is* (DESIGN.md §8): the one place a backgrounded or
/// foreground agent session's identity is composed. A launch names its session,
/// its prompt and log files, and its line in the launch log from this single
Expand Down Expand Up @@ -1400,22 +1438,11 @@ impl AgentsConfig {
else {
return Vec::new();
};
[
(
"sessions",
builtin.sessions.is_some(),
user.sessions.is_some(),
),
("attach", builtin.attach.is_some(), user.attach.is_some()),
("resume", builtin.resume.is_some(), user.resume.is_some()),
("message", builtin.message.is_some(), user.message.is_some()),
("logs", builtin.logs.is_some(), user.logs.is_some()),
("stop", builtin.stop.is_some(), user.stop.is_some()),
("plan", builtin.plan.is_some(), user.plan.is_some()),
]
.into_iter()
.filter_map(|(verb, in_builtin, in_user)| (in_builtin && !in_user).then_some(verb))
.collect()
OPTIONAL_VERBS
.iter()
.filter(|(_, defined)| defined(builtin).is_some() && defined(user).is_none())
.map(|(verb, _)| *verb)
.collect()
}

/// Every agent as `(name, template, provenance)`, sorted by name, for
Expand Down Expand Up @@ -2043,6 +2070,57 @@ mod tests {
);
}

/// The listing and the dropped-verb warning read the same roster, so an
/// agent cannot be listed as lacking a verb the warning says it dropped.
#[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(fork)",
"logs",
"stop",
"plan"
]
);
assert_eq!(agents["codex"].verbs(), vec!["resume"]);

// A message that resumes in place keeps the reference it had, so it is
// named plainly; the roster still lists it.
let text = r#"
[agents.a]
dispatch = "run {prompt_file}"
message = "say --into {session} {prompt_file}"
"#;
let config = AgentsConfig::parse(text, Path::new("/tmp/voro.toml")).unwrap();
assert_eq!(config.agent("a").unwrap().verbs(), vec!["message"]);
}

/// Every verb the warning can name is a verb the listing can name, which is
/// the invariant that kept the two lines disagreeing before they shared a
/// roster: a wholesale override of claude that drops everything reports the
/// same set the built-in row lists.
#[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(
Expand Down
10 changes: 1 addition & 9 deletions crates/voro/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -910,15 +910,7 @@ impl App {
self.config_agents = config
.entries()
.map(|(name, template, provenance)| {
let verbs = [
("sessions", template.sessions()),
("attach", template.attach()),
("resume", template.resume()),
("plan", template.plan()),
]
.into_iter()
.filter_map(|(verb, defined)| defined.map(|_| verb))
.collect();
let verbs = template.verbs();
ConfigAgentRow {
name: name.to_string(),
dispatch: template.dispatch().to_string(),
Expand Down
16 changes: 7 additions & 9 deletions crates/voro/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,15 +1306,7 @@ fn agent_verb(cmd: AgentCmd, ctx: &DispatchCtx) -> Result<String, String> {
} else {
" "
};
let verbs: Vec<&str> = [
("sessions", template.sessions()),
("attach", template.attach()),
("resume", template.resume()),
("plan", template.plan()),
]
.into_iter()
.filter_map(|(verb, defined)| defined.map(|_| verb))
.collect();
let verbs = template.verbs();
let suffix = if verbs.is_empty() {
String::new()
} else {
Expand Down Expand Up @@ -2589,6 +2581,12 @@ mod tests {
assert!(listed.contains("claude"), "{listed}");
assert!(listed.contains("codex"), "{listed}");
assert!(listed.contains("built-in"), "{listed}");
// every optional verb the agent defines, the quick message included and
// marked as the forking send it is
assert!(
listed.contains("[sessions attach resume message(fork) logs stop plan]"),
"{listed}"
);

// init writes an optional skeleton
let out = call(&mut s, &["agent", "init"]).unwrap();
Expand Down
Loading