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,16 @@
|
||||
[package]
|
||||
name = "buzz-persona"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Parser and loader for Buzz persona pack files (.persona.md)"
|
||||
license = "Apache-2.0"
|
||||
repository = "https://github.com/block/sprout"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,6 @@
|
||||
pub mod manifest;
|
||||
pub mod merge;
|
||||
pub mod pack;
|
||||
pub mod persona;
|
||||
pub mod resolve;
|
||||
pub mod validate;
|
||||
@@ -0,0 +1,365 @@
|
||||
//! Pack manifest types and `plugin.json` parser.
|
||||
//!
|
||||
//! Every persona pack ships a `.plugin/plugin.json` that describes the pack
|
||||
//! (OPS metadata) and tells Buzz where to find personas, hooks, and MCP
|
||||
//! config.
|
||||
//!
|
||||
//! ```json
|
||||
//! {
|
||||
//! "id": "my-pack",
|
||||
//! "name": "My Pack",
|
||||
//! "version": "1.0.0",
|
||||
//! "personas": ["personas/bot.persona.md"]
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::persona::RespondTo;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ManifestError {
|
||||
#[error("failed to read file: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("failed to parse JSON: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(String),
|
||||
}
|
||||
|
||||
/// Semver engine constraints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct Engines {
|
||||
/// Semver range the Buzz runtime must satisfy (e.g. `">=0.9.0"`).
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "buzz")]
|
||||
pub buzz: Option<String>,
|
||||
}
|
||||
|
||||
/// Pack-wide behavioral defaults.
|
||||
///
|
||||
/// Persona-level values take precedence; these fill in the gaps.
|
||||
/// Same shape as the persona behavioral config fields.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct BehavioralDefaults {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_context_tokens: Option<u64>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe: Option<Vec<String>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none", alias = "respond_to")]
|
||||
pub triggers: Option<RespondTo>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_replies: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub broadcast_replies: Option<bool>,
|
||||
}
|
||||
|
||||
/// The pack manifest from `.plugin/plugin.json`.
|
||||
///
|
||||
/// OPS required fields (`id`, `name`, `version`) are validated after
|
||||
/// deserialization because `serde_json` would otherwise surface confusing
|
||||
/// errors for missing keys.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PackManifest {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub license: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub homepage: Option<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub engines: Option<Engines>,
|
||||
|
||||
/// Paths to `.persona.md` files (pack-relative).
|
||||
#[serde(default)]
|
||||
pub personas: Vec<String>,
|
||||
|
||||
/// Path to the pack-level instructions markdown file.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub pack_instructions: Option<String>,
|
||||
|
||||
/// Path to `.mcp.json`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mcp_config: Option<String>,
|
||||
|
||||
/// Path to `hooks/hooks.json`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hooks_config: Option<String>,
|
||||
|
||||
/// Pack-wide behavioral defaults.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub defaults: Option<BehavioralDefaults>,
|
||||
}
|
||||
|
||||
/// Mirrors `PackManifest` but with required fields as `Option` so we can
|
||||
/// produce a clean `MissingField` error instead of a serde path error.
|
||||
///
|
||||
/// Intentionally permissive (no `deny_unknown_fields`): `plugin.json` is an
|
||||
/// OPS superset and may carry fields from other tools (e.g. `ops_category`,
|
||||
/// `marketplace_tags`). Unknown fields are silently ignored here; the
|
||||
/// validator issues advisory warnings for Buzz-unknown keys.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
struct RawManifest {
|
||||
id: Option<String>,
|
||||
name: Option<String>,
|
||||
version: Option<String>,
|
||||
description: Option<String>,
|
||||
author: Option<String>,
|
||||
license: Option<String>,
|
||||
homepage: Option<String>,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
engines: Option<Engines>,
|
||||
#[serde(default)]
|
||||
personas: Vec<String>,
|
||||
pack_instructions: Option<String>,
|
||||
mcp_config: Option<String>,
|
||||
hooks_config: Option<String>,
|
||||
defaults: Option<BehavioralDefaults>,
|
||||
}
|
||||
|
||||
/// Parse a `plugin.json` string into a [`PackManifest`].
|
||||
pub fn parse_manifest(content: &str) -> Result<PackManifest, ManifestError> {
|
||||
let raw: RawManifest = serde_json::from_str(content)?;
|
||||
|
||||
let id = raw.id.ok_or(ManifestError::MissingField("id".into()))?;
|
||||
let name = raw.name.ok_or(ManifestError::MissingField("name".into()))?;
|
||||
let version = raw
|
||||
.version
|
||||
.ok_or(ManifestError::MissingField("version".into()))?;
|
||||
|
||||
if id.trim().is_empty() {
|
||||
return Err(ManifestError::MissingField("id (empty)".into()));
|
||||
}
|
||||
if name.trim().is_empty() {
|
||||
return Err(ManifestError::MissingField("name (empty)".into()));
|
||||
}
|
||||
if version.trim().is_empty() {
|
||||
return Err(ManifestError::MissingField("version (empty)".into()));
|
||||
}
|
||||
|
||||
Ok(PackManifest {
|
||||
id,
|
||||
name,
|
||||
version,
|
||||
description: raw.description,
|
||||
author: raw.author,
|
||||
license: raw.license,
|
||||
homepage: raw.homepage,
|
||||
keywords: raw.keywords,
|
||||
engines: raw.engines,
|
||||
personas: raw.personas,
|
||||
pack_instructions: raw.pack_instructions,
|
||||
mcp_config: raw.mcp_config,
|
||||
hooks_config: raw.hooks_config,
|
||||
defaults: raw.defaults,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `plugin.json` file from disk.
|
||||
pub fn parse_manifest_file(path: &Path) -> Result<PackManifest, ManifestError> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
parse_manifest(&content)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn minimal_json() -> &'static str {
|
||||
r#"{"id":"my-pack","name":"My Pack","version":"1.0.0","personas":["personas/bot.persona.md"]}"#
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_valid() {
|
||||
let m = parse_manifest(minimal_json()).unwrap();
|
||||
assert_eq!(m.id, "my-pack");
|
||||
assert_eq!(m.name, "My Pack");
|
||||
assert_eq!(m.version, "1.0.0");
|
||||
assert_eq!(m.personas, vec!["personas/bot.persona.md"]);
|
||||
assert!(m.defaults.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_manifest() {
|
||||
let json = r#"{
|
||||
"id": "full-pack",
|
||||
"name": "Full Pack",
|
||||
"version": "2.3.4",
|
||||
"description": "A full-featured pack.",
|
||||
"author": "Tyler",
|
||||
"license": "MIT",
|
||||
"homepage": "https://example.com",
|
||||
"keywords": ["ai", "bot"],
|
||||
"engines": {"buzz": ">=0.9.0"},
|
||||
"personas": ["personas/a.persona.md", "personas/b.persona.md"],
|
||||
"pack_instructions": "instructions.md",
|
||||
"mcp_config": ".mcp.json",
|
||||
"hooks_config": "hooks/hooks.json",
|
||||
"defaults": {
|
||||
"model": "openai:gpt-4o",
|
||||
"temperature": 0.5,
|
||||
"thread_replies": true
|
||||
}
|
||||
}"#;
|
||||
let m = parse_manifest(json).unwrap();
|
||||
assert_eq!(m.id, "full-pack");
|
||||
assert_eq!(m.keywords, vec!["ai", "bot"]);
|
||||
assert_eq!(m.engines.unwrap().buzz.as_deref(), Some(">=0.9.0"));
|
||||
assert_eq!(m.personas.len(), 2);
|
||||
assert_eq!(m.pack_instructions.as_deref(), Some("instructions.md"));
|
||||
let d = m.defaults.unwrap();
|
||||
assert_eq!(d.model.as_deref(), Some("openai:gpt-4o"));
|
||||
assert_eq!(d.temperature, Some(0.5));
|
||||
assert_eq!(d.thread_replies, Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_personas_array_defaults_empty() {
|
||||
// personas is optional — omitting it yields an empty vec.
|
||||
let json = r#"{"id":"p","name":"P","version":"1.0.0"}"#;
|
||||
let m = parse_manifest(json).unwrap();
|
||||
assert!(m.personas.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_defaults_block_is_valid() {
|
||||
let json = r#"{"id":"p","name":"P","version":"1.0.0","defaults":{}}"#;
|
||||
let m = parse_manifest(json).unwrap();
|
||||
let d = m.defaults.unwrap();
|
||||
assert!(d.model.is_none());
|
||||
assert!(d.temperature.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_with_triggers() {
|
||||
let json = r#"{
|
||||
"id": "p", "name": "P", "version": "1.0.0",
|
||||
"defaults": {
|
||||
"triggers": {"mentions": true, "keywords": ["hey"], "all_messages": false}
|
||||
}
|
||||
}"#;
|
||||
let m = parse_manifest(json).unwrap();
|
||||
let rt = m.defaults.unwrap().triggers.unwrap();
|
||||
assert_eq!(rt.keywords, vec!["hey"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_with_legacy_respond_to_alias() {
|
||||
let json = r#"{
|
||||
"id": "p", "name": "P", "version": "1.0.0",
|
||||
"defaults": {
|
||||
"respond_to": {"mentions": true, "keywords": ["hey"], "all_messages": false}
|
||||
}
|
||||
}"#;
|
||||
let m = parse_manifest(json).unwrap();
|
||||
let rt = m.defaults.unwrap().triggers.unwrap();
|
||||
assert_eq!(rt.keywords, vec!["hey"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_id_errors() {
|
||||
let json = r#"{"name":"P","version":"1.0.0"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f == "id"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_name_errors() {
|
||||
let json = r#"{"id":"p","version":"1.0.0"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f == "name"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_version_errors() {
|
||||
let json = r#"{"id":"p","name":"P"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f == "version"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_id_errors() {
|
||||
let json = r#"{"id":"","name":"P","version":"1.0.0"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f.contains("id")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_id_errors() {
|
||||
let json = r#"{"id":" ","name":"P","version":"1.0.0"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f.contains("id")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_errors() {
|
||||
let json = r#"{"id":"p","name":"","version":"1.0.0"}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f.contains("name")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_version_errors() {
|
||||
let json = r#"{"id":"p","name":"P","version":""}"#;
|
||||
let err = parse_manifest(json).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, ManifestError::MissingField(f) if f.contains("version")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_json_errors() {
|
||||
let err = parse_manifest("{not valid json}").unwrap_err();
|
||||
assert!(matches!(err, ManifestError::Json(_)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
/// Precedence resolution for persona behavioral config.
|
||||
///
|
||||
/// Handles levels 3–5 of the 5-level precedence model:
|
||||
/// 3. Per-persona frontmatter (wins)
|
||||
/// 4. Pack-level defaults (from plugin.json `defaults`)
|
||||
/// 5. Built-in defaults (hardcoded fallbacks)
|
||||
///
|
||||
/// Levels 1–2 (operator env vars, desktop UI) are resolved at runtime.
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct TriggersData {
|
||||
pub mentions: bool,
|
||||
pub keywords: Vec<String>,
|
||||
pub all_messages: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct HooksData {
|
||||
pub on_start: Option<String>,
|
||||
pub on_stop: Option<String>,
|
||||
pub on_message: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedConfig {
|
||||
pub model: Option<String>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_context_tokens: Option<u64>,
|
||||
/// `None` = absent (no persona or pack value) → caller uses its own default
|
||||
/// `Some([])` = intentional "subscribe to nothing"
|
||||
/// `Some([..])` = explicit channel list
|
||||
pub subscribe: Option<Vec<String>>,
|
||||
pub triggers: Option<TriggersData>,
|
||||
pub thread_replies: bool,
|
||||
pub broadcast_replies: bool,
|
||||
}
|
||||
|
||||
const DEFAULT_THREAD_REPLIES: bool = true;
|
||||
const DEFAULT_BROADCAST_REPLIES: bool = false;
|
||||
|
||||
/// Merge pack defaults with per-persona values.
|
||||
///
|
||||
/// Rules:
|
||||
/// - Persona field present (non-null) → use persona value
|
||||
/// - Persona field absent or null → use pack default
|
||||
/// - Empty array `[]` or object `{}` → present, overrides default
|
||||
pub fn merge_behavioral_config(
|
||||
persona_config: &serde_json::Value,
|
||||
pack_defaults: &serde_json::Value,
|
||||
) -> serde_json::Value {
|
||||
use serde_json::Value;
|
||||
|
||||
let persona_obj = match persona_config.as_object() {
|
||||
Some(o) => o,
|
||||
None => return pack_defaults.clone(),
|
||||
};
|
||||
let defaults_obj = match pack_defaults.as_object() {
|
||||
Some(o) => o,
|
||||
None => return persona_config.clone(),
|
||||
};
|
||||
|
||||
let mut merged = serde_json::Map::new();
|
||||
|
||||
// All keys from defaults first, then persona overrides.
|
||||
for (key, default_val) in defaults_obj {
|
||||
let effective = match persona_obj.get(key) {
|
||||
// null in persona → fall through to default
|
||||
Some(Value::Null) | None => default_val.clone(),
|
||||
Some(v) => v.clone(),
|
||||
};
|
||||
merged.insert(key.clone(), effective);
|
||||
}
|
||||
|
||||
// Any persona keys not in defaults are included as-is (excluding null).
|
||||
for (key, val) in persona_obj {
|
||||
if !merged.contains_key(key) && !val.is_null() {
|
||||
merged.insert(key.clone(), val.clone());
|
||||
}
|
||||
}
|
||||
|
||||
Value::Object(merged)
|
||||
}
|
||||
|
||||
/// Resolve a single persona's effective config from raw frontmatter + pack defaults.
|
||||
pub fn resolve_persona_config(
|
||||
persona_frontmatter: &serde_json::Value,
|
||||
pack_defaults: Option<&serde_json::Value>,
|
||||
) -> ResolvedConfig {
|
||||
let empty = serde_json::Value::Object(serde_json::Map::new());
|
||||
let defaults = pack_defaults.unwrap_or(&empty);
|
||||
let merged = merge_behavioral_config(persona_frontmatter, defaults);
|
||||
|
||||
let model = string_field(&merged, "model");
|
||||
let temperature = merged.get("temperature").and_then(|v| v.as_f64());
|
||||
let max_context_tokens = merged.get("max_context_tokens").and_then(|v| v.as_u64());
|
||||
|
||||
// subscribe: Option<Vec<String>>
|
||||
//
|
||||
// Read from persona frontmatter first to distinguish:
|
||||
// - absent / null → None (fall through to pack default, then None)
|
||||
// - Some([]) → intentional "subscribe to nothing"
|
||||
// - Some([..]) → explicit channel list
|
||||
//
|
||||
// Pack default is only used when persona has no subscribe (None/null).
|
||||
let subscribe = {
|
||||
let persona_sub = persona_frontmatter.get("subscribe");
|
||||
let default_sub = defaults.get("subscribe");
|
||||
match (persona_sub, default_sub) {
|
||||
// Persona has a non-null array → use it directly (may be empty).
|
||||
(Some(serde_json::Value::Array(arr)), _) => Some(
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
),
|
||||
// Persona absent or null → try pack default.
|
||||
(None, Some(serde_json::Value::Array(arr)))
|
||||
| (Some(serde_json::Value::Null), Some(serde_json::Value::Array(arr))) => Some(
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
),
|
||||
// Neither side has subscribe.
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
// triggers: SHALLOW REPLACEMENT.
|
||||
//
|
||||
// If persona has triggers (non-null), it replaces the pack default entirely.
|
||||
// Missing sub-fields fall to BUILT-IN defaults, not pack defaults.
|
||||
//
|
||||
// If persona lacks triggers (null or absent), use pack default.
|
||||
// If neither has it, None.
|
||||
let triggers = {
|
||||
let persona_t = persona_frontmatter.get("triggers");
|
||||
let default_t = defaults.get("triggers");
|
||||
match (persona_t, default_t) {
|
||||
// Neither side has triggers.
|
||||
(None, None)
|
||||
| (Some(serde_json::Value::Null), None)
|
||||
| (None, Some(serde_json::Value::Null))
|
||||
| (Some(serde_json::Value::Null), Some(serde_json::Value::Null)) => None,
|
||||
// Persona has triggers — use it directly (shallow replacement).
|
||||
// Pack default is ignored entirely.
|
||||
(Some(v), _) if !v.is_null() => parse_triggers(v),
|
||||
// Persona absent/null — fall through to pack default.
|
||||
(None, Some(v)) | (Some(serde_json::Value::Null), Some(v)) => parse_triggers(v),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
let thread_replies = merged
|
||||
.get("thread_replies")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(DEFAULT_THREAD_REPLIES);
|
||||
|
||||
let broadcast_replies = merged
|
||||
.get("broadcast_replies")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(DEFAULT_BROADCAST_REPLIES);
|
||||
|
||||
ResolvedConfig {
|
||||
model,
|
||||
temperature,
|
||||
max_context_tokens,
|
||||
subscribe,
|
||||
triggers,
|
||||
thread_replies,
|
||||
broadcast_replies,
|
||||
}
|
||||
}
|
||||
|
||||
fn string_field(v: &serde_json::Value, key: &str) -> Option<String> {
|
||||
v.get(key).and_then(|v| v.as_str()).map(str::to_owned)
|
||||
}
|
||||
|
||||
fn parse_triggers(v: &serde_json::Value) -> Option<TriggersData> {
|
||||
let obj = v.as_object()?;
|
||||
Some(TriggersData {
|
||||
mentions: obj
|
||||
.get("mentions")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(true),
|
||||
keywords: obj
|
||||
.get("keywords")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
all_messages: obj
|
||||
.get("all_messages")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn persona_value_wins_over_pack_default() {
|
||||
let persona = json!({ "model": "gpt-4o", "thread_replies": false });
|
||||
let defaults = json!({ "model": "claude-3", "thread_replies": true });
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["model"], "gpt-4o");
|
||||
assert_eq!(merged["thread_replies"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_default_used_when_persona_field_absent() {
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let defaults = json!({ "model": "claude-3", "temperature": 0.7 });
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["model"], "gpt-4o");
|
||||
assert_eq!(merged["temperature"], 0.7);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn null_persona_value_falls_through_to_default() {
|
||||
let persona = json!({ "model": null });
|
||||
let defaults = json!({ "model": "claude-3" });
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["model"], "claude-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_array_overrides_default() {
|
||||
let persona = json!({ "subscribe": [] });
|
||||
let defaults = json!({ "subscribe": ["channel-a", "channel-b"] });
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["subscribe"], json!([]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_object_overrides_default() {
|
||||
let persona = json!({ "triggers": {} });
|
||||
let defaults = json!({ "triggers": { "mentions": true } });
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["triggers"], json!({}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_defaults_persona_fields_pass_through() {
|
||||
let persona = json!({ "model": "gpt-4o", "temperature": 0.5 });
|
||||
let defaults = json!({});
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["model"], "gpt-4o");
|
||||
assert_eq!(merged["temperature"], 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_merge_mixed_present_absent() {
|
||||
let persona = json!({
|
||||
"model": "gpt-4o",
|
||||
"temperature": null,
|
||||
"subscribe": ["chan-x"],
|
||||
});
|
||||
let defaults = json!({
|
||||
"model": "claude-3",
|
||||
"temperature": 0.9,
|
||||
"thread_replies": false,
|
||||
"subscribe": ["chan-default"],
|
||||
});
|
||||
let merged = merge_behavioral_config(&persona, &defaults);
|
||||
assert_eq!(merged["model"], "gpt-4o"); // persona wins
|
||||
assert_eq!(merged["temperature"], 0.9); // null → default
|
||||
assert_eq!(merged["thread_replies"], false); // default used
|
||||
assert_eq!(merged["subscribe"], json!(["chan-x"])); // persona wins
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_defaults_when_no_fields() {
|
||||
let persona = json!({});
|
||||
let resolved = resolve_persona_config(&persona, None);
|
||||
assert_eq!(resolved.model, None);
|
||||
assert_eq!(resolved.temperature, None);
|
||||
assert_eq!(resolved.max_context_tokens, None);
|
||||
assert_eq!(resolved.subscribe, None);
|
||||
assert_eq!(resolved.triggers, None);
|
||||
assert!(resolved.thread_replies); // built-in default
|
||||
assert!(!resolved.broadcast_replies); // built-in default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_with_pack_defaults() {
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let defaults = json!({
|
||||
"temperature": 0.7,
|
||||
"thread_replies": false,
|
||||
"subscribe": ["general"],
|
||||
});
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
assert_eq!(resolved.model.as_deref(), Some("gpt-4o"));
|
||||
assert_eq!(resolved.temperature, Some(0.7));
|
||||
assert!(!resolved.thread_replies);
|
||||
assert_eq!(resolved.subscribe, Some(vec!["general".to_owned()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_triggers_parsed() {
|
||||
let persona = json!({
|
||||
"triggers": {
|
||||
"mentions": true,
|
||||
"keywords": ["help", "buzz"],
|
||||
"all_messages": false,
|
||||
}
|
||||
});
|
||||
let resolved = resolve_persona_config(&persona, None);
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(t.mentions);
|
||||
assert_eq!(t.keywords, vec!["help", "buzz"]);
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_max_context_tokens() {
|
||||
let persona = json!({ "max_context_tokens": 8192u64 });
|
||||
let resolved = resolve_persona_config(&persona, None);
|
||||
assert_eq!(resolved.max_context_tokens, Some(8192));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_shallow_replacement() {
|
||||
// Persona sets `mentions: false` — entire triggers replaces pack default.
|
||||
// Pack's `keywords: ["security"]` is LOST. Missing sub-fields get built-in defaults.
|
||||
let persona = json!({ "triggers": { "mentions": false } });
|
||||
let defaults = json!({ "triggers": { "mentions": true, "keywords": ["security"] } });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(!t.mentions);
|
||||
assert!(
|
||||
t.keywords.is_empty(),
|
||||
"pack keywords should be lost under shallow replacement"
|
||||
);
|
||||
assert!(!t.all_messages); // built-in default
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_absent_inherits_pack() {
|
||||
// Persona has no triggers → pack default used entirely.
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let defaults = json!({ "triggers": { "mentions": false, "keywords": ["deploy"] } });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(!t.mentions);
|
||||
assert_eq!(t.keywords, vec!["deploy"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_empty_gets_builtins() {
|
||||
// Persona sets `triggers: {}` — present but empty.
|
||||
// All sub-fields fall to built-in defaults.
|
||||
let persona = json!({ "triggers": {} });
|
||||
let defaults = json!({ "triggers": { "mentions": false, "keywords": ["security"] } });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(t.mentions, "built-in default for mentions is true");
|
||||
assert!(
|
||||
t.keywords.is_empty(),
|
||||
"built-in default for keywords is empty"
|
||||
);
|
||||
assert!(
|
||||
!t.all_messages,
|
||||
"built-in default for all_messages is false"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_null_inherits_pack_default() {
|
||||
// Persona explicitly sets triggers: null — fall through to pack default.
|
||||
let persona = json!({ "triggers": null });
|
||||
let defaults = json!({ "triggers": { "all_messages": true } });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(t.all_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_persona_explicit_empty_keywords_overrides_pack() {
|
||||
// Persona explicitly sets `keywords: []`; pack default has `keywords: ["foo"]`.
|
||||
// Under shallow replacement, persona wins entirely — pack is ignored.
|
||||
let persona = json!({ "triggers": { "keywords": [] } });
|
||||
let defaults = json!({ "triggers": { "keywords": ["foo"] } });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved.triggers.unwrap();
|
||||
assert!(t.keywords.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_neither_side_returns_none() {
|
||||
// Neither persona nor pack has triggers — result is None.
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let resolved = resolve_persona_config(&persona, None);
|
||||
assert!(resolved.triggers.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_inherited_from_pack_defaults() {
|
||||
// Critical regression test: a persona WITHOUT triggers must inherit
|
||||
// pack-default triggers. This was broken when BehavioralDefaults
|
||||
// serialized the field as "respond_to" but merge looked for "triggers".
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let defaults = json!({
|
||||
"triggers": {
|
||||
"mentions": true,
|
||||
"keywords": ["security", "CVE"],
|
||||
"all_messages": false,
|
||||
}
|
||||
});
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
let t = resolved
|
||||
.triggers
|
||||
.expect("persona should inherit triggers from pack defaults");
|
||||
assert!(t.mentions);
|
||||
assert_eq!(t.keywords, vec!["security", "CVE"]);
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_null_falls_through() {
|
||||
// persona subscribe: null → falls through to pack default.
|
||||
let persona = json!({ "subscribe": null });
|
||||
let defaults = json!({ "subscribe": ["#general", "#alerts"] });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
assert_eq!(
|
||||
resolved.subscribe,
|
||||
Some(vec!["#general".to_owned(), "#alerts".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_empty_overrides() {
|
||||
// persona subscribe: [] → intentional "subscribe to nothing" — Some([]).
|
||||
let persona = json!({ "subscribe": [] });
|
||||
let defaults = json!({ "subscribe": ["#general"] });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
assert_eq!(resolved.subscribe, Some(vec![]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_absent_falls_through_to_pack_default() {
|
||||
// persona has no subscribe field → falls through to pack default.
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let defaults = json!({ "subscribe": ["#security-reviews"] });
|
||||
let resolved = resolve_persona_config(&persona, Some(&defaults));
|
||||
assert_eq!(
|
||||
resolved.subscribe,
|
||||
Some(vec!["#security-reviews".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subscribe_absent_no_pack_is_none() {
|
||||
// Neither persona nor pack has subscribe → None.
|
||||
let persona = json!({ "model": "gpt-4o" });
|
||||
let resolved = resolve_persona_config(&persona, None);
|
||||
assert_eq!(resolved.subscribe, None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,734 @@
|
||||
/// Pack directory loader.
|
||||
///
|
||||
/// Reads a pack directory and produces a fully loaded [`LoadedPack`].
|
||||
///
|
||||
/// Directory layout expected:
|
||||
/// ```text
|
||||
/// <pack_root>/
|
||||
/// .plugin/
|
||||
/// plugin.json ← manifest
|
||||
/// personas/
|
||||
/// <name>.persona.md ← one file per persona
|
||||
/// instructions.md ← optional pack-level instructions
|
||||
/// .mcp.json ← optional shared MCP config
|
||||
/// skills/ ← optional skills directory
|
||||
/// ```
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
path::{Component, Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::manifest::{self, ManifestError};
|
||||
use crate::merge::{resolve_persona_config, HooksData, TriggersData};
|
||||
use crate::persona::{self, PersonaConfig};
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PackError {
|
||||
#[error("manifest not found at {0}")]
|
||||
ManifestNotFound(PathBuf),
|
||||
|
||||
#[error("failed to read {path}: {source}")]
|
||||
Io {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("failed to parse manifest: {0}")]
|
||||
ManifestParse(String),
|
||||
|
||||
#[error("persona file not found: {0}")]
|
||||
PersonaNotFound(PathBuf),
|
||||
|
||||
#[error("invalid file {path}: {reason}")]
|
||||
FileParse { path: PathBuf, reason: String },
|
||||
|
||||
#[error("path traversal rejected: {0}")]
|
||||
PathTraversal(String),
|
||||
|
||||
#[error("path escapes pack root: {0}")]
|
||||
PathEscape(PathBuf),
|
||||
|
||||
#[error("failed to parse .mcp.json at {path}: {reason}")]
|
||||
McpConfigParse { path: PathBuf, reason: String },
|
||||
}
|
||||
|
||||
impl From<ManifestError> for PackError {
|
||||
fn from(e: ManifestError) -> Self {
|
||||
PackError::ManifestParse(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// A fully loaded persona pack.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedPack {
|
||||
pub manifest: PackManifestData,
|
||||
pub personas: Vec<LoadedPersona>,
|
||||
/// Content of instructions.md, if present.
|
||||
pub pack_instructions: Option<String>,
|
||||
/// Raw .mcp.json content, if present.
|
||||
pub shared_mcp_config: Option<serde_json::Value>,
|
||||
/// Path to the skills/ directory, if it exists.
|
||||
pub skills_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A persona with its resolved effective config.
|
||||
#[derive(Debug)]
|
||||
pub struct LoadedPersona {
|
||||
pub source_path: PathBuf,
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub avatar: Option<String>,
|
||||
pub model: Option<String>,
|
||||
/// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude').
|
||||
pub runtime: Option<String>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_context_tokens: Option<u64>,
|
||||
pub subscribe: Vec<String>,
|
||||
pub triggers: Option<TriggersData>,
|
||||
pub thread_replies: bool,
|
||||
pub broadcast_replies: bool,
|
||||
pub skills: Vec<String>,
|
||||
/// Raw MCP server configs.
|
||||
pub mcp_servers: Vec<serde_json::Value>,
|
||||
pub hooks: Option<HooksData>,
|
||||
/// The markdown body (system prompt).
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
/// Minimal manifest data needed by the pack loader.
|
||||
#[derive(Debug)]
|
||||
pub struct PackManifestData {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
/// Relative paths to .persona.md files.
|
||||
pub personas: Vec<String>,
|
||||
pub pack_instructions: Option<String>,
|
||||
pub mcp_config: Option<String>,
|
||||
// hooks_config is intentionally omitted: hooks are a runtime concern loaded
|
||||
// separately by buzz-acp, not a pack-parsing concern.
|
||||
/// Raw defaults block.
|
||||
pub defaults: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
/// Load a persona pack from a directory.
|
||||
///
|
||||
/// 1. Read and parse `.plugin/plugin.json`
|
||||
/// 2. For each persona path in the manifest, read the `.persona.md` file
|
||||
/// 3. Apply pack defaults to each persona (via merge logic)
|
||||
/// 4. Read `pack_instructions` if present
|
||||
/// 5. Read `.mcp.json` if present
|
||||
/// 6. Validate all paths resolve within `pack_root` (no path traversal)
|
||||
pub fn load_pack(pack_dir: &Path) -> Result<LoadedPack, PackError> {
|
||||
let pack_root = pack_dir.canonicalize().map_err(|e| PackError::Io {
|
||||
path: pack_dir.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
// 1. Manifest
|
||||
let manifest_path = pack_root.join(".plugin").join("plugin.json");
|
||||
if !manifest_path.exists() {
|
||||
return Err(PackError::ManifestNotFound(manifest_path));
|
||||
}
|
||||
let manifest_raw = read_file(&manifest_path)?;
|
||||
let pm = manifest::parse_manifest(&manifest_raw)?;
|
||||
let manifest = PackManifestData {
|
||||
id: pm.id,
|
||||
name: pm.name,
|
||||
version: pm.version,
|
||||
description: pm.description,
|
||||
personas: pm.personas,
|
||||
pack_instructions: pm.pack_instructions,
|
||||
mcp_config: pm.mcp_config,
|
||||
defaults: pm
|
||||
.defaults
|
||||
.map(serde_json::to_value)
|
||||
.transpose()
|
||||
.map_err(|e| PackError::ManifestParse(format!("failed to serialize defaults: {e}")))?,
|
||||
};
|
||||
|
||||
let pack_defaults = manifest.defaults.clone();
|
||||
|
||||
// 2 & 3. Personas
|
||||
let persona_size_limit =
|
||||
(persona::MAX_FRONTMATTER_BYTES + persona::MAX_BODY_BYTES + 200) as u64;
|
||||
let mut personas = Vec::with_capacity(manifest.personas.len());
|
||||
for rel_path in &manifest.personas {
|
||||
let abs_path = safe_resolve(&pack_root, rel_path)?;
|
||||
if !abs_path.exists() {
|
||||
return Err(PackError::PersonaNotFound(abs_path));
|
||||
}
|
||||
let content = read_bounded_file(&abs_path, persona_size_limit)?;
|
||||
let persona = parse_persona_file(&abs_path, &content, pack_defaults.as_ref())?;
|
||||
personas.push(persona);
|
||||
}
|
||||
|
||||
// 4. Pack instructions
|
||||
let text_size_limit =
|
||||
(crate::persona::MAX_FRONTMATTER_BYTES + crate::persona::MAX_BODY_BYTES + 200) as u64;
|
||||
|
||||
let pack_instructions = match &manifest.pack_instructions {
|
||||
Some(rel) => {
|
||||
let abs = safe_resolve(&pack_root, rel)?;
|
||||
if !abs.exists() {
|
||||
return Err(PackError::FileParse {
|
||||
path: abs,
|
||||
reason: format!("pack_instructions file not found: {rel}"),
|
||||
});
|
||||
}
|
||||
Some(read_bounded_file(&abs, text_size_limit)?)
|
||||
}
|
||||
None => {
|
||||
let path = pack_root.join("instructions.md");
|
||||
if path.exists() {
|
||||
Some(read_bounded_file(&path, text_size_limit)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Shared MCP config
|
||||
let parse_mcp = |raw: String, path: &Path| -> Result<serde_json::Value, PackError> {
|
||||
serde_json::from_str(&raw).map_err(|e| PackError::McpConfigParse {
|
||||
path: path.to_path_buf(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
};
|
||||
let shared_mcp_config = match &manifest.mcp_config {
|
||||
Some(rel) => {
|
||||
let abs = safe_resolve(&pack_root, rel)?;
|
||||
if !abs.exists() {
|
||||
return Err(PackError::FileParse {
|
||||
path: abs,
|
||||
reason: format!("mcp_config file not found: {rel}"),
|
||||
});
|
||||
}
|
||||
let raw = read_bounded_file(&abs, text_size_limit)?;
|
||||
Some(parse_mcp(raw, &abs)?)
|
||||
}
|
||||
None => {
|
||||
let path = pack_root.join(".mcp.json");
|
||||
if path.exists() {
|
||||
let raw = read_bounded_file(&path, text_size_limit)?;
|
||||
Some(parse_mcp(raw, &path)?)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Skills directory
|
||||
let skills_dir = {
|
||||
let path = pack_root.join("skills");
|
||||
if path.is_dir() {
|
||||
Some(path)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
Ok(LoadedPack {
|
||||
manifest,
|
||||
personas,
|
||||
pack_instructions,
|
||||
shared_mcp_config,
|
||||
skills_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Determine which skills go to which persona.
|
||||
///
|
||||
/// - Skills listed in a persona's `skills:` array → only that persona
|
||||
/// - Skills not listed in any persona's `skills:` → all personas (shared)
|
||||
///
|
||||
/// Returns a map of `persona_name → Vec<skill_name>`.
|
||||
pub fn resolve_skills(pack_dir: &Path, personas: &[LoadedPersona]) -> HashMap<String, Vec<String>> {
|
||||
// Normalize a persona skill path (e.g. `"./skills/security-review/"`,
|
||||
// `"skills/search"`, `"web-search"`) to just the final path component
|
||||
// so it can be compared against bare `read_dir` entry names.
|
||||
fn normalize_skill_name(path: &str) -> String {
|
||||
std::path::Path::new(path.trim_end_matches('/'))
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(path)
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
// Collect all *normalized* skill names claimed by at least one persona.
|
||||
let mut claimed: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||
for p in personas {
|
||||
for s in &p.skills {
|
||||
claimed.insert(normalize_skill_name(s));
|
||||
}
|
||||
}
|
||||
|
||||
// Enumerate skills directory — directories only, skip dotfiles.
|
||||
let skills_path = pack_dir.join("skills");
|
||||
let all_skills: Vec<String> = if skills_path.is_dir() {
|
||||
std::fs::read_dir(&skills_path)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.flatten()
|
||||
.filter_map(|entry| {
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
// Skip dotfiles
|
||||
if name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
// Directories only (skip plain files and unresolvable entries)
|
||||
if entry.file_type().ok()?.is_dir() {
|
||||
Some(name)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
// Shared skills = those not claimed by any persona.
|
||||
let shared: Vec<String> = all_skills
|
||||
.iter()
|
||||
.filter(|s| !claimed.contains(*s))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let mut result: HashMap<String, Vec<String>> = HashMap::new();
|
||||
for p in personas {
|
||||
// Normalize claimed skill paths to bare directory names so the output
|
||||
// format is consistent with shared skills (which come from read_dir).
|
||||
let mut skills: Vec<String> = p.skills.iter().map(|s| normalize_skill_name(s)).collect();
|
||||
for s in &shared {
|
||||
if !skills.contains(s) {
|
||||
skills.push(s.clone());
|
||||
}
|
||||
}
|
||||
result.insert(p.name.clone(), skills);
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Verify a path resolves within the pack root.
|
||||
///
|
||||
/// Defense-in-depth:
|
||||
/// 1. Reject any `..` path component before canonicalization.
|
||||
/// 2. Canonicalize the joined path.
|
||||
/// 3. Verify the result has `pack_root` as a prefix.
|
||||
fn safe_resolve(pack_root: &Path, relative: &str) -> Result<PathBuf, PackError> {
|
||||
// Step 0: reject absolute paths (Unix `/` prefix or Windows drive letters).
|
||||
if relative.starts_with('/') {
|
||||
return Err(PackError::PathTraversal(relative.to_owned()));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if relative.len() >= 2 && relative.as_bytes()[1] == b':' {
|
||||
return Err(PackError::PathTraversal(relative.to_owned()));
|
||||
}
|
||||
|
||||
// Step 1: reject `..` components eagerly.
|
||||
let rel = Path::new(relative);
|
||||
for component in rel.components() {
|
||||
if component == Component::ParentDir {
|
||||
return Err(PackError::PathTraversal(relative.to_owned()));
|
||||
}
|
||||
}
|
||||
|
||||
let joined = pack_root.join(rel);
|
||||
|
||||
// Step 2: canonicalize (resolves symlinks).
|
||||
// If the path doesn't exist yet we can't canonicalize — return the
|
||||
// un-canonicalized path so callers can produce a proper "not found" error.
|
||||
// We still verify the non-canonical form doesn't escape (belt-and-suspenders).
|
||||
if !joined.exists() {
|
||||
// Normalize without canonicalize: just check the lexical prefix.
|
||||
// The `..` check above already guards against traversal.
|
||||
return Ok(joined);
|
||||
}
|
||||
|
||||
let canonical = joined.canonicalize().map_err(|e| PackError::Io {
|
||||
path: joined.clone(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
// Step 3: must be inside pack_root.
|
||||
if !canonical.starts_with(pack_root) {
|
||||
return Err(PackError::PathEscape(canonical));
|
||||
}
|
||||
|
||||
Ok(canonical)
|
||||
}
|
||||
|
||||
fn read_file(path: &Path) -> Result<String, PackError> {
|
||||
std::fs::read_to_string(path).map_err(|e| PackError::Io {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
/// Read a file with a size limit. Returns an error if the file exceeds `max_bytes`.
|
||||
fn read_bounded_file(path: &Path, max_bytes: u64) -> Result<String, PackError> {
|
||||
let meta = std::fs::metadata(path).map_err(|e| PackError::FileParse {
|
||||
path: path.to_path_buf(),
|
||||
reason: format!("cannot stat file: {e}"),
|
||||
})?;
|
||||
if meta.len() > max_bytes {
|
||||
return Err(PackError::FileParse {
|
||||
path: path.to_path_buf(),
|
||||
reason: format!("file too large: {} bytes (max {max_bytes})", meta.len()),
|
||||
});
|
||||
}
|
||||
read_file(path)
|
||||
}
|
||||
|
||||
/// Parse a `.persona.md` file.
|
||||
///
|
||||
/// Delegates identity and prompt parsing to [`persona::parse_persona_md`],
|
||||
/// then applies pack-level behavioral defaults via the merge layer.
|
||||
fn parse_persona_file(
|
||||
path: &Path,
|
||||
content: &str,
|
||||
pack_defaults: Option<&serde_json::Value>,
|
||||
) -> Result<LoadedPersona, PackError> {
|
||||
let pc: PersonaConfig =
|
||||
persona::parse_persona_md(content).map_err(|e| PackError::FileParse {
|
||||
path: path.to_path_buf(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
// Serialize the behavioral fields of PersonaConfig to JSON so the
|
||||
// existing merge logic can do precedence resolution unchanged.
|
||||
let fm_json = serde_json::to_value(&pc).map_err(|e| PackError::FileParse {
|
||||
path: path.to_path_buf(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
|
||||
let resolved = resolve_persona_config(&fm_json, pack_defaults);
|
||||
|
||||
// Convert typed MCP server configs back to raw JSON values.
|
||||
let mcp_servers: Vec<serde_json::Value> = pc
|
||||
.mcp_servers
|
||||
.iter()
|
||||
.filter_map(|s| serde_json::to_value(s).ok())
|
||||
.collect();
|
||||
|
||||
// Convert typed Hooks to HooksData.
|
||||
let hooks = pc.hooks.map(|h| HooksData {
|
||||
on_start: h.on_start,
|
||||
on_stop: h.on_stop,
|
||||
on_message: h.on_message,
|
||||
});
|
||||
|
||||
Ok(LoadedPersona {
|
||||
source_path: path.to_path_buf(),
|
||||
name: pc.name,
|
||||
display_name: pc.display_name,
|
||||
description: pc.description,
|
||||
avatar: pc.avatar,
|
||||
model: resolved.model,
|
||||
runtime: pc.runtime.clone(),
|
||||
temperature: resolved.temperature,
|
||||
max_context_tokens: resolved.max_context_tokens,
|
||||
subscribe: resolved.subscribe.unwrap_or_default(),
|
||||
triggers: resolved.triggers,
|
||||
thread_replies: resolved.thread_replies,
|
||||
broadcast_replies: resolved.broadcast_replies,
|
||||
skills: pc.skills,
|
||||
mcp_servers,
|
||||
hooks,
|
||||
prompt: pc.prompt,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn make_pack(dir: &TempDir, personas: &[(&str, &str)]) -> PathBuf {
|
||||
let root = dir.path();
|
||||
|
||||
// .plugin/plugin.json
|
||||
fs::create_dir_all(root.join(".plugin")).unwrap();
|
||||
let persona_paths: Vec<String> = personas
|
||||
.iter()
|
||||
.map(|(name, _)| format!("personas/{name}.persona.md"))
|
||||
.collect();
|
||||
let manifest = serde_json::json!({
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "0.1.0",
|
||||
"personas": persona_paths,
|
||||
});
|
||||
fs::write(
|
||||
root.join(".plugin/plugin.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// personas/
|
||||
fs::create_dir_all(root.join("personas")).unwrap();
|
||||
for (name, content) in personas {
|
||||
fs::write(root.join(format!("personas/{name}.persona.md")), content).unwrap();
|
||||
}
|
||||
|
||||
root.to_path_buf()
|
||||
}
|
||||
|
||||
const SIMPLE_PERSONA: &str = r#"---
|
||||
name: berry
|
||||
display_name: Berry
|
||||
description: A fast worker
|
||||
---
|
||||
You are Berry, a fast and direct worker.
|
||||
"#;
|
||||
|
||||
#[test]
|
||||
fn load_valid_pack() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
|
||||
let pack = load_pack(&root).unwrap();
|
||||
|
||||
assert_eq!(pack.manifest.id, "test-pack");
|
||||
assert_eq!(pack.personas.len(), 1);
|
||||
assert_eq!(pack.personas[0].name, "berry");
|
||||
assert_eq!(pack.personas[0].display_name, "Berry");
|
||||
assert!(pack.personas[0].prompt.contains("fast and direct"));
|
||||
// Built-in defaults
|
||||
assert!(pack.personas[0].thread_replies);
|
||||
assert!(!pack.personas[0].broadcast_replies);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pack_with_instructions_and_mcp() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
|
||||
|
||||
fs::write(root.join("instructions.md"), "Pack-level instructions.").unwrap();
|
||||
fs::write(root.join(".mcp.json"), r#"{"mcpServers": {}}"#).unwrap();
|
||||
|
||||
let pack = load_pack(&root).unwrap();
|
||||
assert_eq!(
|
||||
pack.pack_instructions.as_deref(),
|
||||
Some("Pack-level instructions.")
|
||||
);
|
||||
assert!(pack.shared_mcp_config.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_pack_skills_dir_detected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = make_pack(&dir, &[("berry", SIMPLE_PERSONA)]);
|
||||
fs::create_dir_all(root.join("skills")).unwrap();
|
||||
|
||||
let pack = load_pack(&root).unwrap();
|
||||
assert!(pack.skills_dir.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_plugin_json_returns_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let err = load_pack(dir.path()).unwrap_err();
|
||||
assert!(matches!(err, PackError::ManifestNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_persona_file_returns_error() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::create_dir_all(root.join(".plugin")).unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"id": "x", "name": "X", "version": "0.1.0",
|
||||
"personas": ["personas/ghost.persona.md"],
|
||||
});
|
||||
fs::write(
|
||||
root.join(".plugin/plugin.json"),
|
||||
serde_json::to_string(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.join("personas")).unwrap();
|
||||
|
||||
let err = load_pack(root).unwrap_err();
|
||||
assert!(matches!(err, PackError::PersonaNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotdot_component_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let err = safe_resolve(&root, "../../etc/passwd").unwrap_err();
|
||||
assert!(matches!(err, PackError::PathTraversal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dotdot_in_middle_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let err = safe_resolve(&root, "personas/../../../etc/passwd").unwrap_err();
|
||||
assert!(matches!(err, PackError::PathTraversal(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normal_path_resolves_ok() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
fs::create_dir_all(root.join("personas")).unwrap();
|
||||
let target = root.join("personas/berry.persona.md");
|
||||
fs::write(&target, "hello").unwrap();
|
||||
let resolved = safe_resolve(&root, "personas/berry.persona.md").unwrap();
|
||||
assert_eq!(resolved, target.canonicalize().unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn symlink_escape_rejected() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
fs::create_dir_all(root.join("personas")).unwrap();
|
||||
|
||||
// Create a symlink inside the pack that points outside.
|
||||
let outside = TempDir::new().unwrap();
|
||||
let outside_file = outside.path().join("secret.txt");
|
||||
fs::write(&outside_file, "secret").unwrap();
|
||||
|
||||
symlink(&outside_file, root.join("personas/escape.persona.md")).unwrap();
|
||||
|
||||
let err = safe_resolve(&root, "personas/escape.persona.md").unwrap_err();
|
||||
assert!(matches!(err, PackError::PathEscape(_)));
|
||||
}
|
||||
|
||||
fn make_loaded_persona(name: &str, skills: Vec<&str>) -> LoadedPersona {
|
||||
LoadedPersona {
|
||||
source_path: PathBuf::from(format!("{name}.persona.md")),
|
||||
name: name.to_owned(),
|
||||
display_name: name.to_owned(),
|
||||
description: String::new(),
|
||||
avatar: None,
|
||||
model: None,
|
||||
runtime: None,
|
||||
temperature: None,
|
||||
max_context_tokens: None,
|
||||
subscribe: vec![],
|
||||
triggers: None,
|
||||
thread_replies: true,
|
||||
broadcast_replies: false,
|
||||
skills: skills.into_iter().map(str::to_owned).collect(),
|
||||
mcp_servers: vec![],
|
||||
hooks: None,
|
||||
prompt: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listed_skills_go_to_specific_persona() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path();
|
||||
let skills_dir = root.join("skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::create_dir_all(skills_dir.join("web-search")).unwrap();
|
||||
fs::create_dir_all(skills_dir.join("code-review")).unwrap();
|
||||
|
||||
let personas = vec![
|
||||
make_loaded_persona("alpha", vec!["web-search"]),
|
||||
make_loaded_persona("beta", vec![]),
|
||||
];
|
||||
|
||||
let map = resolve_skills(root, &personas);
|
||||
|
||||
// alpha claimed "web-search" → only alpha gets it
|
||||
assert!(map["alpha"].contains(&"web-search".to_owned()));
|
||||
// "code-review" is unclaimed → both get it
|
||||
assert!(map["alpha"].contains(&"code-review".to_owned()));
|
||||
assert!(map["beta"].contains(&"code-review".to_owned()));
|
||||
// beta did NOT claim "web-search" and it was claimed → beta doesn't get it
|
||||
assert!(!map["beta"].contains(&"web-search".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclaimed_skills_go_to_all_personas() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path();
|
||||
let skills_dir = root.join("skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::create_dir_all(skills_dir.join("shared-skill")).unwrap();
|
||||
|
||||
let personas = vec![
|
||||
make_loaded_persona("alpha", vec![]),
|
||||
make_loaded_persona("beta", vec![]),
|
||||
];
|
||||
|
||||
let map = resolve_skills(root, &personas);
|
||||
assert!(map["alpha"].contains(&"shared-skill".to_owned()));
|
||||
assert!(map["beta"].contains(&"shared-skill".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_skills_dir_returns_empty() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let personas = vec![make_loaded_persona("alpha", vec![])];
|
||||
let map = resolve_skills(dir.path(), &personas);
|
||||
assert!(map["alpha"].is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claimed_skill_paths_normalized_to_bare_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path();
|
||||
let skills_dir = root.join("skills");
|
||||
fs::create_dir_all(&skills_dir).unwrap();
|
||||
fs::create_dir_all(skills_dir.join("web-search")).unwrap();
|
||||
|
||||
// Persona claims skill via a path with prefix and trailing slash.
|
||||
let personas = vec![make_loaded_persona("alpha", vec!["./skills/web-search/"])];
|
||||
|
||||
let map = resolve_skills(root, &personas);
|
||||
|
||||
// Output must be the bare name, not the raw path.
|
||||
assert!(map["alpha"].contains(&"web-search".to_owned()));
|
||||
assert!(!map["alpha"].iter().any(|s| s.contains('/')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pack_defaults_applied_to_persona() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let root = dir.path();
|
||||
|
||||
fs::create_dir_all(root.join(".plugin")).unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"id": "test-pack",
|
||||
"name": "Test Pack",
|
||||
"version": "0.1.0",
|
||||
"personas": ["personas/berry.persona.md"],
|
||||
"defaults": {
|
||||
"model": "claude-3-sonnet",
|
||||
"thread_replies": false,
|
||||
}
|
||||
});
|
||||
fs::write(
|
||||
root.join(".plugin/plugin.json"),
|
||||
serde_json::to_string_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
fs::create_dir_all(root.join("personas")).unwrap();
|
||||
// Persona does NOT set model or thread_replies → should inherit defaults
|
||||
fs::write(
|
||||
root.join("personas/berry.persona.md"),
|
||||
"---\nname: berry\ndisplay_name: Berry\ndescription: Fast\n---\nYou are Berry.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = load_pack(root).unwrap();
|
||||
let p = &pack.personas[0];
|
||||
assert_eq!(p.model.as_deref(), Some("claude-3-sonnet"));
|
||||
assert!(!p.thread_replies);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,645 @@
|
||||
//! Core persona types and `.persona.md` parser.
|
||||
//!
|
||||
//! A `.persona.md` file is YAML frontmatter (between `---` delimiters)
|
||||
//! followed by a markdown body that becomes the system prompt.
|
||||
//!
|
||||
//! ```text
|
||||
//! ---
|
||||
//! name: my-bot
|
||||
//! display_name: My Bot
|
||||
//! description: Does things.
|
||||
//! ---
|
||||
//! You are My Bot. You do things.
|
||||
//! ```
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Maximum YAML frontmatter size in bytes (1 MiB).
|
||||
pub const MAX_FRONTMATTER_BYTES: usize = 1_048_576;
|
||||
|
||||
/// Maximum persona prompt (markdown body) size in bytes (256 KiB).
|
||||
pub const MAX_BODY_BYTES: usize = 262_144;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PersonaError {
|
||||
#[error("failed to read file: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("missing `---` frontmatter delimiters")]
|
||||
NoFrontmatter,
|
||||
|
||||
#[error("frontmatter exceeds {MAX_FRONTMATTER_BYTES} bytes")]
|
||||
FrontmatterTooLarge,
|
||||
|
||||
#[error("body exceeds {MAX_BODY_BYTES} bytes")]
|
||||
BodyTooLarge,
|
||||
|
||||
#[error("file too large: {0}")]
|
||||
TooLarge(String),
|
||||
|
||||
#[error("failed to parse YAML frontmatter: {0}")]
|
||||
Yaml(#[from] serde_yaml::Error),
|
||||
|
||||
#[error("missing required field: {0}")]
|
||||
MissingField(String),
|
||||
}
|
||||
|
||||
/// Controls which messages trigger a response.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct RespondTo {
|
||||
/// Respond when mentioned. Default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub mentions: Option<bool>,
|
||||
|
||||
/// Respond when any of these keywords appear.
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
/// Respond to every message in subscribed channels. Default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub all_messages: Option<bool>,
|
||||
}
|
||||
|
||||
/// A single MCP server attached to this persona.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct McpServerConfig {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
|
||||
#[serde(default)]
|
||||
pub args: Vec<String>,
|
||||
|
||||
#[serde(default)]
|
||||
pub env: HashMap<String, String>,
|
||||
}
|
||||
|
||||
/// Lifecycle hooks (paths are pack-relative).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
pub struct Hooks {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_start: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_stop: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub on_message: Option<String>,
|
||||
}
|
||||
|
||||
/// Typed representation of a `.persona.md` file (V7 spec).
|
||||
///
|
||||
/// The `prompt` field holds the markdown body (system prompt).
|
||||
/// All other fields come from the YAML frontmatter.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub struct PersonaConfig {
|
||||
/// Machine name (slug). Required.
|
||||
pub name: String,
|
||||
|
||||
/// Human-readable display name. Required.
|
||||
pub display_name: String,
|
||||
|
||||
/// Pack-relative path to avatar image.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub avatar: Option<String>,
|
||||
|
||||
/// One-line description. Required.
|
||||
pub description: String,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub version: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub author: Option<String>,
|
||||
|
||||
/// Pack-relative paths to skill directories.
|
||||
#[serde(default)]
|
||||
pub skills: Vec<String>,
|
||||
|
||||
/// Per-persona MCP server definitions.
|
||||
#[serde(default)]
|
||||
pub mcp_servers: Vec<McpServerConfig>,
|
||||
|
||||
/// Channel names to monitor.
|
||||
///
|
||||
/// - `None` (omitted or `null`) → fall through to pack default
|
||||
/// - `Some(vec![])` → intentional "subscribe to nothing"
|
||||
/// - `Some(vec!["#general"])` → explicit channels
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub subscribe: Option<Vec<String>>,
|
||||
|
||||
/// Message matching triggers.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub triggers: Option<RespondTo>,
|
||||
|
||||
/// Model string in `"provider:model-id"` format.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Preferred ACP runtime ID (e.g., 'goose', 'claude'). Maps to AgentDefinition.runtime during
|
||||
/// pack import.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub runtime: Option<String>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub temperature: Option<f64>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub max_context_tokens: Option<u64>,
|
||||
|
||||
/// Reply in-thread. Default: true.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub thread_replies: Option<bool>,
|
||||
|
||||
/// Broadcast replies to the channel. Default: false.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub broadcast_replies: Option<bool>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub hooks: Option<Hooks>,
|
||||
|
||||
/// The markdown body of the `.persona.md` file.
|
||||
#[serde(default)]
|
||||
pub prompt: String,
|
||||
}
|
||||
|
||||
/// Deserializes just the YAML frontmatter (no `prompt`).
|
||||
/// Unknown keys are rejected — typos cause parse errors instead of silent drops.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "snake_case", deny_unknown_fields)]
|
||||
struct Frontmatter {
|
||||
name: Option<String>,
|
||||
display_name: Option<String>,
|
||||
avatar: Option<String>,
|
||||
description: Option<String>,
|
||||
version: Option<String>,
|
||||
author: Option<String>,
|
||||
#[serde(default)]
|
||||
skills: Vec<String>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Vec<McpServerConfig>,
|
||||
#[serde(default)]
|
||||
subscribe: Option<Vec<String>>,
|
||||
#[serde(alias = "respond_to")]
|
||||
triggers: Option<RespondTo>,
|
||||
model: Option<String>,
|
||||
runtime: Option<String>,
|
||||
temperature: Option<f64>,
|
||||
max_context_tokens: Option<u64>,
|
||||
thread_replies: Option<bool>,
|
||||
broadcast_replies: Option<bool>,
|
||||
hooks: Option<Hooks>,
|
||||
}
|
||||
|
||||
/// Parse a `.persona.md` file into a [`PersonaConfig`].
|
||||
///
|
||||
/// Expects YAML frontmatter between `---` delimiters followed by a markdown
|
||||
/// body. The body becomes `PersonaConfig::prompt`.
|
||||
///
|
||||
/// # Limits
|
||||
/// - Frontmatter: max 1 MiB
|
||||
/// - Body: max 256 KiB
|
||||
pub fn parse_persona_md(content: &str) -> Result<PersonaConfig, PersonaError> {
|
||||
let (fm_str, body) = split_frontmatter(content)?;
|
||||
|
||||
if fm_str.len() > MAX_FRONTMATTER_BYTES {
|
||||
return Err(PersonaError::FrontmatterTooLarge);
|
||||
}
|
||||
if body.len() > MAX_BODY_BYTES {
|
||||
return Err(PersonaError::BodyTooLarge);
|
||||
}
|
||||
|
||||
let fm: Frontmatter = serde_yaml::from_str(fm_str)?;
|
||||
|
||||
let name = fm.name.ok_or(PersonaError::MissingField("name".into()))?;
|
||||
let display_name = fm
|
||||
.display_name
|
||||
.ok_or(PersonaError::MissingField("display_name".into()))?;
|
||||
let description = fm
|
||||
.description
|
||||
.ok_or(PersonaError::MissingField("description".into()))?;
|
||||
|
||||
// Fix #1: enforce non-empty required strings
|
||||
if name.trim().is_empty() {
|
||||
return Err(PersonaError::MissingField("name (empty)".into()));
|
||||
}
|
||||
if display_name.trim().is_empty() {
|
||||
return Err(PersonaError::MissingField("display_name (empty)".into()));
|
||||
}
|
||||
if description.trim().is_empty() {
|
||||
return Err(PersonaError::MissingField("description (empty)".into()));
|
||||
}
|
||||
|
||||
Ok(PersonaConfig {
|
||||
name,
|
||||
display_name,
|
||||
avatar: fm.avatar,
|
||||
description,
|
||||
version: fm.version,
|
||||
author: fm.author,
|
||||
skills: fm.skills,
|
||||
mcp_servers: fm.mcp_servers,
|
||||
subscribe: fm.subscribe,
|
||||
triggers: fm.triggers,
|
||||
model: fm.model,
|
||||
runtime: fm.runtime,
|
||||
temperature: fm.temperature,
|
||||
max_context_tokens: fm.max_context_tokens,
|
||||
thread_replies: fm.thread_replies,
|
||||
broadcast_replies: fm.broadcast_replies,
|
||||
hooks: fm.hooks,
|
||||
prompt: body.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse a `.persona.md` file from disk.
|
||||
pub fn parse_persona_file(path: &Path) -> Result<PersonaConfig, PersonaError> {
|
||||
// Fix #4: check file size before reading to avoid large allocations
|
||||
let metadata = std::fs::metadata(path)?;
|
||||
if metadata.len() > MAX_FRONTMATTER_BYTES as u64 + MAX_BODY_BYTES as u64 + 100 {
|
||||
return Err(PersonaError::TooLarge("file exceeds maximum size".into()));
|
||||
}
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
parse_persona_md(&content)
|
||||
}
|
||||
|
||||
/// Split content into `(frontmatter_str, body_str)`.
|
||||
///
|
||||
/// Expects the file to begin with `---\n` and contain a second `---` line.
|
||||
/// The closing `---` must be on its own line: followed by `\n`, `\r\n`, or EOF.
|
||||
/// A line like `---junk` is NOT treated as a closing delimiter.
|
||||
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), PersonaError> {
|
||||
// Must start with "---"
|
||||
let rest = content
|
||||
.strip_prefix("---")
|
||||
.ok_or(PersonaError::NoFrontmatter)?;
|
||||
|
||||
// Skip optional \r after the opening ---
|
||||
let rest = rest.strip_prefix('\r').unwrap_or(rest);
|
||||
let rest = rest.strip_prefix('\n').ok_or(PersonaError::NoFrontmatter)?;
|
||||
|
||||
// Find the closing --- that is on its own line (followed by \r\n, \n, or EOF).
|
||||
// A line like "---junk" is not a valid delimiter — keep searching.
|
||||
let mut search_from = 0;
|
||||
let close = loop {
|
||||
let pos = rest[search_from..]
|
||||
.find("\n---")
|
||||
.map(|p| p + search_from)
|
||||
.ok_or(PersonaError::NoFrontmatter)?;
|
||||
let after_dashes = pos + 4; // position after "\n---"
|
||||
if after_dashes >= rest.len() {
|
||||
// "---" at EOF — valid closing delimiter
|
||||
break pos;
|
||||
}
|
||||
match rest.as_bytes().get(after_dashes) {
|
||||
Some(b'\n') | Some(b'\r') => break pos, // valid delimiter
|
||||
_ => {
|
||||
search_from = after_dashes; // not a delimiter, keep looking
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let fm_str = &rest[..close];
|
||||
let after_close = &rest[close + 4..]; // skip "\n---"
|
||||
|
||||
// Skip optional \r\n or \n after closing ---
|
||||
let body = after_close
|
||||
.strip_prefix("\r\n")
|
||||
.or_else(|| after_close.strip_prefix('\n'))
|
||||
.unwrap_or(after_close);
|
||||
|
||||
Ok((fm_str, body))
|
||||
}
|
||||
|
||||
/// Split `"provider:model-id"` into `(Some("provider"), "model-id")`.
|
||||
///
|
||||
/// If there is no colon, returns `(None, full_string)`.
|
||||
pub fn split_model(model: &str) -> (Option<&str>, &str) {
|
||||
match model.split_once(':') {
|
||||
Some((provider, id)) => (Some(provider), id),
|
||||
None => (None, model),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn minimal() -> &'static str {
|
||||
"---\nname: my-bot\ndisplay_name: My Bot\ndescription: Does things.\n---\n"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_minimal_valid() {
|
||||
let p = parse_persona_md(minimal()).unwrap();
|
||||
assert_eq!(p.name, "my-bot");
|
||||
assert_eq!(p.display_name, "My Bot");
|
||||
assert_eq!(p.description, "Does things.");
|
||||
assert_eq!(p.prompt, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_with_body() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\nYou are Bot.\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(p.prompt, "You are Bot.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body_is_valid() {
|
||||
let p = parse_persona_md(minimal()).unwrap();
|
||||
assert_eq!(p.prompt, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_full_fields() {
|
||||
let src = indoc(
|
||||
"---
|
||||
name: full-bot
|
||||
display_name: Full Bot
|
||||
avatar: assets/avatar.png
|
||||
description: Full featured.
|
||||
version: 1.2.3
|
||||
author: Tyler
|
||||
skills:
|
||||
- skills/search
|
||||
mcp_servers:
|
||||
- name: my-mcp
|
||||
command: npx
|
||||
args: ['-y', 'my-server']
|
||||
env:
|
||||
TOKEN: abc123
|
||||
subscribe:
|
||||
- '#general'
|
||||
respond_to:
|
||||
mentions: true
|
||||
keywords: [hello, help]
|
||||
all_messages: false
|
||||
model: openai:gpt-4o
|
||||
temperature: 0.7
|
||||
max_context_tokens: 8192
|
||||
thread_replies: true
|
||||
broadcast_replies: false
|
||||
hooks:
|
||||
on_start: hooks/start.sh
|
||||
on_stop: hooks/stop.sh
|
||||
---
|
||||
You are Full Bot.
|
||||
",
|
||||
);
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(p.name, "full-bot");
|
||||
assert_eq!(p.avatar.as_deref(), Some("assets/avatar.png"));
|
||||
assert_eq!(p.skills, vec!["skills/search"]);
|
||||
assert_eq!(p.mcp_servers.len(), 1);
|
||||
assert_eq!(p.mcp_servers[0].name, "my-mcp");
|
||||
assert_eq!(p.mcp_servers[0].env["TOKEN"], "abc123");
|
||||
assert_eq!(p.subscribe, Some(vec!["#general".to_owned()]));
|
||||
let rt = p.triggers.unwrap();
|
||||
assert_eq!(rt.keywords, vec!["hello", "help"]);
|
||||
assert_eq!(p.model.as_deref(), Some("openai:gpt-4o"));
|
||||
assert_eq!(p.temperature, Some(0.7));
|
||||
assert_eq!(p.max_context_tokens, Some(8192));
|
||||
let hooks = p.hooks.unwrap();
|
||||
assert_eq!(hooks.on_start.as_deref(), Some("hooks/start.sh"));
|
||||
assert_eq!(p.prompt, "You are Full Bot.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_frontmatter_keys_error() {
|
||||
// deny_unknown_fields: typos in frontmatter keys cause a parse error.
|
||||
let src =
|
||||
"---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nunknown_key: surprise\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::Yaml(_)), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_hook_key_errors() {
|
||||
// deny_unknown_fields on Hooks means a typo like "on_init" is caught.
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nhooks:\n on_start: hooks/start.sh\n on_init: hooks/init.sh\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::Yaml(_)), "got: {err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_name_errors() {
|
||||
let src = "---\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f == "name"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_display_name_errors() {
|
||||
let src = "---\nname: bot\ndescription: A bot.\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f == "display_name"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_description_errors() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f == "description"),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_name_errors() {
|
||||
let src = "---\nname: \"\"\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f.contains("name")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_only_name_errors() {
|
||||
let src = "---\nname: \" \"\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f.contains("name")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_display_name_errors() {
|
||||
let src = "---\nname: bot\ndisplay_name: \"\"\ndescription: A bot.\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f.contains("display_name")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_description_errors() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: \"\"\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, PersonaError::MissingField(f) if f.contains("description")),
|
||||
"got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_frontmatter_delimiters_errors() {
|
||||
let err = parse_persona_md("Just plain markdown.").unwrap_err();
|
||||
assert!(matches!(err, PersonaError::NoFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_closing_delimiter_errors() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::NoFrontmatter));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_delimiter_with_trailing_junk_is_not_valid() {
|
||||
// "---junk" must NOT be treated as a closing delimiter.
|
||||
// The parser should keep searching and ultimately return NoFrontmatter.
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---junk\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(
|
||||
matches!(err, PersonaError::NoFrontmatter),
|
||||
"expected NoFrontmatter, got: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn closing_delimiter_with_junk_skipped_finds_real_close() {
|
||||
// A "---junk" line inside a YAML block scalar should be skipped;
|
||||
// the real "---" on its own line should still be found.
|
||||
// Use a literal block scalar (|) so "---junk" is valid YAML content.
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: |\n some text\n ---junk\n---\nBody here.\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(p.name, "bot");
|
||||
assert_eq!(p.prompt, "Body here.\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_yaml_errors() {
|
||||
let src = "---\n: bad: yaml: here\n---\n";
|
||||
let err = parse_persona_md(src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::Yaml(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontmatter_too_large_errors() {
|
||||
// Build a frontmatter that exceeds 1 MiB
|
||||
let big = "x".repeat(MAX_FRONTMATTER_BYTES + 1);
|
||||
let src = format!("---\n{big}\n---\n");
|
||||
let err = parse_persona_md(&src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::FrontmatterTooLarge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn body_too_large_errors() {
|
||||
let big = "x".repeat(MAX_BODY_BYTES + 1);
|
||||
let src = format!("---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\n{big}");
|
||||
let err = parse_persona_md(&src).unwrap_err();
|
||||
assert!(matches!(err, PersonaError::BodyTooLarge));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_model_with_colon() {
|
||||
let (provider, id) = split_model("openai:gpt-4o");
|
||||
assert_eq!(provider, Some("openai"));
|
||||
assert_eq!(id, "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_model_without_colon() {
|
||||
let (provider, id) = split_model("gpt-4o");
|
||||
assert_eq!(provider, None);
|
||||
assert_eq!(id, "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_model_multiple_colons_uses_first() {
|
||||
let (provider, id) = split_model("databricks:gpt-5:preview");
|
||||
assert_eq!(provider, Some("databricks"));
|
||||
assert_eq!(id, "gpt-5:preview");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_subscribe_null_is_none() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe: null\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(p.subscribe, None, "YAML null should deserialize to None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_subscribe_empty_is_some_empty() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe: []\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(
|
||||
p.subscribe,
|
||||
Some(vec![]),
|
||||
"YAML [] should deserialize to Some(empty vec)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_subscribe_omitted_is_none() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(p.subscribe, None, "omitted subscribe should be None");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_triggers_canonical_key() {
|
||||
// `triggers:` is the canonical YAML key (spec Section 4).
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\ntriggers:\n mentions: true\n keywords: [hello, help]\n all_messages: false\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
let t = p.triggers.expect("triggers should be Some");
|
||||
assert_eq!(t.mentions, Some(true));
|
||||
assert_eq!(t.keywords, vec!["hello", "help"]);
|
||||
assert_eq!(t.all_messages, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_triggers_legacy_respond_to_alias() {
|
||||
// `respond_to:` is accepted as a legacy alias for `triggers:`.
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nrespond_to:\n mentions: true\n keywords: [hello, help]\n all_messages: false\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
let t = p
|
||||
.triggers
|
||||
.expect("triggers should be Some via respond_to alias");
|
||||
assert_eq!(t.mentions, Some(true));
|
||||
assert_eq!(t.keywords, vec!["hello", "help"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_subscribe_with_channels() {
|
||||
let src = "---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nsubscribe:\n - \"#general\"\n - \"#random\"\n---\n";
|
||||
let p = parse_persona_md(src).unwrap();
|
||||
assert_eq!(
|
||||
p.subscribe,
|
||||
Some(vec!["#general".to_owned(), "#random".to_owned()])
|
||||
);
|
||||
}
|
||||
|
||||
/// Trim leading newline from indented string literals.
|
||||
fn indoc(s: &str) -> &str {
|
||||
s.strip_prefix('\n').unwrap_or(s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
//! Pack resolution: produces fully resolved, ACP-ready output.
|
||||
//!
|
||||
//! `resolve_pack()` is the main entry point. It loads a pack directory,
|
||||
//! applies merge policy, composes prompts, merges MCP servers, and projects
|
||||
//! env vars. The output (`ResolvedPack`) is designed backward from ACP's
|
||||
//! `Config` — every field maps directly to what the runtime consumes.
|
||||
//!
|
||||
//! Design principles:
|
||||
//! - **Pure**: no env access, no network, no side effects.
|
||||
//! - **Complete**: all merge/compose/project logic lives here.
|
||||
//! - **ACP-shaped**: `ResolvedPersona` maps 1:1 to ACP's needs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::merge::TriggersData;
|
||||
use crate::pack::{self, LoadedPack, LoadedPersona, PackError};
|
||||
use crate::persona::split_model;
|
||||
|
||||
/// A fully resolved persona — ready for ACP consumption.
|
||||
/// All merge, composition, and projection is done.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedPersona {
|
||||
// Identity
|
||||
pub name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub avatar: Option<String>,
|
||||
pub version: String,
|
||||
|
||||
// → Config.system_prompt (persona body only)
|
||||
pub system_prompt: String,
|
||||
/// Pack-owned instructions kept separate for the team model migration.
|
||||
pub pack_instructions: Option<String>,
|
||||
|
||||
// → Config.model (plain model ID, post-split)
|
||||
pub model: Option<String>,
|
||||
/// LLM inference provider extracted from the model string colon prefix (e.g., 'databricks'
|
||||
/// from 'databricks:model-id'). Flows into harness-specific env vars (GOOSE_PROVIDER) only.
|
||||
pub llm_provider: Option<String>,
|
||||
/// Preferred ACP runtime ID from the persona config (e.g., 'goose', 'claude'). Maps to
|
||||
/// AgentDefinition.runtime during pack import.
|
||||
pub runtime: Option<String>,
|
||||
pub temperature: Option<f64>,
|
||||
pub max_context_tokens: Option<u64>,
|
||||
|
||||
// → Config.subscribe_mode + channels_override
|
||||
pub subscribe: Vec<String>,
|
||||
// → mapped to ACP filter rules at startup
|
||||
pub triggers: ResolvedTriggers,
|
||||
pub thread_replies: bool,
|
||||
pub broadcast_replies: bool,
|
||||
|
||||
// Effective MCP (pack shared + persona merged, literals preserved)
|
||||
pub mcp_servers: Vec<ResolvedMcpServer>,
|
||||
|
||||
// Hooks (parsed, not executed — reserved for future use, not yet wired)
|
||||
pub hooks: Option<ResolvedHooks>,
|
||||
|
||||
// Skills (bare names — reserved for future use, not yet wired)
|
||||
pub skills: Vec<String>,
|
||||
|
||||
// Env var projection for agent subprocess
|
||||
pub runtime_env_vars: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// An MCP server with env values as literals (no interpolation in this PR).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedMcpServer {
|
||||
pub name: String,
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Lifecycle hooks (pack-relative paths).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedHooks {
|
||||
pub on_start: Option<String>,
|
||||
pub on_stop: Option<String>,
|
||||
pub on_message: Option<String>,
|
||||
}
|
||||
|
||||
/// What triggers a response (renamed from respond_to per spec discussion).
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ResolvedTriggers {
|
||||
pub mentions: bool,
|
||||
pub keywords: Vec<String>,
|
||||
pub all_messages: bool,
|
||||
}
|
||||
|
||||
/// A fully resolved pack.
|
||||
#[derive(Debug)]
|
||||
pub struct ResolvedPack {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub personas: Vec<ResolvedPersona>,
|
||||
}
|
||||
|
||||
/// Load, validate, merge, and resolve a pack directory.
|
||||
///
|
||||
/// Returns a `ResolvedPack` with fully typed, ACP-ready output for each
|
||||
/// persona. All merge policy (levels 3-5) is applied. MCP servers are
|
||||
/// merged with literal env passthrough (no `${VAR}` interpolation).
|
||||
/// Env vars are projected from model/temperature/context config.
|
||||
pub fn resolve_pack(pack_dir: &Path) -> Result<ResolvedPack, PackError> {
|
||||
let loaded = pack::load_pack(pack_dir)?;
|
||||
resolve_loaded_pack(&loaded)
|
||||
}
|
||||
|
||||
/// Resolve from an already-loaded pack. Useful when you've already called
|
||||
/// `load_pack()` and want to avoid re-reading the filesystem.
|
||||
///
|
||||
/// Runs semantic validation (zero personas, duplicate names, invalid slugs)
|
||||
/// before resolution. Returns `PackError` on failure.
|
||||
pub fn resolve_loaded_pack(loaded: &LoadedPack) -> Result<ResolvedPack, PackError> {
|
||||
// Semantic validation — catch issues that load_pack() doesn't check.
|
||||
if loaded.personas.is_empty() {
|
||||
return Err(PackError::ManifestParse(
|
||||
"pack contains zero personas".into(),
|
||||
));
|
||||
}
|
||||
let mut seen_names = std::collections::HashSet::new();
|
||||
for p in &loaded.personas {
|
||||
if !seen_names.insert(&p.name) {
|
||||
return Err(PackError::FileParse {
|
||||
path: p.source_path.clone(),
|
||||
reason: format!("duplicate persona name \"{}\"", p.name),
|
||||
});
|
||||
}
|
||||
if !p
|
||||
.name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
{
|
||||
return Err(PackError::FileParse {
|
||||
path: p.source_path.clone(),
|
||||
reason: format!(
|
||||
"persona name \"{}\" contains invalid characters (allowed: [a-zA-Z0-9_-])",
|
||||
p.name
|
||||
),
|
||||
});
|
||||
}
|
||||
if p.name.len() > 64 {
|
||||
return Err(PackError::FileParse {
|
||||
path: p.source_path.clone(),
|
||||
reason: format!(
|
||||
"persona name \"{}\" exceeds 64 characters (got {})",
|
||||
p.name,
|
||||
p.name.len()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let pack_version = &loaded.manifest.version;
|
||||
let pack_instructions = loaded.pack_instructions.as_deref();
|
||||
let shared_mcp = loaded.shared_mcp_config.as_ref();
|
||||
|
||||
let mut personas = Vec::with_capacity(loaded.personas.len());
|
||||
for lp in &loaded.personas {
|
||||
personas.push(resolve_one_persona(
|
||||
lp,
|
||||
pack_version,
|
||||
pack_instructions,
|
||||
shared_mcp,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ResolvedPack {
|
||||
id: loaded.manifest.id.clone(),
|
||||
name: loaded.manifest.name.clone(),
|
||||
version: loaded.manifest.version.clone(),
|
||||
// Pack-level description not yet wired through PackManifestData.
|
||||
description: loaded.manifest.description.clone().unwrap_or_default(),
|
||||
personas,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve a single persona by name from a pack directory.
|
||||
///
|
||||
/// Convenience wrapper: loads the pack, finds the named persona, resolves it.
|
||||
/// Returns `PackError::PersonaNotFound` if no persona with that name exists.
|
||||
pub fn resolve_persona_by_name(pack_dir: &Path, name: &str) -> Result<ResolvedPersona, PackError> {
|
||||
let pack = resolve_pack(pack_dir)?;
|
||||
pack.personas
|
||||
.into_iter()
|
||||
.find(|p| p.name == name)
|
||||
.ok_or_else(|| PackError::PersonaNotFound(pack_dir.join(name)))
|
||||
}
|
||||
|
||||
fn resolve_one_persona(
|
||||
lp: &LoadedPersona,
|
||||
pack_version: &str,
|
||||
pack_instructions: Option<&str>,
|
||||
shared_mcp: Option<&serde_json::Value>,
|
||||
) -> ResolvedPersona {
|
||||
let system_prompt = lp.prompt.clone();
|
||||
let pack_instructions = pack_instructions
|
||||
.map(str::trim)
|
||||
.filter(|instructions| !instructions.is_empty())
|
||||
.map(str::to_string);
|
||||
|
||||
// Split "provider:model-id" into separate fields (V3 contract).
|
||||
let (llm_provider, model) = match lp.model.as_deref() {
|
||||
Some(s) if !s.trim().is_empty() => {
|
||||
let (prov, id) = split_model(s);
|
||||
(
|
||||
prov.filter(|p| !p.is_empty()).map(str::to_owned),
|
||||
Some(id.to_owned()),
|
||||
)
|
||||
}
|
||||
_ => (None, None),
|
||||
};
|
||||
|
||||
let triggers = resolve_triggers(lp.triggers.as_ref());
|
||||
let mcp_servers = merge_mcp_servers(shared_mcp, &lp.mcp_servers);
|
||||
let hooks = resolve_hooks(lp.hooks.as_ref());
|
||||
let runtime_env_vars = runtime_env_vars(lp);
|
||||
|
||||
// Version: LoadedPersona has no per-persona version field — persona files
|
||||
// don't declare a version in frontmatter. The pack version is used as-is.
|
||||
// If per-persona versioning is added in the future, LoadedPersona should
|
||||
// gain `version: Option<String>` and this line should become:
|
||||
// lp.version.clone().unwrap_or_else(|| pack_version.to_owned())
|
||||
let version = pack_version.to_owned();
|
||||
|
||||
ResolvedPersona {
|
||||
name: lp.name.clone(),
|
||||
display_name: lp.display_name.clone(),
|
||||
description: lp.description.clone(),
|
||||
avatar: lp.avatar.clone(),
|
||||
version,
|
||||
system_prompt,
|
||||
pack_instructions,
|
||||
model,
|
||||
llm_provider,
|
||||
runtime: lp.runtime.clone(),
|
||||
temperature: lp.temperature,
|
||||
max_context_tokens: lp.max_context_tokens,
|
||||
subscribe: lp.subscribe.clone(),
|
||||
triggers,
|
||||
thread_replies: lp.thread_replies,
|
||||
broadcast_replies: lp.broadcast_replies,
|
||||
mcp_servers,
|
||||
hooks,
|
||||
skills: lp.skills.clone(),
|
||||
runtime_env_vars,
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert `TriggersData` to `ResolvedTriggers`.
|
||||
fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers {
|
||||
match rt {
|
||||
Some(data) => ResolvedTriggers {
|
||||
mentions: data.mentions,
|
||||
keywords: data.keywords.clone(),
|
||||
all_messages: data.all_messages,
|
||||
},
|
||||
None => ResolvedTriggers {
|
||||
mentions: true,
|
||||
keywords: Vec::new(),
|
||||
all_messages: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Merge pack-level shared MCP servers with per-persona servers.
|
||||
///
|
||||
/// Pack shared servers come from `.mcp.json` (a map of `name → config`).
|
||||
/// Per-persona servers come from frontmatter `mcp_servers:` (a list).
|
||||
/// Name collision: persona wins (replaces pack server with same name).
|
||||
///
|
||||
/// Env values are passed through as literals — no `${VAR}` interpolation.
|
||||
fn merge_mcp_servers(
|
||||
shared_mcp: Option<&serde_json::Value>,
|
||||
persona_servers: &[serde_json::Value],
|
||||
) -> Vec<ResolvedMcpServer> {
|
||||
let mut by_name: HashMap<String, ResolvedMcpServer> = HashMap::new();
|
||||
|
||||
// 1. Pack-level shared servers from .mcp.json
|
||||
if let Some(shared) = shared_mcp {
|
||||
// .mcp.json format: { "mcpServers": { "name": { "command": ..., "args": [...], "env": {...} } } }
|
||||
if let Some(servers_obj) = shared.get("mcpServers").and_then(|v| v.as_object()) {
|
||||
for (name, config) in servers_obj {
|
||||
if let Some(server) = parse_mcp_server_config(name, config) {
|
||||
by_name.insert(name.clone(), server);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Per-persona servers (persona wins on name collision)
|
||||
for server_val in persona_servers {
|
||||
if let Some(name) = server_val.get("name").and_then(|v| v.as_str()) {
|
||||
if let Some(server) = parse_mcp_server_config(name, server_val) {
|
||||
by_name.insert(name.to_owned(), server);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return in deterministic order (sorted by name)
|
||||
let mut servers: Vec<_> = by_name.into_values().collect();
|
||||
servers.sort_by_key(|s| s.name.clone());
|
||||
servers
|
||||
}
|
||||
|
||||
/// Parse a single MCP server config from JSON.
|
||||
fn parse_mcp_server_config(name: &str, config: &serde_json::Value) -> Option<ResolvedMcpServer> {
|
||||
let command = config.get("command").and_then(|v| v.as_str())?.to_owned();
|
||||
let args = config
|
||||
.get("args")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let env = config
|
||||
.get("env")
|
||||
.and_then(|v| v.as_object())
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_owned())))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Some(ResolvedMcpServer {
|
||||
name: name.to_owned(),
|
||||
command,
|
||||
args,
|
||||
env,
|
||||
})
|
||||
}
|
||||
|
||||
/// Store hook paths as raw relative strings (no path resolution).
|
||||
///
|
||||
/// Security: we intentionally do NOT resolve these to absolute paths.
|
||||
/// Hook paths come from untrusted persona frontmatter and could contain
|
||||
/// `../` traversal. Since hooks are not executed in this PR, we store
|
||||
/// them as-is. The PR that wires execution MUST validate through
|
||||
/// `safe_resolve()` before use.
|
||||
fn resolve_hooks(hooks: Option<&crate::merge::HooksData>) -> Option<ResolvedHooks> {
|
||||
let h = hooks?;
|
||||
if h.on_start.is_none() && h.on_stop.is_none() && h.on_message.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(ResolvedHooks {
|
||||
on_start: h.on_start.clone(),
|
||||
on_stop: h.on_stop.clone(),
|
||||
on_message: h.on_message.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Project persona config into agent subprocess env vars.
|
||||
///
|
||||
/// Pure function — does NOT read the current process env.
|
||||
/// ACP is responsible for filtering based on operator precedence (level 1):
|
||||
/// if the operator already set an env var, ACP skips injection so the
|
||||
/// operator's value wins.
|
||||
fn runtime_env_vars(persona: &LoadedPersona) -> Vec<(String, String)> {
|
||||
let mut vars = Vec::new();
|
||||
let runtime = persona.runtime.as_deref();
|
||||
|
||||
if let Some(model_str) = &persona.model {
|
||||
let (provider, model_id) = split_model(model_str);
|
||||
|
||||
match runtime {
|
||||
Some("buzz-agent") => {
|
||||
vars.push(("BUZZ_AGENT_MODEL".to_owned(), model_id.to_owned()));
|
||||
if let Some(p) = provider {
|
||||
vars.push(("BUZZ_AGENT_PROVIDER".to_owned(), p.to_owned()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
if let Some(p) = provider {
|
||||
vars.push(("GOOSE_PROVIDER".to_owned(), p.to_owned()));
|
||||
}
|
||||
vars.push(("GOOSE_MODEL".to_owned(), model_id.to_owned()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// temperature and context_limit stay as GOOSE_* (only goose reads them)
|
||||
if let Some(temp) = persona.temperature {
|
||||
vars.push(("GOOSE_TEMPERATURE".to_owned(), temp.to_string()));
|
||||
}
|
||||
|
||||
if let Some(ctx) = persona.max_context_tokens {
|
||||
vars.push(("GOOSE_CONTEXT_LIMIT".to_owned(), ctx.to_string()));
|
||||
}
|
||||
|
||||
vars
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::merge::{HooksData, TriggersData};
|
||||
|
||||
#[test]
|
||||
fn triggers_from_triggers_data() {
|
||||
let data = TriggersData {
|
||||
mentions: false,
|
||||
keywords: vec!["security".into(), "CVE".into()],
|
||||
all_messages: false,
|
||||
};
|
||||
let t = resolve_triggers(Some(&data));
|
||||
assert!(!t.mentions);
|
||||
assert_eq!(t.keywords, vec!["security", "CVE"]);
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn triggers_default_when_none() {
|
||||
let t = resolve_triggers(None);
|
||||
assert!(t.mentions);
|
||||
assert!(t.keywords.is_empty());
|
||||
assert!(!t.all_messages);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_merge_shared_only() {
|
||||
let shared = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"example-mcp": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "example-mcp"],
|
||||
"env": { "TOKEN": "abc" }
|
||||
}
|
||||
}
|
||||
});
|
||||
let result = merge_mcp_servers(Some(&shared), &[]);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].name, "example-mcp");
|
||||
assert_eq!(result[0].command, "npx");
|
||||
assert_eq!(result[0].env, vec![("TOKEN".into(), "abc".into())]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_merge_persona_wins_on_collision() {
|
||||
let shared = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"my-server": {
|
||||
"command": "old-cmd",
|
||||
"args": [],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
});
|
||||
let persona = vec![serde_json::json!({
|
||||
"name": "my-server",
|
||||
"command": "new-cmd",
|
||||
"args": ["--flag"],
|
||||
"env": { "KEY": "val" }
|
||||
})];
|
||||
let result = merge_mcp_servers(Some(&shared), &persona);
|
||||
assert_eq!(result.len(), 1);
|
||||
assert_eq!(result[0].command, "new-cmd");
|
||||
assert_eq!(result[0].args, vec!["--flag"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_merge_both_sources_combined() {
|
||||
let shared = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"alpha": { "command": "alpha-cmd" }
|
||||
}
|
||||
});
|
||||
let persona = vec![serde_json::json!({
|
||||
"name": "beta",
|
||||
"command": "beta-cmd"
|
||||
})];
|
||||
let result = merge_mcp_servers(Some(&shared), &persona);
|
||||
assert_eq!(result.len(), 2);
|
||||
// Sorted by name
|
||||
assert_eq!(result[0].name, "alpha");
|
||||
assert_eq!(result[1].name, "beta");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_merge_no_shared_no_persona() {
|
||||
let result = merge_mcp_servers(None, &[]);
|
||||
assert!(result.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_env_literals_preserved() {
|
||||
// ${VAR_NAME} should pass through as-is (no interpolation in this PR)
|
||||
let persona = vec![serde_json::json!({
|
||||
"name": "test",
|
||||
"command": "cmd",
|
||||
"env": { "PATH": "${HOME}/bin", "SECRET": "${MY_SECRET}" }
|
||||
})];
|
||||
let result = merge_mcp_servers(None, &persona);
|
||||
assert_eq!(result.len(), 1);
|
||||
let env: HashMap<String, String> = result[0].env.iter().cloned().collect();
|
||||
assert_eq!(env["PATH"], "${HOME}/bin");
|
||||
assert_eq!(env["SECRET"], "${MY_SECRET}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_stored_as_raw_relative_paths() {
|
||||
// Security: hooks are stored as raw strings, NOT resolved to absolute.
|
||||
// Path traversal validation deferred to the PR that wires execution.
|
||||
let data = HooksData {
|
||||
on_start: Some("hooks/start.sh".into()),
|
||||
on_stop: Some("hooks/stop.sh".into()),
|
||||
on_message: None,
|
||||
};
|
||||
let h = resolve_hooks(Some(&data)).unwrap();
|
||||
assert_eq!(h.on_start.as_deref(), Some("hooks/start.sh"));
|
||||
assert_eq!(h.on_stop.as_deref(), Some("hooks/stop.sh"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_none_when_empty() {
|
||||
let data = HooksData {
|
||||
on_start: None,
|
||||
on_stop: None,
|
||||
on_message: None,
|
||||
};
|
||||
assert!(resolve_hooks(Some(&data)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hooks_none_when_absent() {
|
||||
assert!(resolve_hooks(None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_vars_projected_from_model() {
|
||||
let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None);
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["GOOSE_PROVIDER"], "anthropic");
|
||||
assert_eq!(map["GOOSE_MODEL"], "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_vars_model_without_provider() {
|
||||
let lp = stub_persona(Some("gpt-4o"), None, None);
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert!(!map.contains_key("GOOSE_PROVIDER"));
|
||||
assert_eq!(map["GOOSE_MODEL"], "gpt-4o");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_vars_temperature_and_context() {
|
||||
let lp = stub_persona(None, Some(0.7), Some(8192));
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["GOOSE_TEMPERATURE"], "0.7");
|
||||
assert_eq!(map["GOOSE_CONTEXT_LIMIT"], "8192");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_vars_empty_when_no_config() {
|
||||
let lp = stub_persona(None, None, None);
|
||||
let vars = runtime_env_vars(&lp);
|
||||
assert!(vars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_vars_full_projection() {
|
||||
let lp = stub_persona(Some("openai:gpt-4o"), Some(0.5), Some(16384));
|
||||
let vars = runtime_env_vars(&lp);
|
||||
assert_eq!(vars.len(), 4); // PROVIDER, MODEL, TEMPERATURE, CONTEXT_LIMIT
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_vars_buzz_agent_emits_buzz_agent_vars() {
|
||||
let mut lp = stub_persona(Some("databricks:goose-claude-4-6-opus"), None, None);
|
||||
lp.runtime = Some("buzz-agent".to_owned());
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["BUZZ_AGENT_MODEL"], "goose-claude-4-6-opus");
|
||||
assert_eq!(map["BUZZ_AGENT_PROVIDER"], "databricks");
|
||||
assert!(!map.contains_key("GOOSE_MODEL"));
|
||||
assert!(!map.contains_key("GOOSE_PROVIDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_vars_goose_emits_goose_vars() {
|
||||
let mut lp = stub_persona(Some("databricks:goose-claude-4-6-opus"), None, None);
|
||||
lp.runtime = Some("goose".to_owned());
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["GOOSE_MODEL"], "goose-claude-4-6-opus");
|
||||
assert_eq!(map["GOOSE_PROVIDER"], "databricks");
|
||||
assert!(!map.contains_key("BUZZ_AGENT_MODEL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_vars_no_runtime_defaults_to_goose() {
|
||||
let lp = stub_persona(Some("anthropic:claude-sonnet-4-20250514"), None, None);
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["GOOSE_PROVIDER"], "anthropic");
|
||||
assert_eq!(map["GOOSE_MODEL"], "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_env_vars_buzz_agent_model_without_provider() {
|
||||
let mut lp = stub_persona(Some("gpt-4o"), None, None);
|
||||
lp.runtime = Some("buzz-agent".to_owned());
|
||||
let vars = runtime_env_vars(&lp);
|
||||
let map: HashMap<&str, &str> = vars.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
|
||||
assert_eq!(map["BUZZ_AGENT_MODEL"], "gpt-4o");
|
||||
assert!(!map.contains_key("BUZZ_AGENT_PROVIDER"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_minimal_pack() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{
|
||||
"id": "com.test.minimal",
|
||||
"name": "Minimal Pack",
|
||||
"version": "0.1.0",
|
||||
"personas": ["agents/bot.persona.md"]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join("agents/bot.persona.md"),
|
||||
"---\nname: bot\ndisplay_name: Bot\ndescription: A test bot.\n---\nYou are Bot.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = resolve_pack(dir).unwrap();
|
||||
assert_eq!(pack.id, "com.test.minimal");
|
||||
assert_eq!(pack.personas.len(), 1);
|
||||
|
||||
let p = &pack.personas[0];
|
||||
assert_eq!(p.name, "bot");
|
||||
assert_eq!(p.system_prompt, "You are Bot.\n");
|
||||
assert!(p.model.is_none());
|
||||
assert!(p.llm_provider.is_none());
|
||||
assert!(p.triggers.mentions); // built-in default
|
||||
assert!(p.mcp_servers.is_empty());
|
||||
assert!(p.runtime_env_vars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_pack_with_instructions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{
|
||||
"id": "com.test.instructions",
|
||||
"name": "Instructions Pack",
|
||||
"version": "1.0.0",
|
||||
"personas": ["agents/bot.persona.md"],
|
||||
"pack_instructions": "instructions.md"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(dir.join("instructions.md"), "Always be helpful.").unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join("agents/bot.persona.md"),
|
||||
"---\nname: bot\ndisplay_name: Bot\ndescription: A bot.\nmodel: anthropic:claude-sonnet-4-20250514\n---\nYou are Bot.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = resolve_pack(dir).unwrap();
|
||||
let p = &pack.personas[0];
|
||||
|
||||
// Persona body kept separate from pack instructions.
|
||||
assert_eq!(p.system_prompt, "You are Bot.\n");
|
||||
assert_eq!(p.pack_instructions.as_deref(), Some("Always be helpful."));
|
||||
|
||||
// Model split into separate fields (V3 contract)
|
||||
assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-20250514"));
|
||||
assert_eq!(p.llm_provider.as_deref(), Some("anthropic"));
|
||||
|
||||
// Env vars projected
|
||||
let env_map: HashMap<&str, &str> = p
|
||||
.runtime_env_vars
|
||||
.iter()
|
||||
.map(|(k, v)| (k.as_str(), v.as_str()))
|
||||
.collect();
|
||||
assert_eq!(env_map["GOOSE_PROVIDER"], "anthropic");
|
||||
assert_eq!(env_map["GOOSE_MODEL"], "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_multi_persona_pack() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{
|
||||
"id": "com.test.multi",
|
||||
"name": "Multi Pack",
|
||||
"version": "2.0.0",
|
||||
"personas": ["agents/pip.persona.md", "agents/lep.persona.md"],
|
||||
"defaults": {
|
||||
"model": "anthropic:claude-sonnet-4-20250514",
|
||||
"temperature": 0.7,
|
||||
"thread_replies": true
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join("agents/pip.persona.md"),
|
||||
"---\nname: pip\ndisplay_name: Pip\ndescription: The lead.\nmodel: anthropic:claude-4-opus-20250514\nsubscribe:\n - '#reviews'\n---\nYou are Pip.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join("agents/lep.persona.md"),
|
||||
"---\nname: lep\ndisplay_name: Lep\ndescription: The analyst.\ntemperature: 0.3\n---\nYou are Lep.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = resolve_pack(dir).unwrap();
|
||||
assert_eq!(pack.personas.len(), 2);
|
||||
|
||||
let pip = pack.personas.iter().find(|p| p.name == "pip").unwrap();
|
||||
let lep = pack.personas.iter().find(|p| p.name == "lep").unwrap();
|
||||
|
||||
// pip overrides model
|
||||
assert_eq!(pip.model.as_deref(), Some("claude-4-opus-20250514"));
|
||||
assert_eq!(pip.llm_provider.as_deref(), Some("anthropic"));
|
||||
// lep inherits model from defaults
|
||||
assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));
|
||||
assert_eq!(lep.llm_provider.as_deref(), Some("anthropic"));
|
||||
|
||||
// pip inherits temperature from defaults
|
||||
assert_eq!(pip.temperature, Some(0.7));
|
||||
// lep overrides temperature
|
||||
assert_eq!(lep.temperature, Some(0.3));
|
||||
|
||||
// pip has explicit subscribe
|
||||
assert_eq!(pip.subscribe, vec!["#reviews"]);
|
||||
// lep has no subscribe (empty from defaults)
|
||||
assert!(lep.subscribe.is_empty());
|
||||
|
||||
// Both inherit thread_replies from defaults
|
||||
assert!(pip.thread_replies);
|
||||
assert!(lep.thread_replies);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_persona_by_name_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md","agents/b.persona.md"]}"#,
|
||||
).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("agents/a.persona.md"),
|
||||
"---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\nAlpha prompt.\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("agents/b.persona.md"),
|
||||
"---\nname: beta\ndisplay_name: Beta\ndescription: Second.\n---\nBeta prompt.\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let p = resolve_persona_by_name(dir, "beta").unwrap();
|
||||
assert_eq!(p.name, "beta");
|
||||
assert_eq!(p.display_name, "Beta");
|
||||
assert!(p.system_prompt.contains("Beta prompt."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_persona_by_name_not_found() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("agents/a.persona.md"),
|
||||
"---\nname: alpha\ndisplay_name: Alpha\ndescription: First.\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_persona_by_name(dir, "nonexistent").unwrap_err();
|
||||
assert!(matches!(err, PackError::PersonaNotFound(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_split_provider_and_id() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("agents/a.persona.md"),
|
||||
"---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: openai:gpt-4o\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = resolve_pack(dir).unwrap();
|
||||
let p = &pack.personas[0];
|
||||
assert_eq!(p.model.as_deref(), Some("gpt-4o"));
|
||||
assert_eq!(p.llm_provider.as_deref(), Some("openai"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_no_provider() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let dir = tmp.path();
|
||||
std::fs::create_dir_all(dir.join(".plugin")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("agents")).unwrap();
|
||||
|
||||
std::fs::write(
|
||||
dir.join(".plugin/plugin.json"),
|
||||
r#"{"id":"t","name":"T","version":"1.0.0","personas":["agents/a.persona.md"]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(
|
||||
dir.join("agents/a.persona.md"),
|
||||
"---\nname: a\ndisplay_name: A\ndescription: A.\nmodel: gpt-4o\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pack = resolve_pack(dir).unwrap();
|
||||
let p = &pack.personas[0];
|
||||
assert_eq!(p.model.as_deref(), Some("gpt-4o"));
|
||||
assert!(p.llm_provider.is_none());
|
||||
}
|
||||
|
||||
fn stub_persona(
|
||||
model: Option<&str>,
|
||||
temperature: Option<f64>,
|
||||
max_context_tokens: Option<u64>,
|
||||
) -> LoadedPersona {
|
||||
LoadedPersona {
|
||||
source_path: PathBuf::from("test.persona.md"),
|
||||
name: "test".into(),
|
||||
display_name: "Test".into(),
|
||||
description: "A test persona.".into(),
|
||||
avatar: None,
|
||||
model: model.map(str::to_owned),
|
||||
runtime: None,
|
||||
temperature,
|
||||
max_context_tokens,
|
||||
subscribe: vec![],
|
||||
triggers: None,
|
||||
thread_replies: true,
|
||||
broadcast_replies: false,
|
||||
skills: vec![],
|
||||
mcp_servers: vec![],
|
||||
hooks: None,
|
||||
prompt: "You are a test.".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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