f7a720204a
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
122 lines
5.2 KiB
Rust
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:?}"
|
|
);
|
|
}
|