Files
MyAiDesk/nomifun-tauri/crates/backend/nomifun-ai-agent/tests/skill_manager_integration.rs
T
freedak f7a720204a Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
2026-07-04 19:20:46 +08:00

301 lines
10 KiB
Rust

//! Integration tests for the skill system.
//!
//! These tests verify the full skill lifecycle:
//! - Skill discovery across multiple directories
//! - Skill index generation
//! - Lazy loading of skill bodies
//! - LOAD_SKILL detection in agent output
//! - System instruction building
//! - First message preparation
// Pre-existing: ENV_MUTEX MutexGuard held across await points is intentional —
// it serializes env-var mutation across tests.
#![allow(clippy::await_holding_lock)]
use std::fs;
use std::sync::{Arc, Mutex};
use nomifun_ai_agent::{
AcpSkillManager, build_skills_index_text, build_system_instructions, detect_skill_load_request,
prepare_first_message, prepare_first_message_with_skills_index,
};
use nomifun_extension::{BUILTIN_SKILLS_ENV_VAR, resolve_skill_paths};
use tempfile::TempDir;
/// Serialize env var mutations across tests — `BUILTIN_SKILLS_ENV_VAR` is
/// process-global so concurrent tests that set it must not interleave.
static ENV_MUTEX: Mutex<()> = Mutex::new(());
// ---------------------------------------------------------------------------
// 4.0 New API: discover via extension service
// ---------------------------------------------------------------------------
#[tokio::test]
async fn discover_skills_uses_extension_service_layout() {
let _guard = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new().unwrap();
let builtin_src = tmp.path().join("builtin-skills-src");
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
// auto-inject skill: builtin-src/auto-inject/cron/SKILL.md
let auto_dir = builtin_src.join("auto-inject").join("cron");
fs::create_dir_all(&auto_dir).unwrap();
fs::write(
auto_dir.join("SKILL.md"),
"---\nname: cron\ndescription: Cron helper\n---\nBody",
)
.unwrap();
// opt-in builtin: builtin-src/mermaid/SKILL.md
let opt_dir = builtin_src.join("mermaid");
fs::create_dir_all(&opt_dir).unwrap();
fs::write(
opt_dir.join("SKILL.md"),
"---\nname: mermaid\ndescription: Mermaid diagrams\n---\nBody",
)
.unwrap();
// user custom: data/skills/my-skill/SKILL.md
let user_dir = data_dir.join("skills").join("my-skill");
fs::create_dir_all(&user_dir).unwrap();
fs::write(
user_dir.join("SKILL.md"),
"---\nname: my-skill\ndescription: User skill\n---\nBody",
)
.unwrap();
unsafe {
std::env::set_var(BUILTIN_SKILLS_ENV_VAR, &builtin_src);
}
let paths = Arc::new(resolve_skill_paths(tmp.path(), &data_dir));
let mgr = AcpSkillManager::new(paths);
// No enabled_skills: opt-in builtin (mermaid) and custom (my-skill) should
// be skipped. Only the auto-inject builtin (cron) appears.
let idx = mgr.discover_skills(None, None).await;
let names: std::collections::HashSet<&str> = idx.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains("cron"), "auto-inject skill missing: got {names:?}");
assert!(
!names.contains("mermaid"),
"opt-in builtin leaked without enabled_skills"
);
assert!(!names.contains("my-skill"), "custom leaked without enabled_skills");
unsafe {
std::env::remove_var(BUILTIN_SKILLS_ENV_VAR);
}
}
#[tokio::test]
async fn get_skill_loads_builtin_body_via_read_builtin_skill() {
let _guard = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new().unwrap();
let builtin = tmp.path().join("builtin");
let auto = builtin.join("auto-inject").join("bodyskill");
fs::create_dir_all(&auto).unwrap();
fs::write(
auto.join("SKILL.md"),
"---\nname: bodyskill\ndescription: B\n---\nBuiltin body content",
)
.unwrap();
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
unsafe {
std::env::set_var(BUILTIN_SKILLS_ENV_VAR, &builtin);
}
let paths = Arc::new(resolve_skill_paths(tmp.path(), &data_dir));
let mgr = AcpSkillManager::new(paths);
mgr.discover_skills(None, None).await;
let skill = mgr.get_skill("bodyskill").await.unwrap();
assert_eq!(
skill.body.as_deref(),
Some("Builtin body content"),
"builtin body should be loaded via read_builtin_skill + extract_body"
);
unsafe {
std::env::remove_var(BUILTIN_SKILLS_ENV_VAR);
}
}
#[tokio::test]
async fn get_skill_loads_custom_body_via_fs_read() {
let _guard = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new().unwrap();
let data_dir = tmp.path().join("data");
let user_skill = data_dir.join("skills").join("mine");
fs::create_dir_all(&user_skill).unwrap();
fs::write(
user_skill.join("SKILL.md"),
"---\nname: mine\ndescription: Mine\n---\nCustom body here",
)
.unwrap();
// Ensure no stale BUILTIN_SKILLS_ENV_VAR interferes
unsafe {
std::env::remove_var(BUILTIN_SKILLS_ENV_VAR);
}
let paths = Arc::new(resolve_skill_paths(tmp.path(), &data_dir));
let mgr = AcpSkillManager::new(paths);
let enabled = vec!["mine".to_string()];
let idx = mgr.discover_skills(Some(&enabled), None).await;
let names: Vec<&str> = idx.iter().map(|s| s.name.as_str()).collect();
assert!(
names.contains(&"mine"),
"custom 'mine' should be in index; got {names:?}"
);
let skill = mgr.get_skill("mine").await.unwrap();
assert_eq!(skill.body.as_deref(), Some("Custom body here"));
}
#[tokio::test]
async fn discover_skills_respects_exclude_builtin() {
let _guard = ENV_MUTEX.lock().unwrap();
let tmp = TempDir::new().unwrap();
let builtin_src = tmp.path().join("b");
let auto_dir = builtin_src.join("auto-inject").join("cron");
fs::create_dir_all(&auto_dir).unwrap();
fs::write(
auto_dir.join("SKILL.md"),
"---\nname: cron\ndescription: Cron\n---\nBody",
)
.unwrap();
unsafe {
std::env::set_var(BUILTIN_SKILLS_ENV_VAR, &builtin_src);
}
let data_dir = tmp.path().join("data");
fs::create_dir_all(&data_dir).unwrap();
let paths = Arc::new(resolve_skill_paths(tmp.path(), &data_dir));
let mgr = AcpSkillManager::new(paths);
let exclude = vec!["cron".to_string()];
let idx = mgr.discover_skills(None, Some(&exclude)).await;
assert!(idx.is_empty(), "excluded auto-inject skill should be dropped");
unsafe {
std::env::remove_var(BUILTIN_SKILLS_ENV_VAR);
}
}
// ---------------------------------------------------------------------------
// 5.2 Skill Index (pure function)
// ---------------------------------------------------------------------------
#[test]
fn build_index_text_contains_load_protocol() {
let skills = vec![
nomifun_ai_agent::SkillIndex {
name: "security".into(),
description: "Security review".into(),
},
nomifun_ai_agent::SkillIndex {
name: "tdd".into(),
description: "Test-driven development".into(),
},
];
let text = build_skills_index_text(&skills);
assert!(text.contains("[LOAD_SKILL: skill-name]"));
assert!(text.contains("- **security**: Security review"));
assert!(text.contains("- **tdd**: Test-driven development"));
}
// ---------------------------------------------------------------------------
// 5.4 LOAD_SKILL Detection (pure function)
// ---------------------------------------------------------------------------
#[test]
fn detect_single_load_skill_request() {
let content = "I need to use [LOAD_SKILL: security-review] to check this code.";
let skills = detect_skill_load_request(content);
assert_eq!(skills, vec!["security-review"]);
}
#[test]
fn detect_multiple_load_skill_requests() {
let content = "[LOAD_SKILL: a] then [LOAD_SKILL: b] and [LOAD_SKILL: c]";
let skills = detect_skill_load_request(content);
assert_eq!(skills, vec!["a", "b", "c"]);
}
#[test]
fn detect_no_load_skill_in_normal_text() {
let content = "This is just normal text without any skill requests.";
let skills = detect_skill_load_request(content);
assert!(skills.is_empty());
}
#[test]
fn detect_load_skill_handles_whitespace() {
let content = "[LOAD_SKILL: spaced-name ]";
let skills = detect_skill_load_request(content);
assert_eq!(skills, vec!["spaced-name"]);
}
// ---------------------------------------------------------------------------
// System instruction and first message builders
// ---------------------------------------------------------------------------
#[test]
fn system_instructions_with_loaded_skills() {
let skills = vec![nomifun_ai_agent::SkillDefinition {
name: "helper".into(),
description: "A helper".into(),
location: std::path::PathBuf::new(),
source: nomifun_extension::SkillSource::Custom,
relative_location: None,
body: Some("Complete helper instructions.".into()),
}];
let result = build_system_instructions("Base system prompt", &skills);
assert!(result.starts_with("Base system prompt"));
assert!(result.contains("## Skill: helper"));
assert!(result.contains("Complete helper instructions."));
}
#[test]
fn first_message_with_skills_index_for_acp() {
let skills = vec![nomifun_ai_agent::SkillIndex {
name: "review".into(),
description: "Code review".into(),
}];
let result = prepare_first_message_with_skills_index("Please review my code.", &skills, None);
assert!(result.contains("[Assistant Rules]"));
assert!(result.contains("- **review**: Code review"));
assert!(result.contains("[/Assistant Rules]"));
assert!(result.ends_with("Please review my code."));
}
#[test]
fn first_message_with_full_skills_for_gemini() {
let skills = vec![nomifun_ai_agent::SkillDefinition {
name: "debug".into(),
description: "Debug".into(),
location: std::path::PathBuf::new(),
source: nomifun_extension::SkillSource::Custom,
relative_location: None,
body: Some("Full debug skill content.".into()),
}];
let result = prepare_first_message("Hello", &skills, Some("Be helpful."));
assert!(result.contains("[Assistant Rules]"));
assert!(result.contains("Be helpful."));
assert!(result.contains("Full debug skill content."));
assert!(result.contains("[/Assistant Rules]"));
assert!(result.ends_with("Hello"));
}
// User-override tests moved to the BUILTIN_SKILLS_ENV_VAR-based discovery
// tests at the top of this file — see Task 5 for get_skill body-loading
// coverage against the new skill_service-backed API.