- df-mcp P0: update_idea/project/task 缺省回退 existing(治部分更新清空 title 丢数据)+ P1: evaluate_idea 拆只读(Low)+score_idea(Medium 写),read-only 不再改库 - df-execute P0: probe_pwsh 死缓存(两 OnceLock 合并 PWSH_CACHE 单源,治 Windows 永走 PS5)+ P1: probe_pwsh 3s 超时防挂起 + detect_shell 复用 shell.rs 单源(探测与执行对齐) - df-nodes P1: ai_self_review prompt 注入隔离(truncate + XML 标签 <task_output> 数据/指令隔离 + system 声明) - generate_image: SSRF 集成测试 + b64 OOM 估算纯函数测试(走查测试增强)
208 lines
7.3 KiB
Rust
208 lines
7.3 KiB
Rust
//! update_idea/update_project/update_task 部分更新回归测试
|
|
//!
|
|
//! 回归 P0 bug:LLM 客户端做部分更新(只传 description 不传 title)时,
|
|
//! 旧实现用 `arg_str_or(args, "title", "")` 取值,缺省 → 空串覆盖 existing
|
|
//! → title 被静默清空 → 数据丢失。
|
|
//!
|
|
//! 根本修:title/description(name)缺省回退 existing,而非空默认覆盖。
|
|
//! 本测试覆盖三个 handler 的「只传一个字段,另一字段保留 existing」语义。
|
|
|
|
use df_mcp::tools::{find, Ctx};
|
|
use df_storage::db::Database;
|
|
use serde_json::{json, Value};
|
|
|
|
/// 从 CallToolResult 取首个 text 块解析为 JSON。
|
|
fn result_json(res: &df_mcp::protocol::CallToolResult) -> Value {
|
|
assert!(
|
|
res.is_error != Some(true),
|
|
"工具调用失败(is_error=true): {:?}",
|
|
res.content
|
|
);
|
|
match res.content.first() {
|
|
Some(df_mcp::protocol::ContentBlock::Text { text }) => {
|
|
serde_json::from_str(text).expect("响应非合法 JSON")
|
|
}
|
|
other => panic!("预期 Text 块,实际: {other:?}"),
|
|
}
|
|
}
|
|
|
|
/// 取嵌套对象 record(title/name/description 等业务字段在其下)。
|
|
fn record_of(v: &Value, key: &str) -> Value {
|
|
v.get(key)
|
|
.cloned()
|
|
.unwrap_or_else(|| panic!("响应缺 `{key}` 字段: {v}"))
|
|
}
|
|
|
|
async fn setup() -> Ctx {
|
|
let db = Database::open_in_memory().await.expect("open_in_memory");
|
|
Ctx::new(std::sync::Arc::new(db))
|
|
}
|
|
|
|
async fn call(ctx: &Ctx, name: &str, args: Value) -> Value {
|
|
let spec = find(name).expect("工具已注册");
|
|
let res = (spec.handler)(ctx, args).await;
|
|
result_json(&res)
|
|
}
|
|
|
|
// ============================================================
|
|
// update_idea:只传 description,title 必须保留 existing
|
|
// ============================================================
|
|
|
|
#[tokio::test]
|
|
async fn update_idea_keeps_title_when_only_description_sent() {
|
|
let ctx = setup().await;
|
|
// 先建一条想法:title="原始标题"
|
|
let created = call(
|
|
&ctx,
|
|
"create_idea",
|
|
json!({ "title": "原始标题", "description": "原始描述" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
// LLM 只传 description(不传 title)—— 旧实现会把 title 清空为 ""
|
|
let updated = call(
|
|
&ctx,
|
|
"update_idea",
|
|
json!({ "id": id, "description": "新描述" }),
|
|
)
|
|
.await;
|
|
let idea = record_of(&updated, "idea");
|
|
assert_eq!(idea["title"].as_str(), Some("原始标题"), "title 应保留 existing,不被空默认清空");
|
|
assert_eq!(idea["description"].as_str(), Some("新描述"), "description 应更新为新值");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_idea_keeps_description_when_only_title_sent() {
|
|
let ctx = setup().await;
|
|
let created = call(
|
|
&ctx,
|
|
"create_idea",
|
|
json!({ "title": "原标题", "description": "原描述" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
let updated = call(&ctx, "update_idea", json!({ "id": id, "title": "新标题" })).await;
|
|
let idea = record_of(&updated, "idea");
|
|
assert_eq!(idea["title"].as_str(), Some("新标题"));
|
|
assert_eq!(idea["description"].as_str(), Some("原描述"), "description 应保留 existing");
|
|
}
|
|
|
|
// ============================================================
|
|
// update_project:只传 description,name 必须保留 existing
|
|
// ============================================================
|
|
|
|
#[tokio::test]
|
|
async fn update_project_keeps_name_when_only_description_sent() {
|
|
let ctx = setup().await;
|
|
let created = call(
|
|
&ctx,
|
|
"create_project",
|
|
json!({ "name": "原始项目", "description": "原始描述" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
let updated = call(
|
|
&ctx,
|
|
"update_project",
|
|
json!({ "id": id, "description": "新描述" }),
|
|
)
|
|
.await;
|
|
let project = record_of(&updated, "project");
|
|
assert_eq!(project["name"].as_str(), Some("原始项目"), "name 应保留 existing");
|
|
assert_eq!(project["description"].as_str(), Some("新描述"));
|
|
// path/stack/idea_id 未传也应保留(existing 创建时为 None,这里间接保证不被改)
|
|
assert_eq!(project["path"].as_str(), None);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_project_keeps_status_when_not_sent() {
|
|
// 状态字段缺省同样应保留 existing(旧实现默认 "planning" 会重置状态)
|
|
let ctx = setup().await;
|
|
let created = call(
|
|
&ctx,
|
|
"create_project",
|
|
json!({ "name": "P", "description": "D", "status": "in_progress" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
let updated = call(
|
|
&ctx,
|
|
"update_project",
|
|
json!({ "id": id, "description": "改描述" }),
|
|
)
|
|
.await;
|
|
let project = record_of(&updated, "project");
|
|
assert_eq!(
|
|
project["status"].as_str(),
|
|
Some("in_progress"),
|
|
"status 应保留 existing,不被默认 planning 重置"
|
|
);
|
|
}
|
|
|
|
// ============================================================
|
|
// update_task:只传 description,title/project_id 必须保留 existing
|
|
// ============================================================
|
|
|
|
#[tokio::test]
|
|
async fn update_task_keeps_title_and_project_when_only_description_sent() {
|
|
let ctx = setup().await;
|
|
let proj = call(
|
|
&ctx,
|
|
"create_project",
|
|
json!({ "name": "所属项目", "description": "d" }),
|
|
)
|
|
.await;
|
|
let project_id = proj["id"].as_str().expect("project id").to_owned();
|
|
|
|
let created = call(
|
|
&ctx,
|
|
"create_task",
|
|
json!({ "project_id": project_id, "title": "原始任务标题", "description": "原始描述" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
// 只传 description:旧实现 title 是必填会报错,description 缺省会清空(行为不一)
|
|
// 根本修后三者都应保留 existing(或更新为新值)
|
|
let updated = call(
|
|
&ctx,
|
|
"update_task",
|
|
json!({ "id": id, "description": "新描述" }),
|
|
)
|
|
.await;
|
|
let task = record_of(&updated, "task");
|
|
assert_eq!(task["title"].as_str(), Some("原始任务标题"), "title 应保留 existing");
|
|
assert_eq!(task["description"].as_str(), Some("新描述"));
|
|
assert_eq!(task["project_id"].as_str(), Some(project_id.as_str()), "project_id 应保留 existing");
|
|
// status 走状态机,update_task 不改也应保留
|
|
assert_eq!(task["status"].as_str(), Some("todo"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn update_task_keeps_description_when_only_title_sent() {
|
|
let ctx = setup().await;
|
|
let proj = call(
|
|
&ctx,
|
|
"create_project",
|
|
json!({ "name": "P2", "description": "d" }),
|
|
)
|
|
.await;
|
|
let project_id = proj["id"].as_str().expect("project id").to_owned();
|
|
let created = call(
|
|
&ctx,
|
|
"create_task",
|
|
json!({ "project_id": project_id, "title": "原标题", "description": "原描述" }),
|
|
)
|
|
.await;
|
|
let id = created["id"].as_str().expect("id").to_owned();
|
|
|
|
let updated = call(&ctx, "update_task", json!({ "id": id, "title": "新标题" })).await;
|
|
let task = record_of(&updated, "task");
|
|
assert_eq!(task["title"].as_str(), Some("新标题"));
|
|
assert_eq!(task["description"].as_str(), Some("原描述"), "description 应保留 existing");
|
|
}
|