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

122 lines
5.2 KiB
Rust

//! E2E regression for the unified write stack (P1). Drives the REAL nomi tool
//! → `LiveKnowledge*Sink` → `KnowledgeService::write_document` chain to prove:
//! 1. the reported bug is dead — a staged write-back where the model passes
//! the workspace-mount path lands in the review inbox mirroring the
//! original (NOT a new nested file), with the original untouched;
//! 2. the search → read → write loop updates the original in place by handle,
//! with zero path arithmetic and no duplicate file.
use std::sync::Arc;
use nomi_agent::knowledge_tools::{
KnowledgeReadTool, KnowledgeRetrievalSink, KnowledgeSearchTool, KnowledgeWritebackSink, KnowledgeWriteTool, WriteMode,
};
use nomi_tools::Tool;
use serde_json::json;
/// `nomifun_realtime` ships no public no-op broadcaster, so define a local one
/// (same pattern as `knowledge_search_e2e`).
struct NoopBroadcaster;
impl nomifun_realtime::EventBroadcaster for NoopBroadcaster {
fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage<serde_json::Value>) {}
}
async fn build_service() -> (Arc<nomifun_knowledge::KnowledgeService>, tempfile::TempDir) {
let db = nomifun_db::init_database_memory().await.expect("in-memory db");
let repo = Arc::new(nomifun_db::SqliteKnowledgeRepository::new(db.pool().clone()));
let tmp = tempfile::tempdir().unwrap();
let emitter = nomifun_knowledge::KnowledgeEventEmitter::new(Arc::new(NoopBroadcaster));
let svc = Arc::new(nomifun_knowledge::KnowledgeService::new(repo, tmp.path(), emitter));
(svc, tmp)
}
#[tokio::test]
async fn staged_write_tool_with_mount_prefixed_path_lands_in_inbox_not_nested() {
let (svc, _tmp) = build_service().await;
let info = svc.create_base("领域库", "", None, None).await.unwrap();
svc.write_file(&info.id, "terms.md", "ORIGINAL").await.unwrap();
let sink: Arc<dyn KnowledgeWritebackSink> =
Arc::new(nomifun_ai_agent::LiveKnowledgeWritebackSink { service: svc.clone() });
let tool = KnowledgeWriteTool::new(
sink,
vec![(info.id.clone(), info.name.clone())],
WriteMode::Staged { scope: "conv-9".into() },
vec![info.id.clone()],
);
// The exact reported mistake: the model passes the workspace-mount path.
let res = tool
.execute(json!({
"base": "领域库",
"rel_path": ".nomi/knowledge/领域库/terms.md",
"content": "PROPOSED EDIT"
}))
.await;
assert!(!res.is_error, "tool errored: {}", res.content);
// Original untouched; proposal staged under the mirrored path.
assert_eq!(svc.read_file(&info.id, "terms.md").await.unwrap().content, "ORIGINAL");
assert_eq!(
svc.read_file(&info.id, "_inbox/conv-9/terms.md").await.unwrap().content,
"PROPOSED EDIT"
);
// No stray nested file under the mount path.
let files = svc.list_files(&info.id).await.unwrap();
assert!(
!files.iter().any(|f| f.rel_path.contains(".nomi/knowledge")),
"must not create a nested mount-path file: {files:?}"
);
}
#[tokio::test]
async fn search_read_write_handle_loop_updates_original_in_direct_mode() {
let (svc, _tmp) = build_service().await;
let info = svc.create_base("金融库", "", None, None).await.unwrap();
svc.write_file(&info.id, "terms.md", "# 术语表\n市盈率 = PER\n").await.unwrap();
let retrieval: Arc<dyn KnowledgeRetrievalSink> =
Arc::new(nomifun_ai_agent::LiveKnowledgeRetrievalSink { service: svc.clone() });
let writeback: Arc<dyn KnowledgeWritebackSink> =
Arc::new(nomifun_ai_agent::LiveKnowledgeWritebackSink { service: svc.clone() });
let search = KnowledgeSearchTool::new(retrieval.clone(), vec![info.id.clone()]);
let read = KnowledgeReadTool::new(retrieval, vec![info.id.clone()]);
let write = KnowledgeWriteTool::new(
writeback,
vec![(info.id.clone(), info.name.clone())],
WriteMode::Direct,
vec![info.id.clone()],
);
// 1. Search → extract the opaque handle from the rendered result.
let s = search.execute(json!({"query": "市盈率"})).await;
assert!(!s.is_error, "{}", s.content);
let handle = s
.content
.lines()
.find_map(|l| l.trim().strip_prefix("handle: "))
.expect("search result must carry a handle")
.to_owned();
// 2. Read the full document by handle (no path arithmetic).
let r = read.execute(json!({ "handle": handle })).await;
assert!(!r.is_error && r.content.contains("市盈率"), "read by handle: {}", r.content);
// 3. Update by handle in DIRECT mode → overwrites the original in place.
let w = write
.execute(json!({ "handle": handle, "content": "# 术语表\n市盈率 = PER\nROE = 净资产收益率\n" }))
.await;
assert!(!w.is_error, "write by handle: {}", w.content);
let updated = svc.read_file(&info.id, "terms.md").await.unwrap().content;
assert!(updated.contains("ROE"), "original must be updated in place: {updated}");
let files = svc.list_files(&info.id).await.unwrap();
assert_eq!(
files.iter().filter(|f| f.rel_path.ends_with("terms.md")).count(),
1,
"must not create a duplicate document: {files:?}"
);
}