重构: 后端编排拆解与DRY债收敛及命名纠偏
阶段1拆解: stream 系外移 stream_relay.rs,mod.rs 3048→2607,loop_body 502→83 技能收尾: regenerate/edit 回溯补技能,新增 read_skill 工具,项目级技能扫描 DRY: emit 双写 46 处收敛 event_bus 正典,KV 引号归一收口,LOCK-SLOW 33 处单行化 警示骨架 4→1 与压缩兜底 2→1,噪音目录档位收敛,truncate 同名陷阱消除 工具元数据落 ToolMeta 八维,7 张 match 表改查 registry,parity 测试锁等价 命名: is_noise_entry/teardown_round/probe_missed_terminal_event 等 6 项纠偏
This commit is contained in:
@@ -526,9 +526,19 @@ pub fn build_ai_tool_registry(
|
||||
register_generate_image_tool(&mut registry, db, allowed_dirs);
|
||||
register_get_app_config_tool(&mut registry, get_app_config_ctx);
|
||||
register_local_proxy_tools(&mut registry, local_proxy_ctx);
|
||||
register_read_skill_tool(&mut registry);
|
||||
registry
|
||||
}
|
||||
|
||||
/// read_skill AI 工具注册(1 个:按名读本机 Claude 技能规程正文,只读)— thin 委托。
|
||||
///
|
||||
/// 工具职责:LLM 主动调 read_skill(name) 获取技能 SKILL.md 剥 frontmatter 正文,
|
||||
/// 与 / 技能注入链共用 skills.rs 进程内缓存(同一真相源)。未命中返回明确错误文案。
|
||||
/// 风险:Low(纯只读)。声明式注册在 tools/read_skill.rs。
|
||||
fn register_read_skill_tool(registry: &mut AiToolRegistry) {
|
||||
super::tools::read_skill::register(registry);
|
||||
}
|
||||
|
||||
/// 网络层 AI 工具注册(1 个:http_request)— 结构化 HTTP(GET/POST/PUT/PATCH/DELETE)。
|
||||
/// 不持 db,纯 reqwest 调用。SSRF 防护 + 重定向每跳校验见 commands/ai/http.rs。
|
||||
/// 风险:GET=Medium(只读但触发外发)/ 写方法=High(须人工批准,对齐 audit.rs risk 分流)。
|
||||
@@ -869,7 +879,7 @@ pub(crate) fn list_dir_recursive<'a>(
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
// 噪音目录(.git/node_modules/target 等)既不深入、也不作 entry 返回(避免污染 AI 工具上下文)
|
||||
if skip_noise && is_noise_dir(&name) {
|
||||
if skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext) {
|
||||
continue;
|
||||
}
|
||||
// 临时文件(.bak/.tmp-write 等)也不返回:write_file 原子写过程的副产物,
|
||||
@@ -889,7 +899,7 @@ pub(crate) fn list_dir_recursive<'a>(
|
||||
"depth": depth,
|
||||
}));
|
||||
// 仅递归真实目录(symlink 不递归,防符号链接逃逸到 workspace 外)
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_dir(&name)) {
|
||||
if recursive && is_dir && depth < max_depth && !(skip_noise && is_noise_entry(&name, NoiseDirTier::AiContext)) {
|
||||
let child_path = std::path::Path::new(path).join(&name).to_string_lossy().into_owned();
|
||||
let truncated = list_dir_recursive(&child_path, true, depth + 1, max_depth, max_entries, skip_noise, result).await?;
|
||||
if truncated {
|
||||
@@ -906,72 +916,85 @@ pub(crate) fn list_dir_recursive<'a>(
|
||||
/// 返回 Option<(&str, &[&str])>:(动作前缀模板, 关键参数名列表)。
|
||||
/// 模板中 `{}` 占位符由 build_approval_reason 按参数列表从 args 取值填充。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到通用风险提示。
|
||||
///
|
||||
/// DRY 债 #5 收敛:模板不再本文件手写 match,改查注册元数据(approval_hint 维度),
|
||||
/// 模板在 declare_tool! 注册点(tools/project.rs 等)声明。
|
||||
pub(crate) fn display_hint_for_tool(name: &str) -> Option<(&'static str, &'static [&'static str])> {
|
||||
let (template, keys): (&str, &[&str]) = match name {
|
||||
"delete_project" => ("删除项目{}", &["id"]),
|
||||
"restore_project" => ("从回收站恢复项目{}", &["id"]),
|
||||
"purge_project" => ("永久删除项目及关联数据,不可恢复{}", &["id"]),
|
||||
"update_project" => ("修改项目{}字段「{}」", &["id", "field"]),
|
||||
"bind_directory" => ("绑定目录:{}(项目{})", &["path", "id"]),
|
||||
"create_task" => ("创建任务:{}(项目{})", &["title", "project_id"]),
|
||||
"create_project" => ("创建项目:「{}」", &["name"]),
|
||||
"create_idea" => ("捕获灵感:{}", &["title"]),
|
||||
"patch_file" => ("修改文件:{}", &["path"]),
|
||||
"delete_file" => ("删除文件:{}({})", &["path", "permanent"]),
|
||||
"rename_file" => ("重命名/移动:{} → {}", &["from", "to"]),
|
||||
// F-03 收口:推进链工具审批文案(advance_task/run_workflow)
|
||||
"advance_task" => ("推进任务状态:{} → {}", &["id", "target_status"]),
|
||||
"run_workflow" => ("触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
|
||||
// http_request 审批文案:method + url(看是什么请求打到哪)
|
||||
"http_request" => ("HTTP 请求:{} {}", &["method", "url"]),
|
||||
_ => return None,
|
||||
};
|
||||
Some((template, keys))
|
||||
ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::approval_hint(name)
|
||||
}
|
||||
|
||||
/// 工具名 → 中文动作前缀(轻量映射,供 build_approval_reason 快速取可读标签)。
|
||||
///
|
||||
/// 仅返回动作动词+对象名(如 "创建任务"、"写入文件"),不含参数细节。
|
||||
/// 未注册的工具返回 None,由调用方 fallback 到 display_hint_for_tool 模板填充或硬编码。
|
||||
///
|
||||
/// DRY 债 #5 收敛:标签不再本文件手写 match,改查注册元数据(display_verb 维度)。
|
||||
/// advance_task 刻意无标签:轻量命中会丢 target_status 详情,审批文案须走模板,与旧表一致。
|
||||
pub(crate) fn tool_display_hint(name: &str) -> Option<&'static str> {
|
||||
match name {
|
||||
"create_task" => Some("创建任务"),
|
||||
"write_file" => Some("写入文件"),
|
||||
"read_file" => Some("读取文件"),
|
||||
"list_directory" => Some("列出目录"),
|
||||
"search_files" => Some("搜索文件"),
|
||||
"grep" => Some("搜索代码内容"),
|
||||
"create_project" => Some("创建项目"),
|
||||
"update_project" => Some("修改项目"),
|
||||
"delete_project" => Some("删除项目"),
|
||||
"restore_project" => Some("恢复项目"),
|
||||
"purge_project" => Some("永久删除项目"),
|
||||
"bind_directory" => Some("绑定目录"),
|
||||
"create_idea" => Some("捕获灵感"),
|
||||
"update_task" => Some("修改任务"),
|
||||
// advance_task 不在此轻量映射:让 build_approval_reason 走 display_hint_for_tool
|
||||
// 模板(含 target_status 详情),否则轻量命中"推进任务状态"丢失目标态信息
|
||||
"run_workflow" => Some("触发工作流"),
|
||||
"delete_task" => Some("删除任务"),
|
||||
"update_idea" => Some("修改灵感"),
|
||||
"delete_idea" => Some("删除灵感"),
|
||||
"patch_file" => Some("修改文件"),
|
||||
"append_file" => Some("追加写入"),
|
||||
"delete_file" => Some("删除文件"),
|
||||
"rename_file" => Some("重命名/移动"),
|
||||
"http_request" => Some("发起 HTTP 请求"),
|
||||
_ => None,
|
||||
}
|
||||
ensure_tool_meta_seeded();
|
||||
df_ai::ai_tools::display_verb(name)
|
||||
}
|
||||
|
||||
/// 判断是否为不应深入递归的噪音目录(构建产物/依赖/缓存等)
|
||||
pub(crate) fn is_noise_dir(name: &str) -> bool {
|
||||
const NOISE_DIRS: &[&str] = &[
|
||||
/// 元数据种子(仅测试构建):元数据查询依赖注册期写入的全局表。
|
||||
///
|
||||
/// 生产 build_ai_tool_registry 先于任何查询执行,表必非空,本函数恒空操作;
|
||||
/// 单测可能不经 build 直接调消费方自由函数(extract_file_tool_paths/tool_call_signature 等),
|
||||
/// 表尚空时用 in-memory db 构建一次真实注册表,让 declare_tool! 注册点把元数据写入全局表。
|
||||
/// Once 保证并发测试只种一次;表已非空(任一测试已 build)则跳过。
|
||||
#[cfg(test)]
|
||||
pub(crate) fn ensure_tool_meta_seeded() {
|
||||
static SEED: std::sync::Once = std::sync::Once::new();
|
||||
SEED.call_once(|| {
|
||||
if !df_ai::ai_tools::meta_table_is_empty() {
|
||||
return;
|
||||
}
|
||||
let rt = tokio::runtime::Runtime::new().expect("元数据种子 runtime");
|
||||
rt.block_on(async {
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let _ = build_ai_tool_registry(
|
||||
&db,
|
||||
&allowed_dirs,
|
||||
PathBuf::from(""),
|
||||
make_test_get_app_config_ctx(&db),
|
||||
make_test_local_proxy_ctx(),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/// 生产恒空(注册先于查询,无种子需求)。
|
||||
#[cfg(not(test))]
|
||||
pub(crate) fn ensure_tool_meta_seeded() {}
|
||||
|
||||
/// 噪音目录判定档位:不同消费面"哪些目录算噪音"的口径不同,差异用档位在此显式表达,
|
||||
/// 各消费方不再自持名单(DRY #4:全仓唯一名单入口)。
|
||||
pub(crate) enum NoiseDirTier {
|
||||
/// AI 工具上下文(list_directory/grep 递归):全量名单,构建产物/依赖/缓存/IDE 元数据/回收站不进上下文
|
||||
AiContext,
|
||||
/// 前端文件树/工程扫描:全量名单放行 `build` 与 `venv`(文件树面向用户浏览要见构建产物;
|
||||
/// venv 历史上未过滤,行为保持)。点开头目录(.git/.vite 等)由调用方的隐藏文件过滤兜住
|
||||
FileTree,
|
||||
/// 技能扫描递归剪枝:最小集(仅依赖/VCS)。扩大名单属行为变更(build/ 下若有 SKILL.md 会漏扫)
|
||||
SkillScan,
|
||||
}
|
||||
|
||||
/// 判断是否为不应深入递归/展示的噪音条目(目录/文件名皆可传入,构建产物/依赖/缓存等)。
|
||||
/// 全仓唯一名单入口。命中判定短路返回,技能扫描等热路径可放心逐条目调用。
|
||||
pub(crate) fn is_noise_entry(name: &str, tier: NoiseDirTier) -> bool {
|
||||
const FULL: &[&str] = &[
|
||||
".git", "node_modules", "target", "dist", "build",
|
||||
".next", ".cache", "__pycache__", ".venv", "venv", ".idea",
|
||||
".trash", // delete_file 软删除回收站,不污染 list_directory
|
||||
];
|
||||
NOISE_DIRS.contains(&name)
|
||||
match tier {
|
||||
NoiseDirTier::AiContext => FULL.contains(&name),
|
||||
// 文件树口径 = 全量名单放行 build(用户要看构建产物)与 venv(历史未过滤,行为保持)
|
||||
NoiseDirTier::FileTree => FULL.contains(&name) && !matches!(name, "build" | "venv"),
|
||||
// 技能扫描最小集:只防依赖/VCS 递归爆炸,与历史两项一致
|
||||
NoiseDirTier::SkillScan => matches!(name, "node_modules" | ".git"),
|
||||
}
|
||||
}
|
||||
|
||||
/// 判断是否为不应返回给 AI 的噪音文件(临时/备份/编辑器产物)。
|
||||
@@ -1062,7 +1085,7 @@ pub(crate) fn compile_glob_to_regex(glob: &str) -> anyhow::Result<regex::Regex>
|
||||
/// 递归跨文件内容搜索(grep -rn 模式)
|
||||
///
|
||||
/// 遍历 `dir`(深度 `depth`,上限 `max_depth`)下所有文件:
|
||||
/// - 跳过噪音目录(is_noise_dir)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
|
||||
/// - 跳过噪音目录(is_noise_entry)/噪音文件(is_noise_file)/symlink(file_type 不跟随)
|
||||
/// - glob 过滤器(可选):命中文件名才读内容
|
||||
/// - 二进制文件跳过:前 8KB 含 \0 视为二进制(对齐 read_file/list_directory)
|
||||
/// - 单文件 1MB 上限跳过(对齐 read_file,防读超大文件撑爆)
|
||||
@@ -1110,7 +1133,7 @@ pub(crate) fn grep_recursive<'a>(
|
||||
}
|
||||
if file_type.is_dir() {
|
||||
// 噪音目录(.git/node_modules/target/.trash 等)不深入
|
||||
if is_noise_dir(&file_name) {
|
||||
if is_noise_entry(&file_name, NoiseDirTier::AiContext) {
|
||||
continue;
|
||||
}
|
||||
if depth < max_depth {
|
||||
@@ -1281,40 +1304,46 @@ pub(crate) fn search_files_recursive<'a>(
|
||||
})
|
||||
}
|
||||
|
||||
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
|
||||
// 仅用于 build_ai_tool_registry 注册阶段,handler 不实际执行故无需真实配置)。
|
||||
// 提到 tests 模块外(file 级 cfg(test)):ensure_tool_meta_seeded 种子路径也要用。
|
||||
#[cfg(test)]
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||||
#[cfg(test)]
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
|
||||
///
|
||||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||||
#[cfg(test)]
|
||||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
db: db.clone(),
|
||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
||||
)),
|
||||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||||
)),
|
||||
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
|
||||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
|
||||
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
|
||||
#[cfg(test)]
|
||||
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
gateway_url: "http://127.0.0.1:8899".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
// get_app_config baseline 测试辅助:构造 GetAppConfigCtx(与 AppState init 同款默认值,
|
||||
// 仅用于 build_ai_tool_registry 注册阶段定义完整性断言,handler 不实际执行故无需真实配置)。
|
||||
use std::sync::atomic::{AtomicU64, AtomicUsize};
|
||||
use crate::state::LlmConcurrency;
|
||||
|
||||
/// 测试用 GetAppConfigCtx 构造器(默认值对齐 AppState::init:iter=10/retries=3/timeout=15/concurrency 3,2)。
|
||||
///
|
||||
/// build_ai_tool_registry 第 4 参,5 处测试调用共用,避免每处内联 5 行重复。
|
||||
/// Arc 句柄独立于 AppState(测试无 AppState),仅满足注册期类型签名,handler 不执行。
|
||||
fn make_test_get_app_config_ctx(db: &Arc<Database>) -> crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
crate::commands::ai::get_app_config::GetAppConfigCtx {
|
||||
db: db.clone(),
|
||||
agent_max_iterations: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_ITERATIONS,
|
||||
)),
|
||||
agent_max_retries: Arc::new(AtomicUsize::new(
|
||||
crate::commands::ai::agentic::DEFAULT_MAX_AGENT_RETRIES,
|
||||
)),
|
||||
approval_timeout_minutes: Arc::new(AtomicU64::new(15)),
|
||||
llm_concurrency: LlmConcurrency::new(3, 2),
|
||||
}
|
||||
}
|
||||
|
||||
/// build_ai_tool_registry 第 5 参(local_proxy 工具代理 ctx),测试调用共用。
|
||||
/// gateway_url 用默认本地网关即可,handler 不实际执行无需真实代理。
|
||||
fn make_test_local_proxy_ctx() -> crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
crate::commands::ai::tools::local_proxy::LocalProxyCtx {
|
||||
gateway_url: "http://127.0.0.1:8899".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 工具注册基线测试(SMELL-P0-2 拆分防护)
|
||||
@@ -1324,7 +1353,7 @@ mod tests {
|
||||
// 任一层漏移 register 调用,此测试立即红。工具名集合也断言,防 rename 致 LLM tool 突变。
|
||||
// ============================================================
|
||||
|
||||
/// build_ai_tool_registry 应注册恰好 76 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),且工具名集合稳定。
|
||||
/// build_ai_tool_registry 应注册恰好 77 个工具(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),且工具名集合稳定。
|
||||
///
|
||||
/// 用 in-memory SQLite(Database::open_in_memory 自跑迁移),构造零外部依赖的 db,
|
||||
// 不实际执行任何 handler——仅断言注册阶段的定义完整性,故无需真实数据。
|
||||
@@ -1395,10 +1424,14 @@ mod tests {
|
||||
// knowledge_promote(2026-10-01): data 层 44→45(新增候选知识显式转正工具,
|
||||
// candidate→published 单向,补齐自动晋升默认关后的对话内审核通道,Medium)。
|
||||
// 76 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy。
|
||||
// read_skill(2026-10-01): read_skill 层 0→1(按名读本机 Claude 技能规程正文,只读 Low,
|
||||
// 与 / 技能注入链共用 skills.rs 进程内缓存;未命中返明确错误,LLM 可向用户澄清。
|
||||
// 声明式注册在 tools/read_skill.rs,register_read_skill_tool thin 委托)。
|
||||
// 77 = 45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill。
|
||||
assert_eq!(
|
||||
registry.len(),
|
||||
76,
|
||||
"工具总数应为 76(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy),实际 {}", registry.len()
|
||||
77,
|
||||
"工具总数应为 77(45 data + 15 file + 1 http + 1 fetch_url + 1 fetch_search + 1 generate_image + 1 get_app_config + 11 local_proxy + 1 read_skill),实际 {}", registry.len()
|
||||
);
|
||||
|
||||
// 工具名集合基线:防 rename / 漏注册 / 误删除。
|
||||
@@ -1463,6 +1496,9 @@ mod tests {
|
||||
"ssh_exec", "mongo_find", "mongo_insert",
|
||||
"mongo_update", "mongo_delete", "redis_get",
|
||||
"redis_set", "redis_del",
|
||||
// ── read_skill 层 (1) ──(按名读本机 Claude 技能规程正文,只读 Low,
|
||||
// 与 / 技能注入链共用 skills.rs 进程内缓存,声明式注册 tools/read_skill.rs)
|
||||
"read_skill",
|
||||
];
|
||||
expected.sort_unstable();
|
||||
|
||||
@@ -1476,39 +1512,57 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||
/// is_noise_entry 纯函数:覆盖 .git/.gitignore 区分(目录是噪音,.gitignore 文件名不是)
|
||||
#[test]
|
||||
fn test_is_noise_dir_distinguishes_dir_and_gitignore_file() {
|
||||
fn test_is_noise_entry_distinguishes_dir_and_gitignore_file() {
|
||||
// 噪音目录命中
|
||||
assert!(is_noise_dir(".git"));
|
||||
assert!(is_noise_dir("node_modules"));
|
||||
assert!(is_noise_dir("target"));
|
||||
assert!(is_noise_dir("dist"));
|
||||
assert!(is_noise_dir("build"));
|
||||
assert!(is_noise_entry(".git", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("target", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("dist", NoiseDirTier::AiContext));
|
||||
assert!(is_noise_entry("build", NoiseDirTier::AiContext));
|
||||
// .gitignore / .gitattributes 等文件名不应命中(只匹配纯目录名 .git)
|
||||
assert!(!is_noise_dir(".gitignore"));
|
||||
assert!(!is_noise_dir(".gitattributes"));
|
||||
assert!(!is_noise_dir(".gitkeep"));
|
||||
assert!(!is_noise_entry(".gitignore", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry(".gitattributes", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry(".gitkeep", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:普通目录不命中
|
||||
/// is_noise_entry 纯函数:普通目录不命中
|
||||
#[test]
|
||||
fn test_is_noise_dir_normal_dirs_not_matched() {
|
||||
assert!(!is_noise_dir("src"));
|
||||
assert!(!is_noise_dir("tests"));
|
||||
assert!(!is_noise_dir("docs"));
|
||||
assert!(!is_noise_dir("main.rs"));
|
||||
assert!(!is_noise_dir(""));
|
||||
fn test_is_noise_entry_normal_dirs_not_matched() {
|
||||
assert!(!is_noise_entry("src", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("tests", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("docs", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("main.rs", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_dir 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||||
/// is_noise_entry 纯函数:大小写敏感(不靠 to_lowercase 误命中 Target)
|
||||
#[test]
|
||||
fn test_is_noise_dir_case_sensitive() {
|
||||
fn test_is_noise_entry_case_sensitive() {
|
||||
// 文件系统在 Windows 上大小写不敏感,但函数本身用精确匹配;
|
||||
// 锁定当前语义:大写变体不命中(避免后续误改 to_lowercase 引入行为变化)
|
||||
assert!(!is_noise_dir("Target"));
|
||||
assert!(!is_noise_dir("DIST"));
|
||||
assert!(!is_noise_dir("NODE_MODULES"));
|
||||
assert!(!is_noise_entry("Target", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("DIST", NoiseDirTier::AiContext));
|
||||
assert!(!is_noise_entry("NODE_MODULES", NoiseDirTier::AiContext));
|
||||
}
|
||||
|
||||
/// is_noise_entry 档位差异锁定:文件树放行 build/venv,技能扫描仅两项
|
||||
#[test]
|
||||
fn test_is_noise_entry_tier_deltas() {
|
||||
// FileTree:全量名单放行 build 与 venv(用户浏览要见构建产物),其余与 AiContext 一致
|
||||
assert!(!is_noise_entry("build", NoiseDirTier::FileTree));
|
||||
assert!(!is_noise_entry("venv", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("target", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("dist", NoiseDirTier::FileTree));
|
||||
assert!(is_noise_entry("__pycache__", NoiseDirTier::FileTree));
|
||||
assert!(!is_noise_entry("src", NoiseDirTier::FileTree));
|
||||
// SkillScan:最小集,仅依赖/VCS
|
||||
assert!(is_noise_entry("node_modules", NoiseDirTier::SkillScan));
|
||||
assert!(is_noise_entry(".git", NoiseDirTier::SkillScan));
|
||||
assert!(!is_noise_entry("target", NoiseDirTier::SkillScan));
|
||||
assert!(!is_noise_entry("build", NoiseDirTier::SkillScan));
|
||||
}
|
||||
|
||||
/// is_noise_file 纯函数:write_file 副产物 / 编辑器临时文件命中
|
||||
@@ -2381,4 +2435,191 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(staging["total"], 0);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DRY 债 #5 对照验证:5 处旧 match 表收敛到注册元数据后逐工具比对。
|
||||
//
|
||||
// 旧表值在本测试内固化为期望数据(收敛前从各 match 表逐行抄录):
|
||||
// path_auth 路径参数键 / cache 只读白名单 + 目录级 key / data_change entity-action /
|
||||
// tool_registry 审批模板 + 轻量标签 / helpers 目标描述 + 漂移签名(按样例 args 比对输出)。
|
||||
// 任何一维漂移此测试立即红。
|
||||
// ============================================================
|
||||
#[tokio::test]
|
||||
async fn test_tool_meta_parity_with_legacy_tables() {
|
||||
// 真实构建一次注册表(条目与全局表一致性断言用)
|
||||
let db = Arc::new(Database::open_in_memory().await.expect("in-memory db 初始化失败"));
|
||||
let allowed_dirs = Arc::new(RwLock::new(AllowedDirs::default_with_root()));
|
||||
let registry = build_ai_tool_registry(
|
||||
&db, &allowed_dirs, PathBuf::from(""),
|
||||
make_test_get_app_config_ctx(&db), make_test_local_proxy_ctx(),
|
||||
);
|
||||
ensure_tool_meta_seeded();
|
||||
|
||||
// ── 旧 path_auth::extract_file_tool_paths 表(name → 路径参数键)──
|
||||
let legacy_path_args: &[(&str, &[&str])] = &[
|
||||
("rename_file", &["from", "to"]),
|
||||
("read_file", &["path"]), ("write_file", &["path"]), ("list_directory", &["path"]),
|
||||
("patch_file", &["path"]), ("file_info", &["path"]), ("append_file", &["path"]),
|
||||
("delete_file", &["path"]), ("search_files", &["path"]), ("grep", &["path"]),
|
||||
];
|
||||
for (name, keys) in legacy_path_args {
|
||||
assert_eq!(df_ai::ai_tools::path_arg_keys(name), *keys, "path_args 漂移: {}", name);
|
||||
}
|
||||
// 旧 default:非文件工具返回空(不预校验)
|
||||
for name in ["run_command", "http_request", "create_task", "git_status", "read_symbol", "download_file", "diff_files"] {
|
||||
assert!(df_ai::ai_tools::path_arg_keys(name).is_empty(), "非文件工具不应有 path_args: {}", name);
|
||||
}
|
||||
|
||||
// ── 旧 cache.rs READONLY_CACHE_TOOLS 白名单 ──
|
||||
for name in ["read_file", "read_symbol", "list_directory", "search_files", "grep"] {
|
||||
assert!(df_ai::ai_tools::is_readonly(name), "readonly 漂移: {}", name);
|
||||
}
|
||||
for name in ["write_file", "patch_file", "run_command", "delete_file", "rename_file", "append_file", "file_info", "http_request", "git_commit", "mysql_query"] {
|
||||
assert!(!df_ai::ai_tools::is_readonly(name), "非只读工具误标 readonly: {}", name);
|
||||
}
|
||||
|
||||
// ── 旧 cache.rs 目录级 key / 目录专用警告判据(旧匹配 list_directory;幽灵 list_dir 已删)──
|
||||
assert!(df_ai::ai_tools::cache_path_key_only("list_directory"));
|
||||
for name in ["read_file", "grep", "search_files", "list_dir"] {
|
||||
assert!(!df_ai::ai_tools::cache_path_key_only(name), "cache_path_key 误标: {}", name);
|
||||
}
|
||||
|
||||
// ── 旧 path_auth search_files 盲搜硬拒 ──
|
||||
assert!(df_ai::ai_tools::denies_unauthorized("search_files"));
|
||||
for name in ["grep", "read_file", "list_directory", "run_command"] {
|
||||
assert!(!df_ai::ai_tools::denies_unauthorized(name), "deny_unauthorized 误标: {}", name);
|
||||
}
|
||||
|
||||
// ── 旧 data_change::data_change_for_tool 表(name, entity, action)──
|
||||
let legacy_entity_action: &[(&str, &str, &str)] = &[
|
||||
("create_project", "project", "create"),
|
||||
("create_task", "task", "create"),
|
||||
("create_idea", "idea", "create"),
|
||||
("update_project", "project", "update"),
|
||||
("update_task", "task", "update"),
|
||||
("update_idea", "idea", "update"),
|
||||
("advance_task", "task", "update"),
|
||||
("delete_project", "project", "delete"),
|
||||
("purge_project", "project", "delete"),
|
||||
("delete_task", "task", "delete"),
|
||||
("delete_idea", "idea", "delete"),
|
||||
("restore_project", "project", "update"),
|
||||
("bind_directory", "project", "update"),
|
||||
("write_file", "file", "update"),
|
||||
("patch_file", "file", "update"),
|
||||
];
|
||||
for (name, entity, action) in legacy_entity_action {
|
||||
assert_eq!(
|
||||
df_ai::ai_tools::entity_action(name),
|
||||
Some((*entity, *action)),
|
||||
"entity_action 漂移: {}", name
|
||||
);
|
||||
}
|
||||
for name in ["list_tasks", "run_workflow", "read_file", "git_status"] {
|
||||
assert_eq!(df_ai::ai_tools::entity_action(name), None, "entity_action 应为 None: {}", name);
|
||||
}
|
||||
|
||||
// ── 旧 tool_registry display_hint_for_tool 表(name, 模板, 键)──
|
||||
let legacy_approval: &[(&str, &str, &[&str])] = &[
|
||||
("delete_project", "删除项目{}", &["id"]),
|
||||
("restore_project", "从回收站恢复项目{}", &["id"]),
|
||||
("purge_project", "永久删除项目及关联数据,不可恢复{}", &["id"]),
|
||||
("update_project", "修改项目{}字段「{}」", &["id", "field"]),
|
||||
("bind_directory", "绑定目录:{}(项目{})", &["path", "id"]),
|
||||
("create_task", "创建任务:{}(项目{})", &["title", "project_id"]),
|
||||
("create_project", "创建项目:「{}」", &["name"]),
|
||||
("create_idea", "捕获灵感:{}", &["title"]),
|
||||
("patch_file", "修改文件:{}", &["path"]),
|
||||
("delete_file", "删除文件:{}({})", &["path", "permanent"]),
|
||||
("rename_file", "重命名/移动:{} → {}", &["from", "to"]),
|
||||
("advance_task", "推进任务状态:{} → {}", &["id", "target_status"]),
|
||||
("run_workflow", "触发工作流:任务{} 推进到 {}", &["task_id", "target_status"]),
|
||||
("http_request", "HTTP 请求:{} {}", &["method", "url"]),
|
||||
];
|
||||
for (name, template, keys) in legacy_approval {
|
||||
assert_eq!(
|
||||
df_ai::ai_tools::approval_hint(name),
|
||||
Some((*template, *keys)),
|
||||
"approval_hint 漂移: {}", name
|
||||
);
|
||||
}
|
||||
assert_eq!(df_ai::ai_tools::approval_hint("read_file"), None);
|
||||
|
||||
// ── 旧 tool_registry tool_display_hint 表(advance_task 刻意不在表内)──
|
||||
let legacy_verb: &[(&str, &str)] = &[
|
||||
("create_task", "创建任务"), ("write_file", "写入文件"), ("read_file", "读取文件"),
|
||||
("list_directory", "列出目录"), ("search_files", "搜索文件"), ("grep", "搜索代码内容"),
|
||||
("create_project", "创建项目"), ("update_project", "修改项目"), ("delete_project", "删除项目"),
|
||||
("restore_project", "恢复项目"), ("purge_project", "永久删除项目"), ("bind_directory", "绑定目录"),
|
||||
("create_idea", "捕获灵感"), ("update_task", "修改任务"), ("run_workflow", "触发工作流"),
|
||||
("delete_task", "删除任务"), ("update_idea", "修改灵感"), ("delete_idea", "删除灵感"),
|
||||
("patch_file", "修改文件"), ("append_file", "追加写入"), ("delete_file", "删除文件"),
|
||||
("rename_file", "重命名/移动"), ("http_request", "发起 HTTP 请求"),
|
||||
];
|
||||
for (name, verb) in legacy_verb {
|
||||
assert_eq!(df_ai::ai_tools::display_verb(name), Some(*verb), "display_verb 漂移: {}", name);
|
||||
}
|
||||
assert_eq!(df_ai::ai_tools::display_verb("advance_task"), None, "advance_task 无轻量标签(走模板),与旧表一致");
|
||||
assert_eq!(df_ai::ai_tools::display_verb("read_symbol"), None);
|
||||
|
||||
// ── 旧 helpers::infer_goal_from_tool_calls(样例 args 逐字比对输出)──
|
||||
let goal_of = |name: &str, args: Option<serde_json::Value>| -> Option<String> {
|
||||
df_ai::ai_tools::goal_of(name).and_then(|g| df_ai::ai_tools::goal_text(g, args.as_ref()))
|
||||
};
|
||||
// FileName 风格:按旧实现取末个 / 段(纯反斜杠路径旧 or_else 分支恒死,保持原样)
|
||||
assert_eq!(goal_of("patch_file", Some(serde_json::json!({"path": "a/b/c.rs"}))), Some("修改 c.rs".to_string()));
|
||||
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "docs/dry.md"}))), Some("修改 dry.md".to_string()));
|
||||
assert_eq!(goal_of("append_file", Some(serde_json::json!({"path": "log.txt"}))), Some("修改 log.txt".to_string()));
|
||||
assert_eq!(goal_of("read_file", Some(serde_json::json!({"path": "src/lib.rs"}))), Some("查看 lib.rs".to_string()));
|
||||
assert_eq!(goal_of("file_info", Some(serde_json::json!({"path": "Cargo.toml"}))), Some("查看 Cargo.toml".to_string()));
|
||||
assert_eq!(goal_of("delete_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
|
||||
assert_eq!(goal_of("rename_file", Some(serde_json::json!({"path": "a/b.txt"}))), Some("处理 b.txt".to_string()));
|
||||
// 反斜杠路径:旧实现 rsplit('/') 取整串(or_else 恒不触发),新实现保持同值
|
||||
assert_eq!(goal_of("write_file", Some(serde_json::json!({"path": "x\\y\\n.txt"}))), Some("修改 x\\y\\n.txt".to_string()));
|
||||
// Raw 风格
|
||||
assert_eq!(goal_of("list_directory", Some(serde_json::json!({"path": "/tmp/logs"}))), Some("浏览 /tmp/logs".to_string()));
|
||||
assert_eq!(goal_of("create_project", Some(serde_json::json!({"name": "moyu"}))), Some("创建项目 moyu".to_string()));
|
||||
assert_eq!(goal_of("create_idea", Some(serde_json::json!({"title": "灵感一闪"}))), Some("捕获灵感 灵感一闪".to_string()));
|
||||
// Truncate 风格 + 键回退 + 缺参兜底
|
||||
let long_cmd = "abcdefghijklmnopqrstuvwxyz0123456789";
|
||||
assert_eq!(goal_of("run_command", Some(serde_json::json!({"command": long_cmd}))), Some(format!("执行 {}", &long_cmd[..25])));
|
||||
assert_eq!(goal_of("search_files", Some(serde_json::json!({"pattern": long_cmd}))), Some(format!("搜索 {}", &long_cmd[..25])));
|
||||
assert_eq!(goal_of("grep", Some(serde_json::json!({"query": "todo"}))), Some("搜索 todo".to_string()));
|
||||
assert_eq!(goal_of("search_files", Some(serde_json::json!({}))), Some("搜索文件".to_string()));
|
||||
assert_eq!(goal_of("run_command", Some(serde_json::json!({}))), Some("执行命令".to_string()));
|
||||
// 无键工具恒返兜底(旧 run_workflow 无条件 Some)
|
||||
assert_eq!(goal_of("run_workflow", Some(serde_json::json!({"task_id": "t1"}))), Some("执行工作流".to_string()));
|
||||
assert_eq!(goal_of("run_workflow", None), Some("执行工作流".to_string()));
|
||||
// 键缺失且无兜底返 None(旧表 _ => None 分支)
|
||||
assert_eq!(goal_of("read_file", Some(serde_json::json!({}))), None);
|
||||
assert_eq!(goal_of("update_task", Some(serde_json::json!({"id": "t1"}))), None);
|
||||
|
||||
// ── 旧 helpers::tool_call_signature(样例 args 逐字比对输出)──
|
||||
let sig = |name: &str, args: serde_json::Value| df_ai::ai_tools::tool_signature(name, &args);
|
||||
assert_eq!(sig("grep", serde_json::json!({"pattern": "MAX", "path": "src", "case_sensitive": true})), "grep:pattern=MAX,path=src");
|
||||
assert_eq!(sig("search_files", serde_json::json!({"query": "TODO", "glob": "**/*.rs"})), "search_files:pattern=TODO,path=**/*.rs");
|
||||
assert_eq!(sig("read_file", serde_json::json!({"path": "big.log", "offset": 50, "limit": 100})), "read_file:path=big.log,offset=50,limit=100");
|
||||
assert_eq!(sig("read_file", serde_json::json!({})), "read_file:path=,offset=,limit=");
|
||||
assert_eq!(sig("run_command", serde_json::json!({"command": "ls -la", "timeout": 5000})), "run_command:command=ls -la");
|
||||
assert_eq!(sig("list_directory", serde_json::json!({"path": "/tmp"})), "list_directory:path=/tmp");
|
||||
assert_eq!(sig("http_request", serde_json::json!({"url": "https://x.io", "method": "GET", "headers": {}})), "http_request:url=https://x.io,method=GET");
|
||||
assert_eq!(sig("fetch_url", serde_json::json!({"url": "https://y.io", "raw": false})), "fetch_url:url=https://y.io");
|
||||
// 未注册/未声明 sig 走旧 default:工具名 + 全参 JSON
|
||||
let unknown = sig("custom_tool", serde_json::json!({"x": 1, "y": "z"}));
|
||||
assert!(unknown.starts_with("custom_tool:name=custom_tool,args="), "未知工具应走全参兜底: {}", unknown);
|
||||
let nosig = sig("write_file", serde_json::json!({"path": "a"}));
|
||||
assert!(nosig.starts_with("write_file:name=write_file,args="), "未声明 sig 的已注册工具应走全参兜底: {}", nosig);
|
||||
|
||||
// ── 注册表条目与全局表一致(条目存储元数据 + 查询函数双面)──
|
||||
for name in registry.tool_names() {
|
||||
let entry = registry.meta(&name).expect("条目应有元数据");
|
||||
assert_eq!(Some(entry), df_ai::ai_tools::tool_meta(&name), "条目与全局表漂移: {}", name);
|
||||
}
|
||||
// 抽查条目维度:read_file 只读 + path 路径键;advance_task 有 entity 无轻量标签
|
||||
let rf = registry.meta("read_file").unwrap();
|
||||
assert!(rf.readonly && rf.path_args == ["path"]);
|
||||
let at = registry.meta("advance_task").unwrap();
|
||||
assert_eq!(at.entity_action, Some(("task", "update")));
|
||||
assert_eq!(at.display_verb, None);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user