feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+371
View File
@@ -0,0 +1,371 @@
//! End-to-end tests for the env var flow introduced in PRs #783 and #794.
//!
//! These tests exercise the full pack-resolve pipeline and verify that:
//! - Goose personas emit GOOSE_PROVIDER, GOOSE_MODEL, GOOSE_TEMPERATURE
//! - Buzz-agent personas emit BUZZ_AGENT_MODEL, BUZZ_AGENT_PROVIDER
//! - The import filter strips derived provider/model keys but preserves knobs
//! - Multi-runtime packs produce correct per-persona env var prefixes
//! - Models without a provider prefix emit only the model key (no provider)
use std::collections::BTreeMap;
use std::fs;
use buzz_persona::resolve::resolve_pack;
const DERIVED_PROVIDER_MODEL_ENV_KEYS: &[&str] = &[
"GOOSE_MODEL",
"GOOSE_PROVIDER",
"BUZZ_AGENT_MODEL",
"BUZZ_AGENT_PROVIDER",
];
fn filter_derived(env_vars: Vec<(String, String)>) -> BTreeMap<String, String> {
env_vars
.into_iter()
.filter(|(k, _)| {
!DERIVED_PROVIDER_MODEL_ENV_KEYS
.iter()
.any(|d| d.eq_ignore_ascii_case(k))
})
.collect()
}
#[test]
fn resolve_pack_goose_persona_emits_correct_runtime_env_vars() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.e2e-env",
"name": "E2E Env Test",
"version": "1.0.0",
"personas": ["agents/bot.persona.md"],
"defaults": {}
}"#,
)
.unwrap();
fs::write(
root.join("agents/bot.persona.md"),
r#"---
name: "bot"
display_name: "Bot"
description: "Test bot"
model: "databricks:goose-claude-4-6-opus"
temperature: 0.7
---
You are a test bot.
"#,
)
.unwrap();
let pack = resolve_pack(root).unwrap();
let persona = &pack.personas[0];
let env: std::collections::HashMap<_, _> = persona
.runtime_env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
env.get("GOOSE_PROVIDER"),
Some(&"databricks"),
"should emit GOOSE_PROVIDER=databricks"
);
assert_eq!(
env.get("GOOSE_MODEL"),
Some(&"goose-claude-4-6-opus"),
"should emit GOOSE_MODEL=goose-claude-4-6-opus"
);
assert_eq!(
env.get("GOOSE_TEMPERATURE"),
Some(&"0.7"),
"should emit GOOSE_TEMPERATURE=0.7"
);
}
#[test]
fn resolve_pack_buzz_agent_persona_emits_buzz_agent_vars() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.e2e-env",
"name": "E2E Env Test",
"version": "1.0.0",
"personas": ["agents/bot.persona.md"],
"defaults": {}
}"#,
)
.unwrap();
fs::write(
root.join("agents/bot.persona.md"),
r#"---
name: "bot"
display_name: "Bot"
description: "Test bot"
runtime: "buzz-agent"
model: "openai:gpt-4o"
---
You are a test bot.
"#,
)
.unwrap();
let pack = resolve_pack(root).unwrap();
let persona = &pack.personas[0];
let env: std::collections::HashMap<_, _> = persona
.runtime_env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
env.get("BUZZ_AGENT_MODEL"),
Some(&"gpt-4o"),
"should emit BUZZ_AGENT_MODEL=gpt-4o"
);
assert_eq!(
env.get("BUZZ_AGENT_PROVIDER"),
Some(&"openai"),
"should emit BUZZ_AGENT_PROVIDER=openai"
);
// Must NOT contain GOOSE_* keys
assert!(
!env.contains_key("GOOSE_MODEL"),
"buzz-agent runtime must not emit GOOSE_MODEL"
);
assert!(
!env.contains_key("GOOSE_PROVIDER"),
"buzz-agent runtime must not emit GOOSE_PROVIDER"
);
}
#[test]
fn import_filter_strips_derived_preserves_knobs() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.e2e-env",
"name": "E2E Env Test",
"version": "1.0.0",
"personas": ["agents/bot.persona.md"],
"defaults": {}
}"#,
)
.unwrap();
fs::write(
root.join("agents/bot.persona.md"),
r#"---
name: "bot"
display_name: "Bot"
description: "Test bot"
model: "databricks:goose-claude-4-6-opus"
temperature: 0.7
---
You are a test bot.
"#,
)
.unwrap();
let pack = resolve_pack(root).unwrap();
let persona = &pack.personas[0];
// Apply the import filter (mirrors desktop import_persona_pack logic).
let filtered = filter_derived(persona.runtime_env_vars.clone());
// Derived provider/model keys must be stripped.
assert!(
!filtered.contains_key("GOOSE_MODEL"),
"GOOSE_MODEL must be stripped by import filter"
);
assert!(
!filtered.contains_key("GOOSE_PROVIDER"),
"GOOSE_PROVIDER must be stripped by import filter"
);
// Knob keys must survive.
assert_eq!(
filtered.get("GOOSE_TEMPERATURE").map(|s| s.as_str()),
Some("0.7"),
"GOOSE_TEMPERATURE must survive the import filter"
);
}
#[test]
fn full_pipeline_two_runtimes_different_env_vars() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.e2e-env",
"name": "E2E Env Test",
"version": "1.0.0",
"personas": [
"agents/goose-bot.persona.md",
"agents/buzz-bot.persona.md"
],
"defaults": {}
}"#,
)
.unwrap();
// Goose persona (default runtime)
fs::write(
root.join("agents/goose-bot.persona.md"),
r#"---
name: "goose-bot"
display_name: "Goose Bot"
description: "A goose runtime bot"
model: "anthropic:claude-sonnet-4-20250514"
---
You are a goose bot.
"#,
)
.unwrap();
// Buzz-agent persona
fs::write(
root.join("agents/buzz-bot.persona.md"),
r#"---
name: "buzz-bot"
display_name: "Buzz Bot"
description: "A buzz-agent runtime bot"
runtime: "buzz-agent"
model: "openai:gpt-4o"
---
You are a buzz bot.
"#,
)
.unwrap();
let pack = resolve_pack(root).unwrap();
assert_eq!(pack.personas.len(), 2);
let goose = pack
.personas
.iter()
.find(|p| p.name == "goose-bot")
.expect("goose-bot should exist");
let buzz = pack
.personas
.iter()
.find(|p| p.name == "buzz-bot")
.expect("buzz-bot should exist");
// Goose persona gets GOOSE_* env vars
let goose_env: std::collections::HashMap<_, _> = goose
.runtime_env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(goose_env.get("GOOSE_PROVIDER"), Some(&"anthropic"));
assert_eq!(
goose_env.get("GOOSE_MODEL"),
Some(&"claude-sonnet-4-20250514")
);
assert!(
!goose_env.contains_key("BUZZ_AGENT_MODEL"),
"goose persona must not emit BUZZ_AGENT_MODEL"
);
assert!(
!goose_env.contains_key("BUZZ_AGENT_PROVIDER"),
"goose persona must not emit BUZZ_AGENT_PROVIDER"
);
// Buzz-agent persona gets BUZZ_AGENT_* env vars
let buzz_env: std::collections::HashMap<_, _> = buzz
.runtime_env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(buzz_env.get("BUZZ_AGENT_MODEL"), Some(&"gpt-4o"));
assert_eq!(buzz_env.get("BUZZ_AGENT_PROVIDER"), Some(&"openai"));
assert!(
!buzz_env.contains_key("GOOSE_MODEL"),
"buzz-agent persona must not emit GOOSE_MODEL"
);
assert!(
!buzz_env.contains_key("GOOSE_PROVIDER"),
"buzz-agent persona must not emit GOOSE_PROVIDER"
);
}
#[test]
fn model_without_provider_prefix_emits_model_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.e2e-env",
"name": "E2E Env Test",
"version": "1.0.0",
"personas": ["agents/bot.persona.md"],
"defaults": {}
}"#,
)
.unwrap();
fs::write(
root.join("agents/bot.persona.md"),
r#"---
name: "bot"
display_name: "Bot"
description: "Test bot"
model: "gpt-4o"
---
You are a test bot.
"#,
)
.unwrap();
let pack = resolve_pack(root).unwrap();
let persona = &pack.personas[0];
let env: std::collections::HashMap<_, _> = persona
.runtime_env_vars
.iter()
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
assert_eq!(
env.get("GOOSE_MODEL"),
Some(&"gpt-4o"),
"should emit GOOSE_MODEL=gpt-4o"
);
assert!(
!env.contains_key("GOOSE_PROVIDER"),
"model without colon prefix must NOT emit GOOSE_PROVIDER"
);
}
+650
View File
@@ -0,0 +1,650 @@
//! Integration tests for the buzz-persona crate.
//!
//! These exercise the full pipeline: build a pack on disk → load it →
//! parse personas → resolve defaults → merge config → validate.
//! Each test creates a temporary pack directory with realistic content.
use std::fs;
use std::path::Path;
use buzz_persona::pack;
use buzz_persona::persona;
use buzz_persona::resolve;
use buzz_persona::validate;
/// Create a minimal valid pack in a temp directory.
/// Returns the temp dir (holds the lifetime) and the pack root path.
fn create_test_pack(dir: &Path) {
let plugin_dir = dir.join(".plugin");
fs::create_dir_all(&plugin_dir).unwrap();
let agents_dir = dir.join("agents");
fs::create_dir_all(&agents_dir).unwrap();
let skills_dir = dir.join("skills").join("code-review");
fs::create_dir_all(&skills_dir).unwrap();
// plugin.json
fs::write(
plugin_dir.join("plugin.json"),
r#"{
"id": "com.test.example-pack",
"name": "Example Pack",
"version": "1.0.0",
"description": "A test pack for integration tests.",
"personas": [
"agents/pip.persona.md",
"agents/lep.persona.md"
],
"defaults": {
"model": "anthropic:claude-sonnet-4-20250514",
"temperature": 0.7,
"max_context_tokens": 128000,
"triggers": {
"mentions": true,
"keywords": [],
"all_messages": false
},
"thread_replies": true,
"broadcast_replies": false
},
"pack_instructions": "instructions.md"
}"#,
)
.unwrap();
// pip.persona.md — overrides model to Opus
fs::write(
agents_dir.join("pip.persona.md"),
r##"---
name: "pip"
display_name: "Pip 🐝"
description: "Orchestration agent"
model: "anthropic:claude-4-opus-20250514"
subscribe:
- "#security-reviews"
skills:
- "./skills/code-review/"
---
You are Pip, an orchestration agent. You coordinate the team.
"##,
)
.unwrap();
// lep.persona.md — uses pack defaults
fs::write(
agents_dir.join("lep.persona.md"),
r##"---
name: "lep"
display_name: "Lep 🍀"
description: "Security-focused code reviewer"
triggers:
mentions: true
keywords:
- "security"
- "vulnerability"
- "CVE"
---
You are Lep, a security-focused code reviewer on the team.
"##,
)
.unwrap();
// SKILL.md
fs::write(
skills_dir.join("SKILL.md"),
r##"---
name: "code-review"
description: "Reviews code for quality and correctness"
---
# Code Review
When asked to review code, follow these steps...
"##,
)
.unwrap();
// instructions.md
fs::write(
dir.join("instructions.md"),
"# Team Instructions\n\nBe helpful and thorough.\n",
)
.unwrap();
}
#[test]
fn full_pipeline_load_and_validate() {
let dir = tempfile::tempdir().unwrap();
create_test_pack(dir.path());
// 1. Validate the pack — should be clean.
let report = validate::validate_pack(dir.path());
assert!(
!report.has_errors(),
"validation should pass on a valid pack, got: {report}"
);
// 2. Load the pack.
let loaded = pack::load_pack(dir.path()).unwrap();
// 3. Check manifest data.
assert_eq!(loaded.manifest.id, "com.test.example-pack");
assert_eq!(loaded.manifest.name, "Example Pack");
assert_eq!(loaded.manifest.version, "1.0.0");
// 4. Check personas loaded.
assert_eq!(loaded.personas.len(), 2);
let pip = loaded
.personas
.iter()
.find(|p| p.name == "pip")
.expect("pip persona should be loaded");
let lep = loaded
.personas
.iter()
.find(|p| p.name == "lep")
.expect("lep persona should be loaded");
// 5. Check pip's overrides.
assert_eq!(pip.display_name, "Pip 🐝");
assert_eq!(
pip.model.as_deref(),
Some("anthropic:claude-4-opus-20250514"),
"pip should override model to Opus"
);
assert!(
pip.prompt.contains("You are Pip"),
"pip should have system prompt"
);
// 5b. pip does NOT set triggers → must inherit pack-default triggers.
// This is the critical regression test for the respond_to/triggers fix.
let pip_rt = pip
.triggers
.as_ref()
.expect("pip should inherit triggers from pack defaults");
assert!(
pip_rt.mentions,
"pip should inherit mentions=true from defaults"
);
assert!(
pip_rt.keywords.is_empty(),
"pip should inherit empty keywords from defaults"
);
assert!(
!pip_rt.all_messages,
"pip should inherit all_messages=false from defaults"
);
// 6. Check lep inherits pack defaults.
assert_eq!(lep.display_name, "Lep 🍀");
assert_eq!(
lep.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514"),
"lep should inherit model from pack defaults"
);
assert!(lep.thread_replies, "lep should inherit thread_replies=true");
assert!(
!lep.broadcast_replies,
"lep should inherit broadcast_replies=false"
);
// 7. Check lep's triggers override.
let rt = lep.triggers.as_ref().expect("lep should have triggers");
assert!(rt.mentions, "lep triggers.mentions should be true");
assert!(
rt.keywords.contains(&"security".to_string()),
"lep should have 'security' keyword"
);
// 8. Pack instructions should be loaded.
assert!(
loaded.pack_instructions.is_some(),
"pack instructions should be loaded"
);
assert!(
loaded
.pack_instructions
.as_ref()
.unwrap()
.contains("Be helpful"),
"instructions content should match"
);
}
#[test]
fn persona_parse_round_trip() {
let md = r###"---
name: "test-agent"
display_name: "Test Agent"
description: "A test agent for round-trip verification"
model: "anthropic:claude-sonnet-4-20250514"
temperature: 0.3
subscribe:
- "#test-channel"
triggers:
mentions: true
keywords:
- "test"
all_messages: false
thread_replies: true
broadcast_replies: false
---
You are a test agent. Be precise and thorough.
"###;
let config = persona::parse_persona_md(md).unwrap();
assert_eq!(config.name, "test-agent");
assert_eq!(config.display_name, "Test Agent");
assert_eq!(
config.description,
"A test agent for round-trip verification"
);
assert_eq!(
config.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
assert_eq!(config.temperature, Some(0.3));
assert_eq!(config.subscribe, Some(vec!["#test-channel".to_owned()]));
assert!(config.prompt.contains("Be precise and thorough"));
let rt = config.triggers.unwrap();
assert_eq!(rt.mentions, Some(true));
assert_eq!(rt.keywords, vec!["test"]);
assert_eq!(rt.all_messages, Some(false));
}
#[test]
fn validation_catches_missing_required_fields() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join(".plugin");
let agents_dir = dir.path().join("agents");
fs::create_dir_all(&plugin_dir).unwrap();
fs::create_dir_all(&agents_dir).unwrap();
// plugin.json with a persona reference.
fs::write(
plugin_dir.join("plugin.json"),
r#"{
"id": "com.test.bad-pack",
"name": "Bad Pack",
"version": "1.0.0",
"personas": ["agents/bad.persona.md"]
}"#,
)
.unwrap();
// Persona missing required fields.
fs::write(
agents_dir.join("bad.persona.md"),
"---\nname: \"bad\"\n---\nNo display_name or description.\n",
)
.unwrap();
let report = validate::validate_pack(dir.path());
// load_pack fails on the first missing required field — the pack is
// structurally invalid and a single error is emitted.
assert!(report.has_errors(), "should flag missing required fields");
}
#[test]
fn validation_catches_unknown_behavioral_keys() {
let dir = tempfile::tempdir().unwrap();
let plugin_dir = dir.path().join(".plugin");
let agents_dir = dir.path().join("agents");
fs::create_dir_all(&plugin_dir).unwrap();
fs::create_dir_all(&agents_dir).unwrap();
// plugin.json with a typo in defaults.
fs::write(
plugin_dir.join("plugin.json"),
r#"{
"id": "com.test.typo-pack",
"name": "Typo Pack",
"version": "1.0.0",
"personas": ["agents/t.persona.md"],
"defaults": {
"temprature": 0.5,
"model": "test"
}
}"#,
)
.unwrap();
fs::write(
agents_dir.join("t.persona.md"),
"---\nname: t\ndisplay_name: T\ndescription: T.\n---\n",
)
.unwrap();
let report = validate::validate_pack(dir.path());
// Unknown manifest keys are advisory warnings, not hard errors.
assert!(
!report.has_errors(),
"unknown manifest keys should not be errors"
);
assert!(report.has_warnings(), "should catch typo in defaults");
let warn_str = format!("{report}");
assert!(
warn_str.contains("temprature"),
"should mention the typo: {warn_str}"
);
}
#[test]
fn model_split_cases() {
// provider:model
let (provider, model) = persona::split_model("anthropic:claude-sonnet-4-20250514");
assert_eq!(provider, Some("anthropic"));
assert_eq!(model, "claude-sonnet-4-20250514");
// no colon — entire string is model
let (provider, model) = persona::split_model("gpt-4o");
assert_eq!(provider, None);
assert_eq!(model, "gpt-4o");
// multiple colons — split on first only
let (provider, model) = persona::split_model("custom:my:model:v2");
assert_eq!(provider, Some("custom"));
assert_eq!(model, "my:model:v2");
}
#[test]
fn defaults_merge_persona_overrides() {
let dir = tempfile::tempdir().unwrap();
create_test_pack(dir.path());
let loaded = pack::load_pack(dir.path()).unwrap();
let pip = loaded.personas.iter().find(|p| p.name == "pip").unwrap();
let lep = loaded.personas.iter().find(|p| p.name == "lep").unwrap();
// pip overrides model; lep inherits.
assert_eq!(
pip.model.as_deref(),
Some("anthropic:claude-4-opus-20250514")
);
assert_eq!(
lep.model.as_deref(),
Some("anthropic:claude-sonnet-4-20250514")
);
// Both should inherit temperature from defaults (0.7).
assert_eq!(pip.temperature, Some(0.7));
assert_eq!(lep.temperature, Some(0.7));
// Both should inherit max_context_tokens from defaults (128000).
assert_eq!(pip.max_context_tokens, Some(128000));
assert_eq!(lep.max_context_tokens, Some(128000));
}
/// Build a pack on disk → resolve → verify all fields on each persona.
#[test]
fn resolve_full_pipeline() {
let dir = tempfile::tempdir().unwrap();
create_test_pack(dir.path());
let resolved = resolve::resolve_pack(dir.path()).unwrap();
assert_eq!(resolved.id, "com.test.example-pack");
assert_eq!(resolved.name, "Example Pack");
assert_eq!(resolved.version, "1.0.0");
assert_eq!(resolved.personas.len(), 2);
let pip = resolved
.personas
.iter()
.find(|p| p.name == "pip")
.expect("pip should be resolved");
let lep = resolved
.personas
.iter()
.find(|p| p.name == "lep")
.expect("lep should be resolved");
// Identity
assert_eq!(pip.display_name, "Pip 🐝");
assert_eq!(pip.description, "Orchestration agent");
assert_eq!(pip.version, "1.0.0"); // defaults to pack version
// Model split: "anthropic:claude-4-opus-20250514" → llm_provider + model
assert_eq!(pip.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));
// Lep inherits pack default model
assert_eq!(lep.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));
// System prompt is persona body only; pack instructions stay separate.
assert!(
pip.system_prompt.contains("You are Pip"),
"pip prompt should contain persona body"
);
assert!(
!pip.system_prompt.contains("Be helpful"),
"pip prompt should not contain pack instructions"
);
assert!(
pip.pack_instructions
.as_deref()
.is_some_and(|instructions| instructions.contains("Be helpful")),
"pip.pack_instructions should carry the pack instructions"
);
// Temperature inherited from defaults
assert_eq!(pip.temperature, Some(0.7));
assert_eq!(lep.temperature, Some(0.7));
// Subscribe
assert_eq!(pip.subscribe, vec!["#security-reviews"]);
// Triggers: lep has explicit triggers
assert!(lep.triggers.mentions);
assert!(lep.triggers.keywords.contains(&"security".to_string()));
assert!(lep.triggers.keywords.contains(&"vulnerability".to_string()));
assert!(!lep.triggers.all_messages);
// Env vars projected from model
let pip_env: std::collections::HashMap<_, _> = pip.runtime_env_vars.iter().cloned().collect();
assert_eq!(
pip_env.get("GOOSE_PROVIDER").map(|s| s.as_str()),
Some("anthropic")
);
assert_eq!(
pip_env.get("GOOSE_MODEL").map(|s| s.as_str()),
Some("claude-4-opus-20250514")
);
assert_eq!(
pip_env.get("GOOSE_TEMPERATURE").map(|s| s.as_str()),
Some("0.7")
);
}
/// Pack with 3 personas, each with different configs.
#[test]
fn resolve_multi_persona_pack() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{
"id": "com.test.multi",
"name": "Multi Pack",
"version": "2.0.0",
"personas": [
"agents/alpha.persona.md",
"agents/beta.persona.md",
"agents/gamma.persona.md"
],
"defaults": {
"model": "openai:gpt-4o",
"temperature": 0.5,
"thread_replies": true,
"broadcast_replies": false
}
}"#,
)
.unwrap();
// Alpha: overrides model and temperature
fs::write(
root.join("agents/alpha.persona.md"),
"---\nname: alpha\ndisplay_name: Alpha\ndescription: The first.\nmodel: \"anthropic:claude-sonnet-4-20250514\"\ntemperature: 0.9\n---\nYou are Alpha.\n",
)
.unwrap();
// Beta: uses all defaults
fs::write(
root.join("agents/beta.persona.md"),
"---\nname: beta\ndisplay_name: Beta\ndescription: The second.\n---\nYou are Beta.\n",
)
.unwrap();
// Gamma: overrides subscribe and thread_replies
fs::write(
root.join("agents/gamma.persona.md"),
"---\nname: gamma\ndisplay_name: Gamma\ndescription: The third.\nsubscribe:\n - \"#ops\"\nthread_replies: false\n---\nYou are Gamma.\n",
)
.unwrap();
let resolved = resolve::resolve_pack(root).unwrap();
assert_eq!(resolved.personas.len(), 3);
let alpha = resolved
.personas
.iter()
.find(|p| p.name == "alpha")
.unwrap();
let beta = resolved.personas.iter().find(|p| p.name == "beta").unwrap();
let gamma = resolved
.personas
.iter()
.find(|p| p.name == "gamma")
.unwrap();
// Alpha overrides model and temperature
assert_eq!(alpha.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(alpha.model.as_deref(), Some("claude-sonnet-4-20250514"));
assert_eq!(alpha.temperature, Some(0.9));
// Beta inherits all defaults
assert_eq!(beta.llm_provider.as_deref(), Some("openai"));
assert_eq!(beta.model.as_deref(), Some("gpt-4o"));
assert_eq!(beta.temperature, Some(0.5));
assert!(beta.thread_replies);
assert!(!beta.broadcast_replies);
// Gamma overrides subscribe and thread_replies
assert_eq!(gamma.subscribe, vec!["#ops"]);
assert!(!gamma.thread_replies);
// All share pack version
assert_eq!(alpha.version, "2.0.0");
assert_eq!(beta.version, "2.0.0");
assert_eq!(gamma.version, "2.0.0");
}
/// resolve_persona_by_name finds the correct persona.
#[test]
fn resolve_persona_by_name_found() {
let dir = tempfile::tempdir().unwrap();
create_test_pack(dir.path());
let pip = resolve::resolve_persona_by_name(dir.path(), "pip").unwrap();
assert_eq!(pip.name, "pip");
assert_eq!(pip.display_name, "Pip 🐝");
}
/// resolve_persona_by_name returns error for nonexistent name.
#[test]
fn resolve_persona_by_name_not_found() {
let dir = tempfile::tempdir().unwrap();
create_test_pack(dir.path());
let err = resolve::resolve_persona_by_name(dir.path(), "nonexistent").unwrap_err();
assert!(
format!("{err}").contains("not found")
|| matches!(err, pack::PackError::PersonaNotFound(_)),
"expected PersonaNotFound, got: {err}"
);
}
/// Validation catches zero-persona packs in the full pipeline.
#[test]
fn validate_zero_personas_in_pipeline() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{"id":"com.test.empty","name":"Empty","version":"1.0.0","personas":[]}"#,
)
.unwrap();
let report = validate::validate_pack(root);
assert!(report.has_errors());
let msg = format!("{report}");
assert!(msg.contains("zero personas"), "got: {msg}");
}
/// Validation catches duplicate persona names in the full pipeline.
#[test]
fn validate_duplicate_names_in_pipeline() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join(".plugin")).unwrap();
fs::create_dir_all(root.join("agents")).unwrap();
fs::write(
root.join(".plugin/plugin.json"),
r#"{"id":"com.test.dupes","name":"Dupes","version":"1.0.0","personas":["agents/a.persona.md","agents/b.persona.md"]}"#,
)
.unwrap();
fs::write(
root.join("agents/a.persona.md"),
"---\nname: same\ndisplay_name: A\ndescription: First.\n---\n",
)
.unwrap();
fs::write(
root.join("agents/b.persona.md"),
"---\nname: same\ndisplay_name: B\ndescription: Second.\n---\n",
)
.unwrap();
let report = validate::validate_pack(root);
assert!(report.has_errors());
let msg = format!("{report}");
assert!(msg.contains("duplicate persona name"), "got: {msg}");
}
/// Operator config fields are rejected in persona frontmatter.
/// This documents the security boundary: pack authors define behavior,
/// operators define limits.
#[test]
fn operator_config_fields_rejected_in_frontmatter() {
for field in [
"idle_timeout",
"max_turn_duration",
"agents",
"heartbeat_interval",
"permission_mode",
] {
let src =
format!("---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n{field}: 300\n---\n");
assert!(
persona::parse_persona_md(&src).is_err(),
"{field} should be rejected by deny_unknown_fields"
);
}
}