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