From f39381aa9d022b6b9ce8e23753c63c1e63ccbc89 Mon Sep 17 00:00:00 2001 From: Alex Clarke Date: Thu, 20 Aug 2026 15:14:20 -0600 Subject: [PATCH] feat: add enabled_macros config field at global, role, agent, and session levels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/config/agent.rs | 26 +++++++++++++++ src/config/app_config.rs | 72 ++++++++++++++++++++++++++++++++++++++++ src/config/mod.rs | 39 ++++++++++++++++++++++ src/config/role.rs | 60 +++++++++++++++++++++++++++++++++ src/config/session.rs | 45 +++++++++++++++++++++++++ src/graph/types.rs | 8 +++++ 6 files changed, 250 insertions(+) diff --git a/src/config/agent.rs b/src/config/agent.rs index 95467ce..2a9f1ba 100644 --- a/src/config/agent.rs +++ b/src/config/agent.rs @@ -744,6 +744,8 @@ pub struct AgentConfig { #[serde(skip_serializing_if = "Option::is_none")] pub enabled_skills: Option>, #[serde(skip_serializing_if = "Option::is_none")] + pub enabled_macros: Option>, + #[serde(skip_serializing_if = "Option::is_none")] pub continuation_prompt: Option, #[serde(default)] pub instructions: String, @@ -1225,6 +1227,30 @@ variables: 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] fn agent_config_with_model() { let yaml = diff --git a/src/config/app_config.rs b/src/config/app_config.rs index be85041..51a1855 100644 --- a/src/config/app_config.rs +++ b/src/config/app_config.rs @@ -43,6 +43,8 @@ pub struct AppConfig { #[serde(default, deserialize_with = "super::deserialize_csv_or_vec")] pub enabled_skills: Option>, pub visible_skills: Option>, + #[serde(default, deserialize_with = "super::deserialize_csv_or_vec")] + pub enabled_macros: Option>, pub mcp_server_support: bool, pub mapping_mcp_servers: IndexMap, @@ -127,6 +129,7 @@ impl Default for AppConfig { skills_enabled: true, enabled_skills: None, visible_skills: None, + enabled_macros: None, mcp_server_support: true, mapping_mcp_servers: Default::default(), @@ -211,6 +214,7 @@ impl AppConfig { skills_enabled: config.skills_enabled, enabled_skills: config.enabled_skills, visible_skills: config.visible_skills, + enabled_macros: config.enabled_macros, mcp_server_support: config.mcp_server_support, mapping_mcp_servers: config.mapping_mcp_servers, @@ -533,6 +537,10 @@ impl AppConfig { self.enabled_skills = v.map(|raw| super::csv_to_vec(&raw)); } + if let Some(v) = super::read_env_value::(&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")) { 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] fn editor_returns_configured_value() { let configured = cached_editor() diff --git a/src/config/mod.rs b/src/config/mod.rs index e92f5b6..ad25b6e 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -221,6 +221,8 @@ pub struct Config { #[serde(default, deserialize_with = "deserialize_csv_or_vec")] pub enabled_skills: Option>, pub visible_skills: Option>, + #[serde(default, deserialize_with = "deserialize_csv_or_vec")] + pub enabled_macros: Option>, pub mcp_server_support: bool, pub mapping_mcp_servers: IndexMap, @@ -303,6 +305,7 @@ impl Default for Config { skills_enabled: true, enabled_skills: None, visible_skills: None, + enabled_macros: None, mcp_server_support: true, mapping_mcp_servers: Default::default(), @@ -1124,6 +1127,42 @@ clients: 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] fn assert_state_pass_always_true() { let pass = AssertState::pass(); diff --git a/src/config/role.rs b/src/config/role.rs index cfb6104..69281f8 100644 --- a/src/config/role.rs +++ b/src/config/role.rs @@ -75,6 +75,12 @@ pub struct Role { deserialize_with = "super::deserialize_csv_or_vec" )] enabled_skills: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::deserialize_csv_or_vec" + )] + enabled_macros: Option>, #[serde(skip_serializing_if = "Option::is_none")] auto_continue: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -129,6 +135,7 @@ impl Role { } "skills_enabled" => role.skills_enabled = value.as_bool(), "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(), "max_auto_continues" => { 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()); 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 { metadata.push(format!("auto_continue: {auto_continue}")); } @@ -588,6 +599,55 @@ mod tests { 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] fn role_builtin_shell_loads() { let role = Role::builtin("shell").unwrap(); diff --git a/src/config/session.rs b/src/config/session.rs index 0b593b7..cd7f04e 100644 --- a/src/config/session.rs +++ b/src/config/session.rs @@ -46,6 +46,12 @@ pub struct Session { deserialize_with = "super::deserialize_csv_or_vec" )] enabled_skills: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::deserialize_csv_or_vec" + )] + enabled_macros: Option>, #[serde(skip_serializing_if = "Option::is_none")] save_session: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -931,6 +937,45 @@ mod tests { 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] fn session_new_from_ctx_captures_save_session() { let app_config = Arc::new(AppConfig::default()); diff --git a/src/graph/types.rs b/src/graph/types.rs index fcd3292..8e13538 100644 --- a/src/graph/types.rs +++ b/src/graph/types.rs @@ -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] 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";