use crate::config::ExpertsConfig; use crate::platform::{atomic_rename, home_dir as platform_home_dir}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; #[cfg(test)] static EXPERT_TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); #[cfg(test)] pub(crate) fn acquire_expert_test_env_lock() -> std::sync::MutexGuard<'static, ()> { EXPERT_TEST_ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()) } /// A discovered expert definition. #[derive(Debug, Clone)] pub struct Expert { pub name: String, pub description: String, pub body: String, pub source: ExpertSource, pub path: PathBuf, } /// Where an expert definition was discovered from. #[derive(Debug, Clone, PartialEq, Eq)] pub enum ExpertSource { User, Project, Custom(String), } impl ExpertSource { pub fn as_str(&self) -> &str { match self { ExpertSource::User => "user", ExpertSource::Project => "project", ExpertSource::Custom(path) => path.as_str(), } } } /// The scope an expert is addressed by for enable/disable and CRUD operations. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ExpertScope { User, Project, } impl ExpertScope { pub fn parse(value: &str) -> Option { match value { "user" => Some(Self::User), "project" => Some(Self::Project), _ => None, } } pub fn as_str(&self) -> &'static str { match self { Self::User => "user", Self::Project => "project", } } } impl From for ExpertSource { fn from(value: ExpertScope) -> Self { match value { ExpertScope::User => ExpertSource::User, ExpertScope::Project => ExpertSource::Project, } } } /// An expert entry with its disabled status across scopes (for the settings page). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ExpertWithStatus { pub name: String, pub description: String, /// 专家提示词正文。列表 API 返回它是为了让前端编辑模态框 /// 能直接显示已有正文,无需再发一次详情请求。 pub body: String, pub source: String, pub path: String, /// Which scopes have this expert disabled. Empty means enabled. pub disabled_in_scopes: Vec, } /// Result of an enable/disable operation. #[derive(Debug, Clone)] pub struct ExpertAvailabilityChange { pub name: String, pub scope: ExpertScope, pub changed: bool, pub disabled_in_scopes: Vec, pub available: bool, } /// The persisted expert state file (`expert-state.json`). /// /// Holds both the per-session selected expert and the disabled experts list. /// There is one file per scope (user/project). #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct ExpertStateFile { #[serde(default)] pub session_experts: HashMap, #[serde(default)] pub disabled_experts: Vec, } /// Merged disable state across user + project scopes. #[derive(Debug, Clone, Default)] struct ExpertDisableState { user_disabled: HashSet, project_disabled: HashSet, } impl ExpertDisableState { fn is_disabled(&self, name: &str) -> bool { self.user_disabled.contains(name) || self.project_disabled.contains(name) } fn disabled_scopes_for(&self, name: &str) -> Vec { let mut scopes = Vec::new(); if self.user_disabled.contains(name) { scopes.push(ExpertScope::User); } if self.project_disabled.contains(name) { scopes.push(ExpertScope::Project); } scopes } } /// In-memory catalog of discovered experts. #[derive(Debug, Clone, Default)] pub struct ExpertCatalog { experts: Vec, } impl ExpertCatalog { pub fn discover(config: &ExpertsConfig) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); let disable_state = load_expert_disable_state(&cwd); Self::discover_with_state(config, &cwd, Some(&disable_state)) } fn discover_without_state(config: &ExpertsConfig, cwd: &Path) -> Self { Self::discover_with_state(config, cwd, None) } fn discover_with_state( config: &ExpertsConfig, cwd: &Path, disable_state: Option<&ExpertDisableState>, ) -> Self { if !config.enabled { return Self::default(); } let mut merged: HashMap = HashMap::new(); let mut sources_seen = 0usize; // Load from least specific to most specific so later sources win on conflicts. for source in source_order(&config.sources) { sources_seen += 1; let root = source_root(&source, cwd); let Some(root) = root else { continue }; for expert in load_experts_from_root(&root, source.clone()) { if let Some(existing) = merged.get(&expert.name) { tracing::warn!( expert = %expert.name, old_source = %existing.source.as_str(), new_source = %expert.source.as_str(), "Duplicate expert name found; overriding with later source" ); } merged.insert(expert.name.clone(), expert); } } let mut experts: Vec = merged.into_values().collect(); if let Some(disable_state) = disable_state { experts.retain(|expert| !disable_state.is_disabled(&expert.name)); } experts.sort_by(|a, b| a.name.cmp(&b.name)); tracing::info!( sources_seen, discovered = experts.len(), "Experts discovery completed" ); Self { experts } } pub fn is_empty(&self) -> bool { self.experts.is_empty() } pub fn len(&self) -> usize { self.experts.len() } pub fn find_expert(&self, name: &str) -> Option<&Expert> { self.experts.iter().find(|e| e.name == name) } pub fn all(&self) -> Vec { self.experts.clone() } } /// Expert runtime: manages discovery, disable state, and per-session selection. /// /// Mirrors `SkillRuntime` (discovery + CRUD) and `SubagentRuntime` (disable state) /// patterns, and adds per-session expert selection persisted to `expert-state.json`. #[derive(Debug)] pub struct ExpertRuntime { config: RwLock, catalog: RwLock, disable_state: RwLock, /// session_id -> selected expert name session_experts: RwLock>, cwd: PathBuf, } impl Default for ExpertRuntime { fn default() -> Self { Self { config: RwLock::new(ExpertsConfig::default()), catalog: RwLock::new(ExpertCatalog::default()), disable_state: RwLock::new(ExpertDisableState::default()), session_experts: RwLock::new(HashMap::new()), cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), } } } impl ExpertRuntime { pub fn from_config(config: ExpertsConfig) -> Self { let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); Self::from_config_with_cwd(config, cwd) } fn from_config_with_cwd(config: ExpertsConfig, cwd: PathBuf) -> Self { let catalog = ExpertCatalog::discover_with_state( &config, &cwd, Some(&load_expert_disable_state(&cwd)), ); let disable_state = load_expert_disable_state(&cwd); // session selections are persisted in the project-scope state file let session_experts = load_project_session_experts(&cwd); tracing::info!( enabled = config.enabled, sources = ?config.sources, cwd = %cwd.display(), discovered = catalog.len(), "ExpertRuntime initialized" ); Self { config: RwLock::new(config), catalog: RwLock::new(catalog), disable_state: RwLock::new(disable_state), session_experts: RwLock::new(session_experts), cwd, } } /// Re-discover experts from the filesystem. pub fn reload(&self) -> Result { let config = self.config.read().expect("experts config rwlock poisoned").clone(); let catalog = ExpertCatalog::discover_with_state( &config, &self.cwd, Some(&load_expert_disable_state(&self.cwd)), ); let mut guard = self.catalog.write().expect("experts catalog rwlock poisoned"); *guard = catalog.clone(); Ok(catalog) } /// 运行时更新 experts 配置(sources 等),并立即重新发现专家。 /// 用于前端保存配置后即时生效,无需重启网关。 pub fn update_config(&self, new_config: ExpertsConfig) -> Result<(), String> { { let mut guard = self.config.write().expect("experts config rwlock poisoned"); *guard = new_config; } self.reload()?; Ok(()) } /// List enabled experts (disabled ones are filtered out). pub fn list_experts(&self) -> Vec { self.catalog .read() .expect("experts catalog rwlock poisoned") .experts .clone() } /// List all discovered experts including disabled ones, with their disabled scopes. pub fn list_experts_with_status(&self) -> Vec { let config = self.config.read().expect("experts config rwlock poisoned").clone(); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); let disable_state = load_expert_disable_state(&self.cwd); let mut items: Vec = catalog .experts .iter() .map(|expert| { let scopes = disable_state.disabled_scopes_for(&expert.name); ExpertWithStatus { name: expert.name.clone(), description: expert.description.clone(), body: expert.body.clone(), source: expert.source.as_str().to_string(), path: expert.path.display().to_string(), disabled_in_scopes: scopes.iter().map(|s| s.as_str().to_string()).collect(), } }) .collect(); items.sort_by(|a, b| a.name.cmp(&b.name)); items } pub fn get_expert(&self, name: &str) -> Option { self.catalog .read() .expect("experts catalog rwlock poisoned") .find_expert(name) .cloned() } pub fn create_expert( &self, scope: ExpertScope, name: &str, description: &str, body: &str, reload: bool, ) -> Result { validate_expert_name(name)?; let path = expert_file_path(scope, name, &self.cwd)?; if path.exists() { return Err(format!( "expert '{}' already exists at {}", name, path.display() )); } write_expert_file(&path, name, description, body)?; let expert = parse_expert_file(&path, scope.into())?; if reload { let _ = self.reload()?; } Ok(expert) } pub fn update_expert( &self, scope: ExpertScope, name: &str, description: Option<&str>, body: Option<&str>, reload: bool, ) -> Result { validate_expert_name(name)?; let path = expert_file_path(scope, name, &self.cwd)?; if !path.exists() { return Err(format!("expert '{}' not found at {}", name, path.display())); } let existing = parse_expert_file(&path, scope.into())?; let next_description = description.unwrap_or(&existing.description); let next_body = body.unwrap_or(&existing.body); write_expert_file(&path, name, next_description, next_body)?; let expert = parse_expert_file(&path, scope.into())?; if reload { let _ = self.reload()?; } Ok(expert) } pub fn delete_expert( &self, scope: ExpertScope, name: &str, reload: bool, ) -> Result { validate_expert_name(name)?; let dir = expert_dir_path(scope, name, &self.cwd)?; if !dir.exists() { return Err(format!("expert '{}' not found at {}", name, dir.display())); } fs::remove_dir_all(&dir) .map_err(|err| format!("failed to delete expert directory: {}", err))?; if reload { let _ = self.reload()?; } Ok(dir) } pub fn disable_expert( &self, scope: ExpertScope, name: &str, ) -> Result { self.set_expert_enabled(scope, name, false) } pub fn enable_expert( &self, scope: ExpertScope, name: &str, ) -> Result { self.set_expert_enabled(scope, name, true) } pub fn has_expert_definition(&self, name: &str) -> Result { validate_expert_name(name)?; let config = self.config.read().expect("experts config rwlock poisoned").clone(); let catalog = ExpertCatalog::discover_without_state(&config, &self.cwd); Ok(catalog.find_expert(name).is_some()) } fn set_expert_enabled( &self, scope: ExpertScope, name: &str, enabled: bool, ) -> Result { validate_expert_name(name)?; if !self.has_expert_definition(name)? { return Err(format!("expert '{}' not found", name)); } let state_path = expert_state_path(scope, &self.cwd); let mut state_file = load_expert_state_file(&state_path)?; let mut disabled: HashSet = state_file.disabled_experts.into_iter().collect(); let changed = if enabled { disabled.remove(name) } else { disabled.insert(name.to_string()) }; let mut disabled_list: Vec = disabled.into_iter().collect(); disabled_list.sort(); state_file.disabled_experts = disabled_list; save_expert_state_file(&state_path, &state_file)?; // update in-memory disable_state { let mut state = self .disable_state .write() .expect("experts disable_state rwlock poisoned"); match scope { ExpertScope::User => { if enabled { state.user_disabled.remove(name); } else { state.user_disabled.insert(name.to_string()); } } ExpertScope::Project => { if enabled { state.project_disabled.remove(name); } else { state.project_disabled.insert(name.to_string()); } } } } // refresh catalog so list_experts / get_expert reflect the change let _ = self.reload()?; let state = self .disable_state .read() .expect("experts disable_state rwlock poisoned"); let disabled_in_scopes = state.disabled_scopes_for(name); Ok(ExpertAvailabilityChange { name: name.to_string(), scope, changed, available: disabled_in_scopes.is_empty(), disabled_in_scopes, }) } /// Select an expert for a session. Persists to the project-scope state file. pub fn select_expert(&self, session_id: &str, expert_name: &str) -> Result<(), String> { if session_id.trim().is_empty() { return Err("session_id cannot be empty".to_string()); } // The expert must exist (and not be disabled) for selection to be meaningful. if self.get_expert(expert_name).is_none() { return Err(format!( "expert '{}' not found or disabled", expert_name )); } { let mut sessions = self .session_experts .write() .expect("experts session_experts rwlock poisoned"); sessions.insert(session_id.to_string(), expert_name.to_string()); } persist_session_experts(&self.cwd, |state| { state .session_experts .insert(session_id.to_string(), expert_name.to_string()); }) } /// Clear the selected expert for a session. pub fn clear_expert(&self, session_id: &str) -> Result<(), String> { { let mut sessions = self .session_experts .write() .expect("experts session_experts rwlock poisoned"); sessions.remove(session_id); } persist_session_experts(&self.cwd, |state| { state.session_experts.remove(session_id); }) } /// Returns the expert selected for a session, or None if none selected / disabled / not found. pub fn selected_expert_for(&self, session_id: &str) -> Option { let name = { let sessions = self .session_experts .read() .expect("experts session_experts rwlock poisoned"); sessions.get(session_id).cloned() }?; // Filter out disabled experts. let state = self .disable_state .read() .expect("experts disable_state rwlock poisoned"); if state.is_disabled(&name) { return None; } self.get_expert(&name) } /// Returns just the name of the selected expert (for API responses). pub fn selected_expert_name_for(&self, session_id: &str) -> Option { self.selected_expert_for(session_id).map(|e| e.name) } } // ========== ExpertPromptProvider ========== use crate::agent::{SystemPrompt, SystemPromptContext, SystemPromptProvider}; /// Injects the currently-selected expert's body as a system prompt fragment. /// /// Looks up the selection via the shared `ExpertRuntime` using /// `context.session_id`. Returns `None` when no expert is selected or the /// selected expert is disabled/missing. pub struct ExpertPromptProvider { runtime: Arc, } impl ExpertPromptProvider { pub fn new(runtime: Arc) -> Self { Self { runtime } } } impl SystemPromptProvider for ExpertPromptProvider { fn build(&self, context: &SystemPromptContext) -> Option { let session_id = context.session_id.as_ref()?; let expert = self.runtime.selected_expert_for(session_id)?; let content = if expert.body.trim().is_empty() { // Empty body is OK; inject a header so the LLM still knows the role. format!( "# 专家角色: {}\n\n{}", expert.name, expert.description ) } else { expert.body.clone() }; Some(SystemPrompt { content, context: Some("expert".to_string()), }) } } // ========== Path helpers ========== fn user_experts_root() -> Option { platform_home_dir().map(|p| p.join(".picobot").join("experts")) } fn project_experts_root(cwd: &Path) -> PathBuf { cwd.join(".picobot").join("experts") } fn user_expert_state_path() -> Option { platform_home_dir().map(|p| p.join(".picobot").join("expert-state.json")) } fn project_expert_state_path(cwd: &Path) -> PathBuf { cwd.join(".picobot").join("expert-state.json") } fn expert_state_path(scope: ExpertScope, cwd: &Path) -> PathBuf { match scope { ExpertScope::User => user_expert_state_path() .unwrap_or_else(|| cwd.join(".picobot").join("expert-state.json")), ExpertScope::Project => project_expert_state_path(cwd), } } fn root_for_scope(scope: ExpertScope, cwd: &Path) -> Result { match scope { ExpertScope::User => user_experts_root() .ok_or_else(|| "failed to resolve home directory".to_string()), ExpertScope::Project => Ok(project_experts_root(cwd)), } } fn expert_dir_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result { Ok(root_for_scope(scope, cwd)?.join(name)) } fn expert_file_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result { Ok(expert_dir_path(scope, name, cwd)?.join("EXPERT.md")) } // ========== Source ordering ========== fn source_order(sources: &[String]) -> Vec { let mut result = Vec::new(); for source in sources { match source.as_str() { "user" => { if !result.contains(&ExpertSource::User) { result.push(ExpertSource::User); } } "project" => { if !result.contains(&ExpertSource::Project) { result.push(ExpertSource::Project); } } unknown => { let custom = ExpertSource::Custom(unknown.to_string()); if !result.contains(&custom) { result.push(custom); } } } } // 注意:空 sources 时不再兜底返回 [User, Project]。 // 用户在前端关闭所有源时,sources 会变为空数组,此时应尊重用户意图, // 不发现任何专家。配置文件缺失 sources 字段时,default_experts_sources() // 已经返回 ["user", "project"],不会走到这里。 result } fn source_root(source: &ExpertSource, cwd: &Path) -> Option { match source { ExpertSource::User => user_experts_root(), ExpertSource::Project => Some(project_experts_root(cwd)), ExpertSource::Custom(path) => { let p = PathBuf::from(path); if p.is_absolute() { Some(p) } else { tracing::warn!(path = %path, "Custom experts source must be an absolute path, skipping"); None } } } } // ========== Validation ========== fn validate_expert_name(name: &str) -> Result<(), String> { if name.trim().is_empty() { return Err("expert name cannot be empty".to_string()); } if name.contains('/') || name.contains('\\') || name.contains("..") { return Err("expert name must not contain path separators or '..'".to_string()); } Ok(()) } // ========== File I/O ========== #[derive(Debug, Deserialize)] struct ExpertFrontmatter { description: String, #[serde(default)] name: Option, } fn render_expert_file(name: &str, description: &str, body: &str) -> Result { if description.trim().is_empty() { return Err("description is required and cannot be empty".to_string()); } #[derive(serde::Serialize)] struct ExpertFrontmatterOwned { name: String, description: String, } let yaml = serde_yaml::to_string(&ExpertFrontmatterOwned { name: name.to_string(), description: description.to_string(), }) .map_err(|err| format!("failed to render expert frontmatter: {}", err))?; let yaml = yaml.trim_start_matches("---\n"); let body = body.trim(); if body.is_empty() { Ok(format!("---\n{}---\n", yaml)) } else { Ok(format!("---\n{}---\n{}\n", yaml, body)) } } fn write_expert_file(path: &Path, name: &str, description: &str, body: &str) -> Result<(), String> { let content = render_expert_file(name, description, body)?; if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create expert directory: {}", err))?; } fs::write(path, content).map_err(|err| format!("failed to write expert file: {}", err)) } fn load_experts_from_root(root: &Path, source: ExpertSource) -> Vec { let mut out = Vec::new(); if !root.exists() { return out; } let entries = match fs::read_dir(root) { Ok(entries) => entries, Err(err) => { tracing::warn!(path = %root.display(), error = %err, "Failed to read experts directory"); return out; } }; for entry in entries.flatten() { let path = entry.path(); if !path.is_dir() { continue; } let expert_md = path.join("EXPERT.md"); if !expert_md.exists() { continue; } match parse_expert_file(&expert_md, source.clone()) { Ok(expert) => out.push(expert), Err(err) => { tracing::warn!(path = %expert_md.display(), error = %err, "Skipping invalid expert file"); } } } out } fn parse_expert_file(path: &Path, source: ExpertSource) -> Result { let content = fs::read_to_string(path).map_err(|e| format!("failed to read file: {}", e))?; let (frontmatter_raw, body) = split_frontmatter(&content).ok_or_else(|| "missing YAML frontmatter block".to_string())?; let frontmatter: ExpertFrontmatter = serde_yaml::from_str(frontmatter_raw) .map_err(|e| format!("invalid YAML frontmatter: {}", e))?; let description = frontmatter.description.trim(); if description.is_empty() { return Err("description is required and cannot be empty".to_string()); } let dir_name = path .parent() .and_then(|p| p.file_name()) .map(|s| s.to_string_lossy().to_string()) .unwrap_or_else(|| "unknown-expert".to_string()); let name = frontmatter.name.unwrap_or(dir_name).trim().to_string(); Ok(Expert { name, description: description.to_string(), body: body.trim().to_string(), source, path: path.to_path_buf(), }) } fn split_frontmatter(content: &str) -> Option<(&str, &str)> { // 兼容 CRLF(Windows)和 LF(Unix)行尾符 let rest = content .strip_prefix("---\n") .or_else(|| content.strip_prefix("---\r\n"))?; let marker = "\n---\n"; let marker_crlf = "\n---\r\n"; if let Some(idx) = rest.find(marker) { let frontmatter = &rest[..idx]; let body = &rest[idx + marker.len()..]; Some((frontmatter, body)) } else if let Some(idx) = rest.find(marker_crlf) { let frontmatter = &rest[..idx]; let body = &rest[idx + marker_crlf.len()..]; Some((frontmatter, body)) } else { None } } // ========== State file I/O ========== fn load_expert_disable_state(cwd: &Path) -> ExpertDisableState { ExpertDisableState { user_disabled: user_expert_state_path() .map(|path| load_disabled_expert_names(&path)) .unwrap_or_default(), project_disabled: load_disabled_expert_names(&project_expert_state_path(cwd)), } } fn load_disabled_expert_names(path: &Path) -> HashSet { match load_expert_state_file(path) { Ok(state) => state .disabled_experts .into_iter() .filter_map(|name| normalize_expert_name(name, path)) .collect(), Err(err) => { tracing::warn!(path = %path.display(), error = %err, "Failed to load expert state file"); HashSet::new() } } } fn normalize_expert_name(name: String, path: &Path) -> Option { let trimmed = name.trim(); match validate_expert_name(trimmed) { Ok(()) => Some(trimmed.to_string()), Err(err) => { tracing::warn!(path = %path.display(), expert = %name, error = %err, "Ignoring invalid disabled expert entry"); None } } } fn load_expert_state_file(path: &Path) -> Result { if !path.exists() { return Ok(ExpertStateFile::default()); } let content = fs::read_to_string(path) .map_err(|err| format!("failed to read expert state file: {}", err))?; serde_json::from_str(&content) .map_err(|err| format!("failed to parse expert state file: {}", err)) } fn save_expert_state_file(path: &Path, state: &ExpertStateFile) -> Result<(), String> { if let Some(parent) = path.parent() { fs::create_dir_all(parent) .map_err(|err| format!("failed to create expert state directory: {}", err))?; } let content = serde_json::to_string_pretty(state) .map_err(|err| format!("failed to render expert state file: {}", err))?; let tmp_path = path.with_extension("json.tmp"); fs::write(&tmp_path, format!("{}\n", content)) .map_err(|err| format!("failed to write temporary expert state file: {}", err))?; atomic_rename(&tmp_path, path) .map_err(|err| format!("failed to persist expert state file: {}", err)) } /// Load only the session_experts map from the project-scope state file (used at startup). fn load_project_session_experts(cwd: &Path) -> HashMap { let path = project_expert_state_path(cwd); match load_expert_state_file(&path) { Ok(state) => state.session_experts, Err(err) => { tracing::warn!(path = %path.display(), error = %err, "Failed to load project expert state for session_experts"); HashMap::new() } } } /// Persist a mutation to the project-scope state file's session_experts while /// preserving the existing disabled_experts field. fn persist_session_experts(cwd: &Path, mutate: F) -> Result<(), String> { let path = project_expert_state_path(cwd); let mut state = load_expert_state_file(&path)?; mutate(&mut state); save_expert_state_file(&path, &state) } #[cfg(test)] mod tests { use super::*; use std::ffi::OsString; struct CurrentDirGuard { previous: PathBuf, } struct HomeDirGuard { previous: Option, previous_userprofile: Option, } impl CurrentDirGuard { fn enter(path: &Path) -> Self { let previous = std::env::current_dir().unwrap(); std::env::set_current_dir(path).unwrap(); Self { previous } } } impl Drop for CurrentDirGuard { fn drop(&mut self) { let _ = std::env::set_current_dir(&self.previous); } } impl HomeDirGuard { fn enter(path: &Path) -> Self { let home_backup = std::env::var_os("HOME"); let userprofile_backup = std::env::var_os("USERPROFILE"); unsafe { std::env::set_var("HOME", path); std::env::set_var("USERPROFILE", path); } Self { previous: home_backup, previous_userprofile: userprofile_backup, } } } impl Drop for HomeDirGuard { fn drop(&mut self) { unsafe { match &self.previous { Some(value) => std::env::set_var("HOME", value), None => std::env::remove_var("HOME"), } match &self.previous_userprofile { Some(value) => std::env::set_var("USERPROFILE", value), None => std::env::remove_var("USERPROFILE"), } } } } fn acquire_test_lock() -> std::sync::MutexGuard<'static, ()> { acquire_expert_test_env_lock() } #[test] fn test_split_frontmatter() { let input = "---\ndescription: demo\n---\nhello"; let (fm, body) = split_frontmatter(input).unwrap(); assert!(fm.contains("description")); assert_eq!(body, "hello"); } #[test] fn test_parse_expert_file_requires_description() { let dir = tempfile::tempdir().unwrap(); let expert_dir = dir.path().join("demo"); fs::create_dir_all(&expert_dir).unwrap(); let expert_md = expert_dir.join("EXPERT.md"); fs::write(&expert_md, "---\nname: demo\n---\ncontent").unwrap(); let err = parse_expert_file(&expert_md, ExpertSource::Project).unwrap_err(); assert!(err.contains("description")); } #[test] fn test_parse_expert_file_falls_back_to_dir_name() { let dir = tempfile::tempdir().unwrap(); let expert_dir = dir.path().join("from-dir"); fs::create_dir_all(&expert_dir).unwrap(); let expert_md = expert_dir.join("EXPERT.md"); fs::write( &expert_md, "---\ndescription: demo expert\n---\nYou are a translator.", ) .unwrap(); let expert = parse_expert_file(&expert_md, ExpertSource::Project).unwrap(); assert_eq!(expert.name, "from-dir"); assert_eq!(expert.description, "demo expert"); assert_eq!(expert.body, "You are a translator."); } #[test] fn test_render_expert_file_requires_description() { let err = render_expert_file("demo", " ", "body").unwrap_err(); assert!(err.contains("description")); } #[test] fn test_discover_prefers_project_over_user() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); // user scope let user_dir = home_dir.join(".picobot").join("experts").join("demo"); fs::create_dir_all(&user_dir).unwrap(); fs::write( user_dir.join("EXPERT.md"), "---\ndescription: user version\n---\nUser body", ) .unwrap(); // project scope (overrides user) let project_dir_expert = project_dir .join(".picobot") .join("experts") .join("demo"); fs::create_dir_all(&project_dir_expert).unwrap(); fs::write( project_dir_expert.join("EXPERT.md"), "---\ndescription: project version\n---\nProject body", ) .unwrap(); let catalog = ExpertCatalog::discover(&ExpertsConfig { enabled: true, sources: vec!["user".to_string(), "project".to_string()], }); assert_eq!(catalog.len(), 1); let expert = catalog.find_expert("demo").unwrap(); assert_eq!(expert.source, ExpertSource::Project); assert_eq!(expert.description, "project version"); assert_eq!(expert.body, "Project body"); } #[test] fn test_create_update_delete_cycle() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); assert_eq!(runtime.list_experts().len(), 0); let created = runtime .create_expert( ExpertScope::Project, "translator", "翻译专家", "你是一名专业翻译。", true, ) .unwrap(); assert_eq!(created.name, "translator"); assert_eq!(runtime.list_experts().len(), 1); // Duplicate create -> error let dup = runtime.create_expert( ExpertScope::Project, "translator", "dup", "body", true, ); assert!(dup.is_err()); let updated = runtime .update_expert( ExpertScope::Project, "translator", Some("更新翻译专家"), Some("你是一名中文教师。"), true, ) .unwrap(); assert_eq!(updated.description, "更新翻译专家"); assert_eq!(updated.body, "你是一名中文教师。"); // update with None preserves fields let updated_none = runtime .update_expert(ExpertScope::Project, "translator", None, None, true) .unwrap(); assert_eq!(updated_none.description, "更新翻译专家"); assert_eq!(updated_none.body, "你是一名中文教师。"); let deleted_path = runtime .delete_expert(ExpertScope::Project, "translator", true) .unwrap(); assert!(!deleted_path.exists()); assert_eq!(runtime.list_experts().len(), 0); } #[test] fn test_select_and_selected_expert_for() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); runtime .create_expert( ExpertScope::Project, "coder", "编程专家", "你是一名编程专家。", true, ) .unwrap(); // No selection initially assert!(runtime.selected_expert_for("sess-1").is_none()); assert!(runtime.selected_expert_name_for("sess-1").is_none()); runtime.select_expert("sess-1", "coder").unwrap(); let selected = runtime.selected_expert_for("sess-1").unwrap(); assert_eq!(selected.name, "coder"); assert_eq!( runtime.selected_expert_name_for("sess-1"), Some("coder".to_string()) ); // persistence: reload from disk let runtime2 = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); assert_eq!( runtime2.selected_expert_name_for("sess-1"), Some("coder".to_string()) ); } #[test] fn test_clear_expert_removes_selection() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); runtime .create_expert( ExpertScope::Project, "writer", "写作专家", "你是一名写作专家。", true, ) .unwrap(); runtime.select_expert("sess-clear", "writer").unwrap(); assert!(runtime.selected_expert_for("sess-clear").is_some()); runtime.clear_expert("sess-clear").unwrap(); assert!(runtime.selected_expert_for("sess-clear").is_none()); // clear persists across reloads let runtime2 = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); assert!(runtime2.selected_expert_for("sess-clear").is_none()); } #[test] fn test_disable_expert_makes_selected_return_none() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); runtime .create_expert( ExpertScope::Project, "reviewer", "代码审查专家", "你是一名代码审查专家。", true, ) .unwrap(); runtime.select_expert("sess-disable", "reviewer").unwrap(); assert!(runtime.selected_expert_for("sess-disable").is_some()); let change = runtime .disable_expert(ExpertScope::Project, "reviewer") .unwrap(); assert!(change.changed); assert!(!change.available); assert_eq!(change.disabled_in_scopes, vec![ExpertScope::Project]); // After disabling, selection no longer resolves. assert!(runtime.selected_expert_for("sess-disable").is_none()); // Re-enable restores availability let change = runtime .enable_expert(ExpertScope::Project, "reviewer") .unwrap(); assert!(change.changed); assert!(change.available); assert!(change.disabled_in_scopes.is_empty()); assert!(runtime.selected_expert_for("sess-disable").is_some()); } #[test] fn test_list_experts_with_status_includes_disabled() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], }); runtime .create_expert( ExpertScope::Project, "planner", "规划专家", "你是一名规划专家。", true, ) .unwrap(); // initially enabled let items = runtime.list_experts_with_status(); assert_eq!(items.len(), 1); assert!(items[0].disabled_in_scopes.is_empty()); // disable in project scope runtime .disable_expert(ExpertScope::Project, "planner") .unwrap(); let items = runtime.list_experts_with_status(); assert_eq!(items.len(), 1, "list_experts_with_status should include disabled experts"); assert_eq!(items[0].name, "planner"); assert_eq!(items[0].disabled_in_scopes, vec!["project".to_string()]); // list_experts (filtered) should be empty let active = runtime.list_experts(); assert_eq!(active.len(), 0, "list_experts should filter out disabled experts"); } #[test] fn test_state_file_roundtrip_preserves_both_fields() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("expert-state.json"); let mut state = ExpertStateFile { session_experts: HashMap::new(), disabled_experts: vec!["demo".to_string()], }; state.session_experts.insert("sess-1".to_string(), "demo".to_string()); save_expert_state_file(&path, &state).unwrap(); let loaded = load_expert_state_file(&path).unwrap(); assert_eq!(loaded, state); } #[test] fn test_persist_session_experts_preserves_disabled() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); // Pre-populate the project state file with a disabled expert. let state_path = project_dir.join(".picobot").join("expert-state.json"); fs::create_dir_all(state_path.parent().unwrap()).unwrap(); save_expert_state_file( &state_path, &ExpertStateFile { session_experts: HashMap::new(), disabled_experts: vec!["locked".to_string()], }, ) .unwrap(); // Now persist a session selection; the disabled_experts field should survive. persist_session_experts(&project_dir, |state| { state .session_experts .insert("sess-x".to_string(), "translator".to_string()); }) .unwrap(); let loaded = load_expert_state_file(&state_path).unwrap(); assert_eq!(loaded.disabled_experts, vec!["locked".to_string()]); assert_eq!( loaded.session_experts.get("sess-x"), Some(&"translator".to_string()) ); } #[test] fn test_prompt_provider_returns_none_without_selection() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], })); let provider = ExpertPromptProvider::new(runtime.clone()); // No session_id let ctx = SystemPromptContext { session_id: None, chat_id: "chat".to_string(), user_message_count: 1, }; assert!(provider.build(&ctx).is_none()); // session_id present but no selection let ctx = SystemPromptContext { session_id: Some("no-selection".to_string()), chat_id: "chat".to_string(), user_message_count: 1, }; assert!(provider.build(&ctx).is_none()); } #[test] fn test_prompt_provider_returns_body_when_selected() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], })); runtime .create_expert( ExpertScope::Project, "teacher", "教师专家", "你是一名中文教师,请用中文回答。", true, ) .unwrap(); runtime.select_expert("sess-p", "teacher").unwrap(); let provider = ExpertPromptProvider::new(runtime.clone()); let ctx = SystemPromptContext { session_id: Some("sess-p".to_string()), chat_id: "chat".to_string(), user_message_count: 1, }; let prompt = provider.build(&ctx).unwrap(); assert_eq!(prompt.context, Some("expert".to_string())); assert!(prompt.content.contains("中文教师")); } #[test] fn test_prompt_provider_injects_header_when_body_empty() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let runtime = Arc::new(ExpertRuntime::from_config(ExpertsConfig { enabled: true, sources: vec!["project".to_string()], })); runtime .create_expert( ExpertScope::Project, "empty-body", "无 body 的专家", "", // body empty true, ) .unwrap(); runtime.select_expert("sess-empty", "empty-body").unwrap(); let provider = ExpertPromptProvider::new(runtime.clone()); let ctx = SystemPromptContext { session_id: Some("sess-empty".to_string()), chat_id: "chat".to_string(), user_message_count: 1, }; let prompt = provider.build(&ctx).unwrap(); assert!(prompt.content.contains("# 专家角色: empty-body")); assert!(prompt.content.contains("无 body 的专家")); } #[test] fn test_disabled_config_returns_empty_catalog() { let _lock = acquire_test_lock(); let temp_dir = tempfile::tempdir().unwrap(); let home_dir = temp_dir.path().join("home"); let project_dir = temp_dir.path().join("project"); fs::create_dir_all(&home_dir).unwrap(); fs::create_dir_all(&project_dir).unwrap(); let _home = HomeDirGuard::enter(&home_dir); let _guard = CurrentDirGuard::enter(&project_dir); let project_expert_dir = project_dir.join(".picobot").join("experts").join("ignored"); fs::create_dir_all(&project_expert_dir).unwrap(); fs::write( project_expert_dir.join("EXPERT.md"), "---\ndescription: should be ignored\n---\nbody", ) .unwrap(); let catalog = ExpertCatalog::discover(&ExpertsConfig { enabled: false, sources: vec!["project".to_string()], }); assert_eq!(catalog.len(), 0); } }