use std::collections::HashSet;
use std::path::Path;
use nexo_config::{AgentConfig, TelegramPluginConfig};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum BindingValidationError {
#[error(
"agent '{agent}': duplicate binding (plugin='{plugin}', instance={instance}) \
— each (plugin, instance) pair must appear at most once"
)]
DuplicateBinding {
agent: String,
plugin: String,
instance: String,
},
#[error(
"agent '{agent}' binding[{index}]: plugin='telegram' instance='{instance}' is not \
declared in config/plugins/telegram.yaml (known instances: {known})"
)]
UnknownTelegramInstance {
agent: String,
index: usize,
instance: String,
known: String,
},
#[error(
"agent '{agent}' binding[{index}]: allowed_tools entry '{tool}' does not match any \
registered tool (known tools: {known})"
)]
UnknownTool {
agent: String,
index: usize,
tool: String,
known: String,
},
#[error(
"agent '{agent}' binding[{index}]: skill '{skill}' not found under skills_dir '{dir}'"
)]
UnknownSkill {
agent: String,
index: usize,
skill: String,
dir: String,
},
#[error(
"agent '{agent}' binding[{index}]: model override provider '{binding_provider}' \
does not match the agent provider '{agent_provider}'. Per-binding model switching is \
only supported within the same provider (the LLM client is wired once per agent)."
)]
ProviderMismatch {
agent: String,
index: usize,
binding_provider: String,
agent_provider: String,
},
#[error("agent '{agent}': unknown LLM provider '{provider}' (known: {known})")]
UnknownProvider {
agent: String,
provider: String,
known: String,
},
#[error(
"agent '{agent}' binding[{index}]: invalid role '{role}'. Valid values: \
coordinator | worker | proactive"
)]
InvalidRole {
agent: String,
index: usize,
role: String,
},
#[error(
"agent '{agent}' binding[{index}]: role='coordinator' is incompatible with \
proactive ticking (coordinators delegate to workers and must not run their \
own tick loop). Either drop the role or disable proactive on this binding."
)]
CoordinatorWithProactive { agent: String, index: usize },
}
#[derive(Debug, Default, Clone)]
pub struct KnownTools<'a> {
names: HashSet<&'a str>,
}
#[derive(Debug, Default, Clone)]
pub struct KnownProviders<'a> {
names: HashSet<&'a str>,
}
impl<'a> KnownProviders<'a> {
pub fn new<I>(names: I) -> Self
where
I: IntoIterator<Item = &'a str>,
{
Self {
names: names.into_iter().collect(),
}
}
fn is_enabled(&self) -> bool {
!self.names.is_empty()
}
fn contains(&self, name: &str) -> bool {
self.names.contains(name)
}
fn listed(&self) -> String {
let mut v: Vec<&&str> = self.names.iter().collect();
v.sort();
v.iter().copied().copied().collect::<Vec<_>>().join(", ")
}
}
impl<'a> KnownTools<'a> {
pub fn new<I>(names: I) -> Self
where
I: IntoIterator<Item = &'a str>,
{
Self {
names: names.into_iter().collect(),
}
}
fn is_enabled(&self) -> bool {
!self.names.is_empty()
}
fn contains(&self, pattern: &str) -> bool {
if pattern == "*" {
return true;
}
if let Some(prefix) = pattern.strip_suffix('*') {
return self.names.iter().any(|t| t.starts_with(prefix));
}
self.names.contains(pattern)
}
fn listed(&self) -> String {
let mut v: Vec<&&str> = self.names.iter().collect();
v.sort();
v.iter().copied().copied().collect::<Vec<_>>().join(", ")
}
}
pub fn collect_binding_errors(
agents: &[AgentConfig],
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
) -> Vec<BindingValidationError> {
collect_binding_errors_with_providers(
agents,
telegram_instances,
known_tools,
&KnownProviders::default(),
)
}
pub fn collect_binding_errors_with_providers(
agents: &[AgentConfig],
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
known_providers: &KnownProviders<'_>,
) -> Vec<BindingValidationError> {
let mut errors = Vec::new();
for agent in agents {
validate_agent_into(
agent,
telegram_instances,
known_tools,
known_providers,
&mut errors,
);
}
errors
}
pub fn validate_agents(
agents: &[AgentConfig],
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
) -> anyhow::Result<()> {
validate_agents_with_providers(
agents,
telegram_instances,
known_tools,
&KnownProviders::default(),
)
}
pub fn validate_agents_with_providers(
agents: &[AgentConfig],
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
known_providers: &KnownProviders<'_>,
) -> anyhow::Result<()> {
let errors = collect_binding_errors_with_providers(
agents,
telegram_instances,
known_tools,
known_providers,
);
if errors.is_empty() {
return Ok(());
}
let mut msg = format!(
"per-binding override validation failed ({} issue{}):",
errors.len(),
if errors.len() == 1 { "" } else { "s" },
);
for e in &errors {
msg.push_str("\n - ");
msg.push_str(&e.to_string());
}
Err(anyhow::anyhow!(msg))
}
pub fn validate_agent(
agent: &AgentConfig,
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
) -> Result<(), BindingValidationError> {
let mut errors = Vec::new();
validate_agent_into(
agent,
telegram_instances,
known_tools,
&KnownProviders::default(),
&mut errors,
);
if let Some(first) = errors.into_iter().next() {
Err(first)
} else {
Ok(())
}
}
fn validate_agent_into(
agent: &AgentConfig,
telegram_instances: &[TelegramPluginConfig],
known_tools: &KnownTools<'_>,
known_providers: &KnownProviders<'_>,
errors: &mut Vec<BindingValidationError>,
) {
if known_providers.is_enabled() {
if !known_providers.contains(&agent.model.provider) {
errors.push(BindingValidationError::UnknownProvider {
agent: agent.id.clone(),
provider: agent.model.provider.clone(),
known: known_providers.listed(),
});
}
for b in &agent.inbound_bindings {
let Some(m) = &b.model else { continue };
if !known_providers.contains(&m.provider) {
errors.push(BindingValidationError::UnknownProvider {
agent: agent.id.clone(),
provider: m.provider.clone(),
known: known_providers.listed(),
});
}
}
}
let mut seen: HashSet<(String, Option<String>)> = HashSet::new();
for b in &agent.inbound_bindings {
let key = (b.plugin.clone(), b.instance.clone());
if !seen.insert(key.clone()) {
errors.push(BindingValidationError::DuplicateBinding {
agent: agent.id.clone(),
plugin: b.plugin.clone(),
instance: b.instance.clone().unwrap_or_else(|| "<wildcard>".into()),
});
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
if b.plugin != "telegram" {
continue;
}
let Some(inst) = b.instance.as_deref() else {
continue;
};
let declared = telegram_instances
.iter()
.any(|t| t.instance.as_deref() == Some(inst));
if !declared {
let known = telegram_instances
.iter()
.filter_map(|t| t.instance.clone())
.collect::<Vec<_>>()
.join(", ");
errors.push(BindingValidationError::UnknownTelegramInstance {
agent: agent.id.clone(),
index: idx,
instance: inst.to_string(),
known: if known.is_empty() {
"<none>".into()
} else {
known
},
});
}
}
if known_tools.is_enabled() {
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
let Some(list) = b.allowed_tools.as_ref() else {
continue;
};
for tool in list {
if !known_tools.contains(tool) {
errors.push(BindingValidationError::UnknownTool {
agent: agent.id.clone(),
index: idx,
tool: tool.clone(),
known: known_tools.listed(),
});
}
}
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
let Some(m) = &b.model else { continue };
if m.provider != agent.model.provider {
errors.push(BindingValidationError::ProviderMismatch {
agent: agent.id.clone(),
index: idx,
binding_provider: m.provider.clone(),
agent_provider: agent.model.provider.clone(),
});
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
let Some(role) = b.role.as_deref() else {
continue;
};
let normalized = role.trim().to_ascii_lowercase();
if !matches!(normalized.as_str(), "coordinator" | "worker" | "proactive") {
errors.push(BindingValidationError::InvalidRole {
agent: agent.id.clone(),
index: idx,
role: role.to_string(),
});
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
let Some(role) = b.role.as_deref() else {
continue;
};
if role.trim().to_ascii_lowercase() != "coordinator" {
continue;
}
let proactive_enabled = b
.proactive
.as_ref()
.map(|p| p.enabled)
.unwrap_or(agent.proactive.enabled);
if proactive_enabled {
errors.push(BindingValidationError::CoordinatorWithProactive {
agent: agent.id.clone(),
index: idx,
});
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
let Some(skills) = b.skills.as_ref() else {
continue;
};
for skill in skills {
let skill_dir = Path::new(&agent.skills_dir).join(skill);
if !skill_dir.is_dir() {
errors.push(BindingValidationError::UnknownSkill {
agent: agent.id.clone(),
index: idx,
skill: skill.clone(),
dir: agent.skills_dir.clone(),
});
}
}
}
for (idx, b) in agent.inbound_bindings.iter().enumerate() {
if !has_any_override(b) {
tracing::warn!(
agent = %agent.id,
binding_index = idx,
plugin = %b.plugin,
instance = b.instance.as_deref().unwrap_or("<wildcard>"),
"inbound binding defines no overrides — inherits every agent-level setting \
(consider removing the binding entry if this was unintentional)"
);
}
}
let mut seen_wildcard: HashSet<&str> = HashSet::new();
let mut seen_specific: HashSet<&str> = HashSet::new();
for b in &agent.inbound_bindings {
if b.instance.is_none() {
seen_wildcard.insert(b.plugin.as_str());
} else {
seen_specific.insert(b.plugin.as_str());
}
}
for plugin in seen_wildcard.intersection(&seen_specific) {
tracing::warn!(
agent = %agent.id,
plugin = %plugin,
"inbound bindings contain both a wildcard (instance=None) and a \
specific-instance entry for the same plugin — first-match wins; \
list the specific binding before the wildcard if you want it to \
take priority"
);
}
}
fn has_any_override(b: &nexo_config::InboundBinding) -> bool {
b.allowed_tools.is_some()
|| b.outbound_allowlist.is_some()
|| b.skills.is_some()
|| b.model.is_some()
|| b.system_prompt_extra.is_some()
|| b.allowed_delegates.is_some()
|| b.language.is_some()
|| !b.link_understanding.is_null()
|| !b.web_search.is_null()
|| !b.pairing_policy.is_null()
|| b.dispatch_policy.is_some()
|| b.remote_triggers.is_some()
|| b.plan_mode.is_some()
|| b.role.is_some()
|| b.proactive.is_some()
|| b.repl.is_some()
|| b.lsp.is_some()
|| b.team.is_some()
|| b.config_tool.is_some()
|| !matches!(
b.sender_rate_limit,
nexo_config::SenderRateLimitOverride::Keyword(
nexo_config::SenderRateLimitKeyword::Inherit
)
)
}
#[cfg(test)]
mod tests {
use super::*;
use nexo_config::{
AgentRuntimeConfig, DreamingYamlConfig, HeartbeatConfig, InboundBinding, ModelConfig,
OutboundAllowlistConfig, TelegramAllowlistConfig, TelegramAutoTranscribeConfig,
TelegramPluginConfig, TelegramPollingConfig, WorkspaceGitConfig,
};
use std::fs;
use tempfile::TempDir;
fn agent(id: &str, skills_dir: &str) -> AgentConfig {
AgentConfig {
id: id.into(),
model: ModelConfig {
provider: "anthropic".into(),
model: "claude-haiku-4-5".into(),
},
plugins: Vec::new(),
heartbeat: HeartbeatConfig::default(),
config: AgentRuntimeConfig::default(),
system_prompt: String::new(),
workspace: String::new(),
skills: Vec::new(),
skills_dir: skills_dir.into(),
skill_overrides: Default::default(),
transcripts_dir: String::new(),
dreaming: DreamingYamlConfig::default(),
workspace_git: WorkspaceGitConfig::default(),
tool_rate_limits: None,
tool_args_validation: None,
extra_docs: Vec::new(),
inbound_bindings: Vec::new(),
allowed_tools: Vec::new(),
sender_rate_limit: None,
allowed_delegates: Vec::new(),
accept_delegates_from: Vec::new(),
description: String::new(),
google_auth: None,
credentials: Default::default(),
link_understanding: serde_json::Value::Null,
web_search: serde_json::Value::Null,
pairing_policy: serde_json::Value::Null,
language: None,
outbound_allowlist: OutboundAllowlistConfig::default(),
context_optimization: None,
dispatch_policy: Default::default(),
plan_mode: Default::default(),
remote_triggers: Vec::new(),
lsp: nexo_config::types::lsp::LspPolicy::default(),
config_tool: nexo_config::types::config_tool::ConfigToolPolicy::default(),
team: nexo_config::types::team::TeamPolicy::default(),
proactive: Default::default(),
repl: Default::default(),
auto_dream: None,
assistant_mode: None,
away_summary: None,
brief: None,
channels: None,
auto_approve: false,
extract_memories: None,
event_subscribers: Vec::new(),
tenant_id: None,
extensions_config: std::collections::BTreeMap::new(),
active: true,
}
}
fn tg_instance(name: &str) -> TelegramPluginConfig {
TelegramPluginConfig {
token: "t".into(),
polling: TelegramPollingConfig::default(),
allowlist: TelegramAllowlistConfig::default(),
auto_transcribe: TelegramAutoTranscribeConfig::default(),
bridge_timeout_ms: 120_000,
instance: Some(name.into()),
allow_agents: Vec::new(),
}
}
#[test]
fn duplicate_binding_rejected() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("ana_tg".into()),
..Default::default()
});
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("ana_tg".into()),
..Default::default()
});
let tg = vec![tg_instance("ana_tg")];
let err = validate_agent(&a, &tg, &KnownTools::default()).unwrap_err();
assert!(matches!(
err,
BindingValidationError::DuplicateBinding { .. }
));
}
#[test]
fn unknown_telegram_instance_rejected() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("missing".into()),
..Default::default()
});
let tg = vec![tg_instance("ana_tg")];
let err = validate_agent(&a, &tg, &KnownTools::default()).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("missing"));
assert!(msg.contains("ana_tg"));
}
#[test]
fn wildcard_telegram_binding_accepts_no_instances() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: None,
..Default::default()
});
let tg: Vec<TelegramPluginConfig> = Vec::new();
validate_agent(&a, &tg, &KnownTools::default()).expect("wildcard must pass");
}
#[test]
fn unknown_tool_rejected_when_catalogue_supplied() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "whatsapp".into(),
allowed_tools: Some(vec!["nonexistent_tool".into()]),
..Default::default()
});
let tools = KnownTools::new(["whatsapp_send_message", "weather"]);
let err = validate_agent(&a, &[], &tools).unwrap_err();
assert!(matches!(err, BindingValidationError::UnknownTool { .. }));
}
#[test]
fn wildcard_tool_always_passes() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
allowed_tools: Some(vec!["*".into()]),
..Default::default()
});
let tools = KnownTools::new(["whatsapp_send_message"]);
validate_agent(&a, &[], &tools).expect("'*' is always valid");
}
#[test]
fn trailing_star_glob_matches_prefix() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
allowed_tools: Some(vec!["memory_*".into()]),
..Default::default()
});
let tools = KnownTools::new(["memory_write", "memory_query"]);
validate_agent(&a, &[], &tools).expect("prefix glob should match");
}
#[test]
fn empty_tools_catalogue_disables_check() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
allowed_tools: Some(vec!["anything".into()]),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default()).expect("empty catalogue = check disabled");
}
#[test]
fn missing_skill_rejected() {
let dir = TempDir::new().unwrap();
let skills_dir = dir.path().to_str().unwrap().to_string();
let mut a = agent("ana", &skills_dir);
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
skills: Some(vec!["no_such_skill".into()]),
..Default::default()
});
let err = validate_agent(&a, &[], &KnownTools::default()).unwrap_err();
assert!(matches!(err, BindingValidationError::UnknownSkill { .. }));
}
#[test]
fn existing_skill_dir_passes() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("weather")).unwrap();
let mut a = agent("ana", dir.path().to_str().unwrap());
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
skills: Some(vec!["weather".into()]),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default()).expect("skill present");
}
#[test]
fn binding_without_overrides_passes_and_warns() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "whatsapp".into(),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default()).expect("must still boot");
}
#[test]
fn provider_mismatch_rejected() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
model: Some(ModelConfig {
provider: "minimax".into(),
model: "MiniMax-M2.5".into(),
}),
..Default::default()
});
let err = validate_agent(&a, &[], &KnownTools::default()).unwrap_err();
assert!(matches!(
err,
BindingValidationError::ProviderMismatch { .. }
));
}
#[test]
fn same_provider_model_switch_accepted() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
model: Some(ModelConfig {
provider: "anthropic".into(),
model: "claude-sonnet-4-5".into(),
}),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default())
.expect("same-provider model switch must pass");
}
#[test]
fn invalid_role_rejected() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
role: Some("boss-mode".into()),
..Default::default()
});
let err = validate_agent(&a, &[], &KnownTools::default()).unwrap_err();
assert!(matches!(err, BindingValidationError::InvalidRole { .. }));
}
#[test]
fn collect_binding_errors_aggregates_multi_agent_problems() {
let mut a1 = agent("ana", "./skills");
a1.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("missing".into()),
..Default::default()
});
let mut a2 = agent("bob", "./skills");
a2.inbound_bindings.push(InboundBinding {
plugin: "whatsapp".into(),
model: Some(ModelConfig {
provider: "minimax".into(),
model: "x".into(),
}),
..Default::default()
});
let errors = collect_binding_errors(&[a1, a2], &[], &KnownTools::default());
assert_eq!(errors.len(), 2, "both agent errors should be collected");
assert!(errors
.iter()
.any(|e| matches!(e, BindingValidationError::UnknownTelegramInstance { .. })));
assert!(errors
.iter()
.any(|e| matches!(e, BindingValidationError::ProviderMismatch { .. })));
}
#[test]
fn validate_agents_aggregates_into_single_error_message() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("ana_tg".into()),
..Default::default()
});
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("ana_tg".into()),
..Default::default()
});
a.inbound_bindings.push(InboundBinding {
plugin: "whatsapp".into(),
model: Some(ModelConfig {
provider: "minimax".into(),
model: "x".into(),
}),
..Default::default()
});
let tg = vec![tg_instance("ana_tg")];
let err = validate_agents(&[a], &tg, &KnownTools::default()).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("duplicate binding"));
assert!(msg.contains("provider"));
assert!(
msg.contains("issue"),
"message should count issues, got: {msg}"
);
}
#[test]
fn unknown_provider_rejected_when_catalogue_supplied() {
let mut a = agent("ana", "./skills");
a.model.provider = "anthopic".into();
let known = KnownProviders::new(["anthropic", "minimax", "openai"]);
let err =
validate_agents_with_providers(&[a], &[], &KnownTools::default(), &known).unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("anthopic"));
assert!(msg.contains("anthropic"));
}
#[test]
fn empty_providers_catalogue_disables_check() {
let mut a = agent("ana", "./skills");
a.model.provider = "bogus".into();
validate_agents(&[a], &[], &KnownTools::default())
.expect("empty catalogue disables the check");
}
#[test]
fn binding_provider_typo_rejected() {
let mut a = agent("ana", "./skills");
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
model: Some(ModelConfig {
provider: "anthopic".into(),
model: "x".into(),
}),
..Default::default()
});
let known = KnownProviders::new(["anthropic", "minimax"]);
let errors =
collect_binding_errors_with_providers(&[a], &[], &KnownTools::default(), &known);
assert!(errors
.iter()
.any(|e| matches!(e, BindingValidationError::UnknownProvider { .. })));
assert!(errors
.iter()
.any(|e| matches!(e, BindingValidationError::ProviderMismatch { .. })));
}
#[test]
fn happy_path_with_multiple_checks() {
let dir = TempDir::new().unwrap();
fs::create_dir(dir.path().join("weather")).unwrap();
let mut a = agent("ana", dir.path().to_str().unwrap());
a.inbound_bindings.push(InboundBinding {
plugin: "whatsapp".into(),
allowed_tools: Some(vec!["whatsapp_send_message".into()]),
..Default::default()
});
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
instance: Some("ana_tg".into()),
allowed_tools: Some(vec!["*".into()]),
skills: Some(vec!["weather".into()]),
..Default::default()
});
let tg = vec![tg_instance("ana_tg")];
let tools = KnownTools::new(["whatsapp_send_message", "weather"]);
validate_agent(&a, &tg, &tools).expect("happy path must pass");
}
#[test]
fn coordinator_role_with_agent_proactive_enabled_rejected() {
let mut a = agent("ana", "./skills");
a.proactive.enabled = true;
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
role: Some("coordinator".into()),
..Default::default()
});
let err = validate_agent(&a, &[], &KnownTools::default()).unwrap_err();
assert!(
matches!(err, BindingValidationError::CoordinatorWithProactive { .. }),
"expected CoordinatorWithProactive, got {err:?}"
);
}
#[test]
fn coordinator_role_with_binding_proactive_override_rejected() {
let mut a = agent("ana", "./skills");
a.proactive.enabled = false;
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
role: Some("coordinator".into()),
proactive: Some(nexo_config::types::proactive::ProactiveConfig {
enabled: true,
..Default::default()
}),
..Default::default()
});
let err = validate_agent(&a, &[], &KnownTools::default()).unwrap_err();
assert!(matches!(
err,
BindingValidationError::CoordinatorWithProactive { .. }
));
}
#[test]
fn coordinator_role_without_proactive_passes() {
let mut a = agent("ana", "./skills");
a.proactive.enabled = false;
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
role: Some("coordinator".into()),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default())
.expect("coordinator without proactive must pass");
}
#[test]
fn proactive_role_with_proactive_enabled_passes() {
let mut a = agent("ana", "./skills");
a.proactive.enabled = true;
a.inbound_bindings.push(InboundBinding {
plugin: "telegram".into(),
role: Some("proactive".into()),
..Default::default()
});
validate_agent(&a, &[], &KnownTools::default())
.expect("role=proactive + proactive.enabled is the canonical happy path");
}
#[test]
fn has_any_override_returns_false_for_bare_binding() {
let b = InboundBinding {
plugin: "telegram".into(),
..Default::default()
};
assert!(
!has_any_override(&b),
"a bare binding (only `plugin` set) must not be flagged as having overrides"
);
}
#[test]
fn has_any_override_returns_true_for_each_new_override() {
type Case = (&'static str, fn(&mut InboundBinding));
let cases: Vec<Case> = vec![
("plan_mode", |b| b.plan_mode = Some(Default::default())),
("role", |b| b.role = Some("worker".into())),
("proactive", |b| b.proactive = Some(Default::default())),
("repl", |b| b.repl = Some(Default::default())),
("lsp", |b| b.lsp = Some(Default::default())),
("team", |b| b.team = Some(Default::default())),
("config_tool", |b| b.config_tool = Some(Default::default())),
];
for (name, mutate) in cases {
let mut b = InboundBinding {
plugin: "telegram".into(),
..Default::default()
};
mutate(&mut b);
assert!(
has_any_override(&b),
"expected has_any_override == true after setting `{name}`"
);
}
}
}