后端: - split_frontmatter 兼容 CRLF/LF 行尾符,修复 Windows 下 EXPERT.md 解析失败 - Config::load_from 移除 fallback 到 cwd/config.json 的逻辑,避免静默加载项目目录配置导致 experts 字段缺失 - ExpertRuntime 新增 update_config 方法,save_config 时同步更新 ExpertRuntime 内部 config,sources 变更即时生效 - source_order 空数组不再兜底返回 [User, Project],尊重用户关闭所有源的意图 - from_config_with_cwd 增加诊断日志,输出 enabled/sources/cwd/discovered 前端: - ExpertSelector 弹窗改为按钮上方居中展开 - 专家卡片 name 完整显示(break-all),路径显示在卡片底部 - "管理专家"入口仅在无专家时显示 - 每次打开下拉都重新拉取列表和选中状态 - 监听设置弹窗关闭事件刷新已选专家(处理已选专家被禁用的情况)
1521 lines
50 KiB
Rust
1521 lines
50 KiB
Rust
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<Self> {
|
||
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<ExpertScope> 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<String>,
|
||
}
|
||
|
||
/// 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<ExpertScope>,
|
||
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<String, String>,
|
||
#[serde(default)]
|
||
pub disabled_experts: Vec<String>,
|
||
}
|
||
|
||
/// Merged disable state across user + project scopes.
|
||
#[derive(Debug, Clone, Default)]
|
||
struct ExpertDisableState {
|
||
user_disabled: HashSet<String>,
|
||
project_disabled: HashSet<String>,
|
||
}
|
||
|
||
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<ExpertScope> {
|
||
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<Expert>,
|
||
}
|
||
|
||
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<String, Expert> = 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<Expert> = 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<Expert> {
|
||
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<ExpertsConfig>,
|
||
catalog: RwLock<ExpertCatalog>,
|
||
disable_state: RwLock<ExpertDisableState>,
|
||
/// session_id -> selected expert name
|
||
session_experts: RwLock<HashMap<String, String>>,
|
||
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<ExpertCatalog, String> {
|
||
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<Expert> {
|
||
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<ExpertWithStatus> {
|
||
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<ExpertWithStatus> = 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<Expert> {
|
||
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<Expert, String> {
|
||
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<Expert, String> {
|
||
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<PathBuf, String> {
|
||
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<ExpertAvailabilityChange, String> {
|
||
self.set_expert_enabled(scope, name, false)
|
||
}
|
||
|
||
pub fn enable_expert(
|
||
&self,
|
||
scope: ExpertScope,
|
||
name: &str,
|
||
) -> Result<ExpertAvailabilityChange, String> {
|
||
self.set_expert_enabled(scope, name, true)
|
||
}
|
||
|
||
pub fn has_expert_definition(&self, name: &str) -> Result<bool, String> {
|
||
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<ExpertAvailabilityChange, String> {
|
||
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<String> = 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<String> = 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<Expert> {
|
||
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<String> {
|
||
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<ExpertRuntime>,
|
||
}
|
||
|
||
impl ExpertPromptProvider {
|
||
pub fn new(runtime: Arc<ExpertRuntime>) -> Self {
|
||
Self { runtime }
|
||
}
|
||
}
|
||
|
||
impl SystemPromptProvider for ExpertPromptProvider {
|
||
fn build(&self, context: &SystemPromptContext) -> Option<SystemPrompt> {
|
||
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<PathBuf> {
|
||
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<PathBuf> {
|
||
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<PathBuf, String> {
|
||
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<PathBuf, String> {
|
||
Ok(root_for_scope(scope, cwd)?.join(name))
|
||
}
|
||
|
||
fn expert_file_path(scope: ExpertScope, name: &str, cwd: &Path) -> Result<PathBuf, String> {
|
||
Ok(expert_dir_path(scope, name, cwd)?.join("EXPERT.md"))
|
||
}
|
||
|
||
// ========== Source ordering ==========
|
||
|
||
fn source_order(sources: &[String]) -> Vec<ExpertSource> {
|
||
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<PathBuf> {
|
||
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<String>,
|
||
}
|
||
|
||
fn render_expert_file(name: &str, description: &str, body: &str) -> Result<String, String> {
|
||
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<Expert> {
|
||
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<Expert, String> {
|
||
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<String> {
|
||
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<String> {
|
||
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<ExpertStateFile, String> {
|
||
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<String, String> {
|
||
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<F: FnOnce(&mut ExpertStateFile)>(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<OsString>,
|
||
previous_userprofile: Option<OsString>,
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|