feat: add enabled_macros config field at global, role, agent, and session levels

Mirrors the enabled_skills plumbing per plans/custom-commands-design.md §5:
- global: Config + AppConfig structs, from_config copy, and the
  COYOTE_ENABLED_MACROS env-override arm (csv_to_vec parsing)
- role: frontmatter via parse_string_or_array (list or csv string),
  plus the export() mirror so Role::save round-trips the field
- agent (non-graph): plain serde on AgentConfig; graph.yaml silently
  ignores the key (pinned by test, no field on Graph by design)
- session: plain serde with csv-or-vec deserializer

Empty list/string deserializes to Some([]) (explicit zero), distinct
from absent/null (None) — pinned by tests at every level, including
the env arm (serial-fenced against the from_config tests, which read
the process env via load_envs).
This commit is contained in:
2026-08-21 11:55:13 -06:00
parent e8b55bba15
commit f39381aa9d
6 changed files with 250 additions and 0 deletions
+26
View File
@@ -744,6 +744,8 @@ pub struct AgentConfig {
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub enabled_skills: Option<Vec<String>>, pub enabled_skills: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub enabled_macros: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub continuation_prompt: Option<String>, pub continuation_prompt: Option<String>,
#[serde(default)] #[serde(default)]
pub instructions: String, pub instructions: String,
@@ -1225,6 +1227,30 @@ variables:
assert!(config.top_p.is_none()); assert!(config.top_p.is_none());
} }
#[test]
fn agent_config_enabled_macros_absent_is_none() {
let yaml = "name: minimal\ninstructions: hi\n";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.enabled_macros, None);
}
#[test]
fn agent_config_enabled_macros_empty_list_is_some_empty() {
let yaml = "name: minimal\ninstructions: hi\nenabled_macros: []\n";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.enabled_macros, Some(vec![]));
}
#[test]
fn agent_config_enabled_macros_list() {
let yaml = "name: minimal\ninstructions: hi\nenabled_macros:\n - a\n";
let config: AgentConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.enabled_macros, Some(vec!["a".to_string()]));
}
#[test] #[test]
fn agent_config_with_model() { fn agent_config_with_model() {
let yaml = let yaml =
+72
View File
@@ -43,6 +43,8 @@ pub struct AppConfig {
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")] #[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
pub enabled_skills: Option<Vec<String>>, pub enabled_skills: Option<Vec<String>>,
pub visible_skills: Option<Vec<String>>, pub visible_skills: Option<Vec<String>>,
#[serde(default, deserialize_with = "super::deserialize_csv_or_vec")]
pub enabled_macros: Option<Vec<String>>,
pub mcp_server_support: bool, pub mcp_server_support: bool,
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
@@ -127,6 +129,7 @@ impl Default for AppConfig {
skills_enabled: true, skills_enabled: true,
enabled_skills: None, enabled_skills: None,
visible_skills: None, visible_skills: None,
enabled_macros: None,
mcp_server_support: true, mcp_server_support: true,
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
@@ -211,6 +214,7 @@ impl AppConfig {
skills_enabled: config.skills_enabled, skills_enabled: config.skills_enabled,
enabled_skills: config.enabled_skills, enabled_skills: config.enabled_skills,
visible_skills: config.visible_skills, visible_skills: config.visible_skills,
enabled_macros: config.enabled_macros,
mcp_server_support: config.mcp_server_support, mcp_server_support: config.mcp_server_support,
mapping_mcp_servers: config.mapping_mcp_servers, mapping_mcp_servers: config.mapping_mcp_servers,
@@ -533,6 +537,10 @@ impl AppConfig {
self.enabled_skills = v.map(|raw| super::csv_to_vec(&raw)); self.enabled_skills = v.map(|raw| super::csv_to_vec(&raw));
} }
if let Some(v) = super::read_env_value::<String>(&get_env_name("enabled_macros")) {
self.enabled_macros = v.map(|raw| super::csv_to_vec(&raw));
}
if let Some(Some(v)) = super::read_env_bool(&get_env_name("mcp_server_support")) { if let Some(Some(v)) = super::read_env_bool(&get_env_name("mcp_server_support")) {
self.mcp_server_support = v; self.mcp_server_support = v;
} }
@@ -769,6 +777,70 @@ mod tests {
); );
} }
#[test]
#[serial_test::serial]
fn from_config_copies_enabled_macros() {
let cfg = Config {
model_id: "provider:test".to_string(),
enabled_macros: Some(vec!["a".to_string()]),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.enabled_macros, Some(vec!["a".to_string()]));
}
#[test]
#[serial_test::serial]
fn from_config_preserves_explicit_empty_enabled_macros() {
let cfg = Config {
model_id: "provider:test".to_string(),
enabled_macros: Some(vec![]),
..Config::default()
};
let app = AppConfig::from_config(cfg).unwrap();
assert_eq!(app.enabled_macros, Some(vec![]));
}
#[test]
#[serial_test::serial]
fn load_envs_overrides_enabled_macros() {
let env_name = get_env_name("enabled_macros");
let prev = std::env::var_os(&env_name);
let mut app = AppConfig::default();
unsafe { std::env::set_var(&env_name, "a,b") };
app.load_envs();
assert_eq!(
app.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
unsafe { std::env::set_var(&env_name, "") };
app.load_envs();
assert_eq!(app.enabled_macros, Some(vec![]));
unsafe { std::env::set_var(&env_name, "null") };
app.load_envs();
assert_eq!(app.enabled_macros, None);
unsafe { std::env::remove_var(&env_name) };
app.enabled_macros = Some(vec!["keep".to_string()]);
app.load_envs();
assert_eq!(app.enabled_macros, Some(vec!["keep".to_string()]));
unsafe {
match prev {
Some(v) => std::env::set_var(&env_name, v),
None => std::env::remove_var(&env_name),
}
}
}
#[test] #[test]
fn editor_returns_configured_value() { fn editor_returns_configured_value() {
let configured = cached_editor() let configured = cached_editor()
+39
View File
@@ -221,6 +221,8 @@ pub struct Config {
#[serde(default, deserialize_with = "deserialize_csv_or_vec")] #[serde(default, deserialize_with = "deserialize_csv_or_vec")]
pub enabled_skills: Option<Vec<String>>, pub enabled_skills: Option<Vec<String>>,
pub visible_skills: Option<Vec<String>>, pub visible_skills: Option<Vec<String>>,
#[serde(default, deserialize_with = "deserialize_csv_or_vec")]
pub enabled_macros: Option<Vec<String>>,
pub mcp_server_support: bool, pub mcp_server_support: bool,
pub mapping_mcp_servers: IndexMap<String, String>, pub mapping_mcp_servers: IndexMap<String, String>,
@@ -303,6 +305,7 @@ impl Default for Config {
skills_enabled: true, skills_enabled: true,
enabled_skills: None, enabled_skills: None,
visible_skills: None, visible_skills: None,
enabled_macros: None,
mcp_server_support: true, mcp_server_support: true,
mapping_mcp_servers: Default::default(), mapping_mcp_servers: Default::default(),
@@ -1124,6 +1127,42 @@ clients:
assert!(cfg.enabled_mcp_servers.is_none()); assert!(cfg.enabled_mcp_servers.is_none());
} }
#[test]
fn config_enabled_macros_absent_is_none() {
let cfg: Config = serde_yaml::from_str("model: provider:test").unwrap();
assert_eq!(cfg.enabled_macros, None);
}
#[test]
fn config_enabled_macros_empty_string_is_some_empty() {
let cfg: Config = serde_yaml::from_str("enabled_macros: \"\"").unwrap();
assert_eq!(cfg.enabled_macros, Some(vec![]));
}
#[test]
fn config_enabled_macros_csv_string() {
let cfg: Config = serde_yaml::from_str("enabled_macros: \"a, b\"").unwrap();
assert_eq!(
cfg.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn config_enabled_macros_list() {
let cfg: Config = serde_yaml::from_str("enabled_macros:\n - a\n - b").unwrap();
assert_eq!(
cfg.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn config_enabled_macros_null_is_none() {
let cfg: Config = serde_yaml::from_str("enabled_macros: null").unwrap();
assert_eq!(cfg.enabled_macros, None);
}
#[test] #[test]
fn assert_state_pass_always_true() { fn assert_state_pass_always_true() {
let pass = AssertState::pass(); let pass = AssertState::pass();
+60
View File
@@ -75,6 +75,12 @@ pub struct Role {
deserialize_with = "super::deserialize_csv_or_vec" deserialize_with = "super::deserialize_csv_or_vec"
)] )]
enabled_skills: Option<Vec<String>>, enabled_skills: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::deserialize_csv_or_vec"
)]
enabled_macros: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
auto_continue: Option<bool>, auto_continue: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -129,6 +135,7 @@ impl Role {
} }
"skills_enabled" => role.skills_enabled = value.as_bool(), "skills_enabled" => role.skills_enabled = value.as_bool(),
"enabled_skills" => role.enabled_skills = parse_string_or_array(value), "enabled_skills" => role.enabled_skills = parse_string_or_array(value),
"enabled_macros" => role.enabled_macros = parse_string_or_array(value),
"auto_continue" => role.auto_continue = value.as_bool(), "auto_continue" => role.auto_continue = value.as_bool(),
"max_auto_continues" => { "max_auto_continues" => {
role.max_auto_continues = value.as_u64().map(|v| v as usize) role.max_auto_continues = value.as_u64().map(|v| v as usize)
@@ -196,6 +203,10 @@ impl Role {
let inline = serde_json::to_string(enabled_skills).unwrap_or_else(|_| "[]".to_string()); let inline = serde_json::to_string(enabled_skills).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_skills: {inline}")); metadata.push(format!("enabled_skills: {inline}"));
} }
if let Some(enabled_macros) = &self.enabled_macros {
let inline = serde_json::to_string(enabled_macros).unwrap_or_else(|_| "[]".to_string());
metadata.push(format!("enabled_macros: {inline}"));
}
if let Some(auto_continue) = self.auto_continue { if let Some(auto_continue) = self.auto_continue {
metadata.push(format!("auto_continue: {auto_continue}")); metadata.push(format!("auto_continue: {auto_continue}"));
} }
@@ -588,6 +599,55 @@ mod tests {
assert_eq!(role.enabled_mcp_servers(), None); assert_eq!(role.enabled_mcp_servers(), None);
} }
#[test]
fn role_new_enabled_macros_absent_is_none() {
let role = Role::new("test", "---\ntemperature: 0.5\n---\nPrompt");
assert_eq!(role.enabled_macros, None);
}
#[test]
fn role_new_enabled_macros_empty_string_is_some_empty() {
let role = Role::new("test", "---\nenabled_macros: \"\"\n---\nPrompt");
assert_eq!(role.enabled_macros, Some(vec![]));
}
#[test]
fn role_new_enabled_macros_csv_string() {
let role = Role::new("test", "---\nenabled_macros: a, b\n---\nPrompt");
assert_eq!(
role.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn role_new_enabled_macros_list() {
let role = Role::new("test", "---\nenabled_macros: [a, b]\n---\nPrompt");
assert_eq!(
role.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn role_new_enabled_macros_null_is_none() {
let role = Role::new("test", "---\nenabled_macros: null\n---\nPrompt");
assert_eq!(role.enabled_macros, None);
}
#[test]
fn role_export_includes_enabled_macros() {
let role = Role::new("test", "---\nenabled_macros: [a]\n---\nPrompt");
let exported = role.export();
assert!(exported.contains("enabled_macros: [\"a\"]"));
}
#[test]
fn role_export_omits_enabled_macros_when_none() {
let role = Role::new("test", "Just a prompt");
assert!(!role.export().contains("enabled_macros"));
}
#[test] #[test]
fn role_builtin_shell_loads() { fn role_builtin_shell_loads() {
let role = Role::builtin("shell").unwrap(); let role = Role::builtin("shell").unwrap();
+45
View File
@@ -46,6 +46,12 @@ pub struct Session {
deserialize_with = "super::deserialize_csv_or_vec" deserialize_with = "super::deserialize_csv_or_vec"
)] )]
enabled_skills: Option<Vec<String>>, enabled_skills: Option<Vec<String>>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "super::deserialize_csv_or_vec"
)]
enabled_macros: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
save_session: Option<bool>, save_session: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -931,6 +937,45 @@ mod tests {
assert!(!session.dirty()); assert!(!session.dirty());
} }
#[test]
fn session_enabled_macros_absent_is_none() {
let session: Session = serde_yaml::from_str("model: provider:test\nmessages: []").unwrap();
assert_eq!(session.enabled_macros, None);
}
#[test]
fn session_enabled_macros_empty_list_is_some_empty() {
let session: Session =
serde_yaml::from_str("model: provider:test\nenabled_macros: []\nmessages: []").unwrap();
assert_eq!(session.enabled_macros, Some(vec![]));
}
#[test]
fn session_enabled_macros_empty_string_is_some_empty() {
let session: Session =
serde_yaml::from_str("model: provider:test\nenabled_macros: \"\"\nmessages: []")
.unwrap();
assert_eq!(session.enabled_macros, Some(vec![]));
}
#[test]
fn session_enabled_macros_csv_string() {
let session: Session =
serde_yaml::from_str("model: provider:test\nenabled_macros: \"a,b\"\nmessages: []")
.unwrap();
assert_eq!(
session.enabled_macros,
Some(vec!["a".to_string(), "b".to_string()])
);
}
#[test]
fn session_serialize_omits_enabled_macros_when_none() {
let session = Session::default();
let yaml = serde_yaml::to_string(&session).unwrap();
assert!(!yaml.contains("enabled_macros"));
}
#[test] #[test]
fn session_new_from_ctx_captures_save_session() { fn session_new_from_ctx_captures_save_session() {
let app_config = Arc::new(AppConfig::default()); let app_config = Arc::new(AppConfig::default());
+8
View File
@@ -588,6 +588,14 @@ nodes:
)); ));
} }
#[test]
fn graph_silently_ignores_enabled_macros_key() {
let yaml = "name: g\nenabled_macros: [\"x\"]\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";
let graph: Graph = serde_yaml::from_str(yaml).unwrap();
assert_eq!(graph.name, "g");
assert_eq!(graph.start, "x");
}
#[test] #[test]
fn graph_settings_have_sensible_defaults() { fn graph_settings_have_sensible_defaults() {
let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n"; let yaml = "name: g\nstart: x\nnodes:\n x:\n id: x\n type: end\n output: ok\n";