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:
@@ -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 =
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
Reference in New Issue
Block a user