Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,886 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
use nomifun_api_types::{
|
||||
CreateConversationRequest, ListConversationsQuery, UpdateConversationRequest, WebSocketMessage,
|
||||
};
|
||||
use nomifun_common::{AgentKillReason, AgentType, AppError, ConversationSource, ConversationStatus, TimestampMs};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::SkillResolver;
|
||||
use nomifun_db::{SqliteConversationRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Test infrastructure ────────────────────────────────────────────
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
|
||||
fn take_events(&self) -> Vec<WebSocketMessage<serde_json::Value>> {
|
||||
std::mem::take(&mut self.events.lock().unwrap())
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopTaskManager;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IWorkerTaskManager for NoopTaskManager {
|
||||
fn get_task(&self, _: &str) -> Option<nomifun_ai_agent::AgentInstance> {
|
||||
None
|
||||
}
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
_: &str,
|
||||
_: nomifun_ai_agent::types::BuildTaskOptions,
|
||||
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
|
||||
Err(AppError::Internal("noop".into()))
|
||||
}
|
||||
fn kill(&self, _: &str, _: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
fn clear(&self) {}
|
||||
fn active_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptySkillResolver;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SkillResolver for EmptySkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_extension::ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[nomifun_extension::ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (ConversationService, Arc<TestBroadcaster>, Arc<dyn IWorkerTaskManager>) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr.clone(),
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo,
|
||||
);
|
||||
(svc, broadcaster, task_mgr)
|
||||
}
|
||||
|
||||
const USER_ID: &str = "system_default_user";
|
||||
|
||||
fn make_create_req() -> CreateConversationRequest {
|
||||
serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
// ── T1: Create conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_1_create_with_defaults() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
|
||||
let resp = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
assert!(resp.id > 0);
|
||||
assert_eq!(resp.r#type, AgentType::Acp);
|
||||
assert_eq!(resp.status, ConversationStatus::Pending);
|
||||
assert_eq!(resp.source, Some(ConversationSource::Nomifun));
|
||||
assert!(!resp.pinned);
|
||||
assert!(resp.pinned_at.is_none());
|
||||
assert_eq!(resp.extra["workspace"], "/home/user/project");
|
||||
assert!(resp.created_at > 0);
|
||||
assert_eq!(resp.created_at, resp.modified_at);
|
||||
|
||||
// Non-nomi: top-level model is None.
|
||||
assert!(resp.model.is_none(), "ACP response should not carry top-level model");
|
||||
|
||||
// WebSocket event
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "conversation.listChanged");
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[0].data["conversation_id"], resp.id);
|
||||
assert_eq!(events[0].data["source"], "nomifun");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_2_create_each_agent_type() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let types = vec![
|
||||
("acp", AgentType::Acp),
|
||||
("openclaw-gateway", AgentType::OpenclawGateway),
|
||||
("nanobot", AgentType::Nanobot),
|
||||
("remote", AgentType::Remote),
|
||||
("nomi", AgentType::Nomi),
|
||||
];
|
||||
|
||||
for (type_str, expected_type) in types {
|
||||
let body = if type_str == "nomi" {
|
||||
json!({
|
||||
"type": type_str,
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
})
|
||||
} else {
|
||||
json!({
|
||||
"type": type_str,
|
||||
"extra": {}
|
||||
})
|
||||
};
|
||||
let req: CreateConversationRequest = serde_json::from_value(body).unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.r#type, expected_type, "Type mismatch for {type_str}");
|
||||
if type_str == "nomi" {
|
||||
assert!(resp.model.is_some(), "nomi should keep top-level model");
|
||||
} else {
|
||||
assert!(resp.model.is_none(), "{type_str} should have no top-level model");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t1_3_create_with_optional_fields() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Custom Name",
|
||||
"source": "telegram",
|
||||
"channel_chat_id": "user:123",
|
||||
"extra": { "workspace": "/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.name, "Custom Name");
|
||||
assert_eq!(resp.source, Some(ConversationSource::Telegram));
|
||||
assert_eq!(resp.channel_chat_id.as_deref(), Some("user:123"));
|
||||
}
|
||||
|
||||
// ── T2: List conversations ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_1_list_empty() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let result = svc.list(USER_ID, ListConversationsQuery::default(), false).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
assert!(!result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_2_list_basic() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
for _ in 0..3 {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
}
|
||||
|
||||
let result = svc.list(USER_ID, ListConversationsQuery::default(), false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.total, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_3_cursor_pagination() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
for _ in 0..5 {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
}
|
||||
|
||||
// First page: limit=2
|
||||
let query = ListConversationsQuery {
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page1 = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(page1.items.len(), 2);
|
||||
assert!(page1.has_more);
|
||||
assert_eq!(page1.total, 5);
|
||||
|
||||
// Second page: cursor = last ID from page 1
|
||||
let cursor = page1.items.last().unwrap().id;
|
||||
let query2 = ListConversationsQuery {
|
||||
cursor: Some(cursor.to_string()),
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page2 = svc.list(USER_ID, query2, false).await.unwrap();
|
||||
assert_eq!(page2.items.len(), 2);
|
||||
assert!(page2.has_more);
|
||||
|
||||
// Third page
|
||||
let cursor2 = page2.items.last().unwrap().id;
|
||||
let query3 = ListConversationsQuery {
|
||||
cursor: Some(cursor2.to_string()),
|
||||
limit: Some(2),
|
||||
..Default::default()
|
||||
};
|
||||
let page3 = svc.list(USER_ID, query3, false).await.unwrap();
|
||||
assert_eq!(page3.items.len(), 1);
|
||||
assert!(!page3.has_more);
|
||||
|
||||
// No overlap between pages
|
||||
let all_ids: Vec<i64> = page1
|
||||
.items
|
||||
.iter()
|
||||
.chain(page2.items.iter())
|
||||
.chain(page3.items.iter())
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
let unique: std::collections::HashSet<&i64> = all_ids.iter().collect();
|
||||
assert_eq!(all_ids.len(), unique.len());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_4_source_filter() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
// 2 nomifun + 1 telegram
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let telegram_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"source": "telegram",
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
svc.create(USER_ID, telegram_req).await.unwrap();
|
||||
|
||||
let query = ListConversationsQuery {
|
||||
source: Some("telegram".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.items[0].source, Some(ConversationSource::Telegram));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t2_5_pinned_filter() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
// Pin one
|
||||
let pin_req: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
svc.update(USER_ID, &conv.id.to_string(), pin_req, &task_mgr).await.unwrap();
|
||||
|
||||
let query = ListConversationsQuery {
|
||||
pinned: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list(USER_ID, query, false).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert!(result.items[0].pinned);
|
||||
}
|
||||
|
||||
// ── T3: Get single conversation ────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_1_get_existing() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let created = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let fetched = svc.get(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
assert_eq!(fetched.r#type, created.r#type);
|
||||
assert_eq!(fetched.name, created.name);
|
||||
assert_eq!(fetched.status, created.status);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t3_2_get_not_found() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let err = svc.get(USER_ID, "non-existent-uuid").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T4: Update conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_1_update_name() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "New Name" })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert_eq!(updated.name, "New Name");
|
||||
assert!(updated.modified_at >= conv.modified_at);
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data["action"], "updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_2_pin_conversation() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert!(updated.pinned);
|
||||
assert!(updated.pinned_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_3_unpin_clears_pinned_at() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
// Pin
|
||||
let pin: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": true })).unwrap();
|
||||
let pinned = svc.update(USER_ID, &conv.id.to_string(), pin, &task_mgr).await.unwrap();
|
||||
assert!(pinned.pinned_at.is_some());
|
||||
|
||||
// Unpin
|
||||
let unpin: UpdateConversationRequest = serde_json::from_value(json!({ "pinned": false })).unwrap();
|
||||
let unpinned = svc.update(USER_ID, &conv.id.to_string(), unpin, &task_mgr).await.unwrap();
|
||||
assert!(!unpinned.pinned);
|
||||
assert!(unpinned.pinned_at.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_4_extra_merge_preserves_existing_keys() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/old", "contextFileName": "ctx.md" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
// Update only workspace
|
||||
let update_req: UpdateConversationRequest =
|
||||
serde_json::from_value(json!({ "extra": { "workspace": "/new" } })).unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), update_req, &task_mgr).await.unwrap();
|
||||
|
||||
assert_eq!(updated.extra["workspace"], "/new");
|
||||
assert_eq!(updated.extra["contextFileName"], "ctx.md");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_5_update_model() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
// Top-level model updates are only valid on nomi conversations
|
||||
// (Task 8 enforces the nomi-only rule in update).
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p2", "model": "new-model" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
let model = updated.model.unwrap();
|
||||
assert_eq!(model.provider_id, "p2");
|
||||
assert_eq!(model.model, "new-model");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t4_6_update_not_found() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "x" })).unwrap();
|
||||
let err = svc.update(USER_ID, "non-existent", req, &task_mgr).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T5: Delete conversation ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_1_delete_conversation() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
// Verify gone
|
||||
let err = svc.get(USER_ID, &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
|
||||
// Verify broadcast
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].data["action"], "deleted");
|
||||
assert_eq!(events[0].data["conversation_id"], conv.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_2_delete_then_get_returns_404() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
let err = svc.get(USER_ID, &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t5_3_delete_not_found() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let err = svc.delete(USER_ID, "non-existent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T11: WebSocket event verification ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_1_create_broadcasts_created() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let resp = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].name, "conversation.listChanged");
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[0].data["conversation_id"], resp.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_2_update_broadcasts_updated() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({ "name": "x" })).unwrap();
|
||||
svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events[0].data["action"], "updated");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t11_3_delete_broadcasts_deleted() {
|
||||
let (svc, broadcaster, _task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
broadcaster.take_events();
|
||||
|
||||
svc.delete(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events[0].data["action"], "deleted");
|
||||
}
|
||||
|
||||
// ── T12: Boundary scenarios ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_1_long_name() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
let long_name = "x".repeat(1000);
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": long_name,
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.name.len(), 1000);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_2_large_extra_json() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let large_extra = json!({
|
||||
"workspace": "/project",
|
||||
"nested": {
|
||||
"deep": {
|
||||
"array": [1, 2, 3, 4, 5],
|
||||
"object": { "key": "value" }
|
||||
}
|
||||
},
|
||||
"list": (0..100).collect::<Vec<_>>()
|
||||
});
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": large_extra
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
assert_eq!(resp.extra["workspace"], "/project");
|
||||
assert_eq!(resp.extra["nested"]["deep"]["array"][2], 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_3_concurrent_creates() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let mut handles = vec![];
|
||||
for _ in 0..10 {
|
||||
let svc = svc.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
svc.create(USER_ID, make_create_req()).await.unwrap()
|
||||
}));
|
||||
}
|
||||
|
||||
let mut ids = vec![];
|
||||
for handle in handles {
|
||||
let resp = handle.await.unwrap();
|
||||
ids.push(resp.id);
|
||||
}
|
||||
|
||||
// All IDs unique
|
||||
let unique: std::collections::HashSet<&i64> = ids.iter().collect();
|
||||
assert_eq!(ids.len(), unique.len());
|
||||
}
|
||||
|
||||
// ── Full lifecycle ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_lifecycle_create_get_update_delete() {
|
||||
let (svc, broadcaster, task_mgr) = setup().await;
|
||||
|
||||
// Create
|
||||
let created = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
assert_eq!(created.status, ConversationStatus::Pending);
|
||||
|
||||
// Get
|
||||
let fetched = svc.get(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.id, created.id);
|
||||
|
||||
// Update
|
||||
let update_req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"name": "Updated",
|
||||
"pinned": true,
|
||||
"extra": { "workspace": "/updated" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &created.id.to_string(), update_req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.name, "Updated");
|
||||
assert!(updated.pinned);
|
||||
assert_eq!(updated.extra["workspace"], "/updated");
|
||||
|
||||
// Delete
|
||||
svc.delete(USER_ID, &created.id.to_string()).await.unwrap();
|
||||
assert!(svc.get(USER_ID, &created.id.to_string()).await.is_err());
|
||||
|
||||
// Verify all events: created + updated + deleted
|
||||
let events = broadcaster.take_events();
|
||||
assert_eq!(events.len(), 3);
|
||||
assert_eq!(events[0].data["action"], "created");
|
||||
assert_eq!(events[1].data["action"], "updated");
|
||||
assert_eq!(events[2].data["action"], "deleted");
|
||||
}
|
||||
|
||||
// ── Type-aware model rules ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_top_level_model_for_acp() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let err = svc.create(USER_ID, req).await.unwrap_err();
|
||||
match err {
|
||||
AppError::BadRequest(msg) => {
|
||||
assert!(msg.contains("model"), "error message should mention model: {msg}");
|
||||
assert!(msg.contains("extra"), "error message should mention extra: {msg}");
|
||||
}
|
||||
other => panic!("expected BadRequest, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_rejects_top_level_model_for_remote() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "remote",
|
||||
"model": { "provider_id": "p1", "model": "m1" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
assert!(matches!(svc.create(USER_ID, req).await, Err(AppError::BadRequest(_))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_accepts_top_level_model_for_nomi() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.r#type, AgentType::Nomi);
|
||||
let model = resp.model.expect("nomi response should carry top-level model");
|
||||
assert_eq!(model.provider_id, "p1");
|
||||
assert_eq!(model.model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_nomi_strips_extra_model_field() {
|
||||
let (svc, _, _task_mgr) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {
|
||||
"workspace": "/home/user/project",
|
||||
"model": "bogus-from-legacy-client"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let resp = svc.create(USER_ID, req).await.unwrap();
|
||||
assert!(
|
||||
!resp.extra.as_object().unwrap().contains_key("model"),
|
||||
"nomi create must strip extra.model to avoid dual source of truth; got {:?}",
|
||||
resp.extra
|
||||
);
|
||||
// Top-level model is still present and wins.
|
||||
assert_eq!(resp.model.unwrap().model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_rejects_top_level_model_for_acp() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" }
|
||||
}))
|
||||
.unwrap();
|
||||
|
||||
let err = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, AppError::BadRequest(_)),
|
||||
"expected BadRequest, got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_accepts_top_level_model_for_nomi() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o-mini" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.model.unwrap().model, "gpt-4o-mini");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_non_nomi_extra_model_does_not_kill_task() {
|
||||
// Verifies the explicit rule that `extra.model` changes for non-nomi
|
||||
// do NOT trigger task_manager.kill. Since our `NoopTaskManager::kill` is
|
||||
// a no-op we can't assert the negative directly; we assert the update
|
||||
// succeeds and the merged extra carries the new field, and that top-level
|
||||
// model remains None.
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"extra": { "current_model_id": "claude-opus-4" }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
assert_eq!(updated.extra["current_model_id"], "claude-opus-4");
|
||||
assert!(updated.model.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_nomi_strips_extra_model_from_patch() {
|
||||
let (svc, _, task_mgr) = setup().await;
|
||||
|
||||
let create_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "gpt-4o" },
|
||||
"extra": {}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, create_req).await.unwrap();
|
||||
|
||||
// Client mistakenly sends extra.model on an nomi PATCH. It should be
|
||||
// silently stripped from the merged extra, not persisted.
|
||||
let req: UpdateConversationRequest = serde_json::from_value(json!({
|
||||
"extra": { "model": "legacy-value", "last_token_usage": { "total_tokens": 42 } }
|
||||
}))
|
||||
.unwrap();
|
||||
let updated = svc.update(USER_ID, &conv.id.to_string(), req, &task_mgr).await.unwrap();
|
||||
|
||||
assert!(
|
||||
!updated.extra.as_object().unwrap().contains_key("model"),
|
||||
"nomi PATCH must strip extra.model; got {:?}",
|
||||
updated.extra
|
||||
);
|
||||
// Other extra keys from the patch are merged as usual.
|
||||
assert_eq!(updated.extra["last_token_usage"]["total_tokens"], 42);
|
||||
// Top-level model unchanged by the extra-only patch.
|
||||
assert_eq!(updated.model.unwrap().model, "gpt-4o");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_acp_seeds_acp_session_runtime_from_extra() {
|
||||
use nomifun_db::{SqliteAcpSessionRepository, init_database_memory};
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(nomifun_db::SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = nomifun_conversation::ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": {
|
||||
"backend": "claude",
|
||||
"current_mode_id": "bypassPermissions",
|
||||
"current_model_id": "claude-opus-4"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let runtime = acp_session_repo
|
||||
.load_runtime_state(conv.id)
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("acp_session runtime state should exist after create");
|
||||
assert_eq!(
|
||||
runtime.current_mode_id.as_deref(),
|
||||
Some("bypassPermissions"),
|
||||
"extra.current_mode_id must be seeded into acp_session on create"
|
||||
);
|
||||
assert_eq!(
|
||||
runtime.current_model_id.as_deref(),
|
||||
Some("claude-opus-4"),
|
||||
"extra.current_model_id must be seeded into acp_session on create"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn create_acp_skips_seed_when_extra_has_empty_runtime_fields() {
|
||||
use nomifun_db::{SqliteAcpSessionRepository, init_database_memory};
|
||||
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(nomifun_db::SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = nomifun_conversation::ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo,
|
||||
agent_metadata_repo,
|
||||
acp_session_repo.clone(),
|
||||
);
|
||||
|
||||
// Both fields present but empty — treated as absent, no save_runtime_state call.
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "backend": "claude", "current_mode_id": "", "current_model_id": "" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let runtime = acp_session_repo.load_runtime_state(conv.id).await.unwrap();
|
||||
// Either `None` (no runtime key yet) or Some(default) — both mean "nothing seeded".
|
||||
assert!(
|
||||
runtime
|
||||
.as_ref()
|
||||
.is_none_or(|r| r.current_mode_id.is_none() && r.current_model_id.is_none()),
|
||||
"empty runtime fields should not produce a seed: got {runtime:?}"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,658 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::IWorkerTaskManager;
|
||||
use nomifun_api_types::{
|
||||
CloneConversationRequest, CreateConversationRequest, ListMessagesQuery, SearchMessagesQuery, WebSocketMessage,
|
||||
};
|
||||
use nomifun_common::{AgentKillReason, AppError, ConversationStatus, TimestampMs, generate_prefixed_id, now_ms};
|
||||
use nomifun_conversation::ConversationService;
|
||||
use nomifun_conversation::skill_resolver::SkillResolver;
|
||||
use nomifun_db::models::MessageRow;
|
||||
use nomifun_db::{IConversationRepository, SqliteConversationRepository, init_database_memory};
|
||||
use nomifun_realtime::EventBroadcaster;
|
||||
use serde_json::json;
|
||||
use std::sync::Mutex;
|
||||
|
||||
// ── Test infrastructure ────────────────────────────────────────────
|
||||
|
||||
struct TestBroadcaster {
|
||||
events: Mutex<Vec<WebSocketMessage<serde_json::Value>>>,
|
||||
}
|
||||
|
||||
impl TestBroadcaster {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
events: Mutex::new(vec![]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBroadcaster for TestBroadcaster {
|
||||
fn broadcast(&self, event: WebSocketMessage<serde_json::Value>) {
|
||||
self.events.lock().unwrap().push(event);
|
||||
}
|
||||
}
|
||||
|
||||
struct NoopTaskManager;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl IWorkerTaskManager for NoopTaskManager {
|
||||
fn get_task(&self, _: &str) -> Option<nomifun_ai_agent::AgentInstance> {
|
||||
None
|
||||
}
|
||||
async fn get_or_build_task(
|
||||
&self,
|
||||
_: &str,
|
||||
_: nomifun_ai_agent::types::BuildTaskOptions,
|
||||
) -> Result<nomifun_ai_agent::AgentInstance, AppError> {
|
||||
Err(AppError::Internal("noop".into()))
|
||||
}
|
||||
fn kill(&self, _: &str, _: Option<AgentKillReason>) -> Result<(), AppError> {
|
||||
Ok(())
|
||||
}
|
||||
fn kill_and_wait(
|
||||
&self,
|
||||
_: &str,
|
||||
_: Option<AgentKillReason>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = ()> + Send>> {
|
||||
Box::pin(std::future::ready(()))
|
||||
}
|
||||
fn clear(&self) {}
|
||||
fn active_count(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn collect_idle(&self, _: TimestampMs) -> Vec<String> {
|
||||
vec![]
|
||||
}
|
||||
}
|
||||
|
||||
struct EmptySkillResolver;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl SkillResolver for EmptySkillResolver {
|
||||
async fn auto_inject_names(&self) -> Vec<String> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn resolve_skills(&self, _names: &[String]) -> Vec<nomifun_extension::ResolvedAgentSkill> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
async fn link_workspace_skills(
|
||||
&self,
|
||||
_workspace: &std::path::Path,
|
||||
_rel_dirs: &[&str],
|
||||
_skills: &[nomifun_extension::ResolvedAgentSkill],
|
||||
) -> usize {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
async fn setup() -> (
|
||||
ConversationService,
|
||||
Arc<SqliteConversationRepository>,
|
||||
Arc<TestBroadcaster>,
|
||||
) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let broadcaster = Arc::new(TestBroadcaster::new());
|
||||
let agent_metadata_repo: Arc<dyn nomifun_db::IAgentMetadataRepository> =
|
||||
Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone()));
|
||||
let acp_session_repo: Arc<dyn nomifun_db::IAcpSessionRepository> =
|
||||
Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone()));
|
||||
let task_mgr: Arc<dyn IWorkerTaskManager> = Arc::new(NoopTaskManager);
|
||||
let svc = ConversationService::new(
|
||||
std::env::temp_dir(),
|
||||
broadcaster.clone(),
|
||||
Arc::new(EmptySkillResolver),
|
||||
task_mgr,
|
||||
repo.clone(),
|
||||
agent_metadata_repo,
|
||||
acp_session_repo,
|
||||
);
|
||||
(svc, repo, broadcaster)
|
||||
}
|
||||
|
||||
const USER_ID: &str = "system_default_user";
|
||||
|
||||
fn make_create_req() -> CreateConversationRequest {
|
||||
serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn make_message(conv_id: i64, content: &str, offset_ms: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: generate_prefixed_id("msg"),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(generate_prefixed_id("client")),
|
||||
r#type: "text".to_string(),
|
||||
content: format!(r#"{{"content":"{content}"}}"#),
|
||||
position: Some("right".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms() + offset_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn make_acp_tool_message(conv_id: i64, id: &str, output: &str, offset_ms: i64) -> MessageRow {
|
||||
MessageRow {
|
||||
id: id.to_string(),
|
||||
conversation_id: conv_id,
|
||||
msg_id: Some(id.to_string()),
|
||||
r#type: "acp_tool_call".to_string(),
|
||||
content: json!({
|
||||
"session_id": "session-1",
|
||||
"update": {
|
||||
"session_update": "tool_call",
|
||||
"tool_call_id": id,
|
||||
"status": "completed",
|
||||
"title": "rg",
|
||||
"kind": "search",
|
||||
"raw_input": { "pattern": "needle", "path": "." },
|
||||
"content": [{
|
||||
"type": "content",
|
||||
"content": { "type": "text", "text": output }
|
||||
}]
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
position: Some("left".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms() + offset_ms,
|
||||
}
|
||||
}
|
||||
|
||||
// ── T6: Clone conversation ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t6_2_clone_without_source() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req: CloneConversationRequest = serde_json::from_value(json!({
|
||||
"conversation": {
|
||||
"type": "acp",
|
||||
"name": "Direct",
|
||||
"extra": {}
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let resp = svc.clone_create(USER_ID, req).await.unwrap();
|
||||
assert_eq!(resp.name, "Direct");
|
||||
// No source to merge from — only the caller-provided CreateConversationRequest
|
||||
// drives `extra`, so source-only keys (e.g. `contextFileName`) must not appear.
|
||||
assert!(resp.extra.get("contextFileName").is_none());
|
||||
}
|
||||
|
||||
// ── T7: Reset conversation ─────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_1_reset_clears_messages_and_status() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
// Insert messages
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
svc.reset(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
|
||||
let fetched = svc.get(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
assert_eq!(fetched.status, ConversationStatus::Pending);
|
||||
|
||||
let messages = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(messages.items.is_empty());
|
||||
assert_eq!(messages.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t7_3_reset_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc.reset(USER_ID, "nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T8: Message list ───────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_1_empty_messages() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let result = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_2_pagination() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..10 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 100))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = ListMessagesQuery {
|
||||
page: Some(1),
|
||||
page_size: Some(3),
|
||||
order: None,
|
||||
content_mode: None,
|
||||
cursor: None,
|
||||
};
|
||||
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 3);
|
||||
assert_eq!(result.total, 10);
|
||||
assert!(result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_3_asc_order_default() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let result = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
// ASC (default): oldest first
|
||||
assert!(result.items[0].created_at <= result.items[1].created_at);
|
||||
assert!(result.items[1].created_at <= result.items[2].created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_4_asc_order() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..3 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("msg {i}"), i * 1000))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = ListMessagesQuery {
|
||||
order: Some("ASC".into()),
|
||||
..Default::default()
|
||||
};
|
||||
let result = svc.list_messages(USER_ID, &conv.id.to_string(), query).await.unwrap();
|
||||
assert!(result.items[0].created_at <= result.items[1].created_at);
|
||||
assert!(result.items[1].created_at <= result.items[2].created_at);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_5_conversation_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc
|
||||
.list_messages(USER_ID, "nonexistent", ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T9: Message search ─────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_6_compact_mode_truncates_large_tool_content_only_for_list_response() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
let large_output = "match line\n".repeat(10_000);
|
||||
|
||||
repo.insert_message(&make_acp_tool_message(conv.id, "tool-big", &large_output, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let full = svc
|
||||
.list_messages(USER_ID, &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
full.items[0].content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
|
||||
let compact = svc
|
||||
.list_messages(
|
||||
USER_ID,
|
||||
&conv.id.to_string(),
|
||||
ListMessagesQuery {
|
||||
content_mode: Some("compact".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let compact_content = &compact.items[0].content;
|
||||
let preview = compact_content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap();
|
||||
|
||||
assert!(compact_content["_compact"]["truncated"].as_bool().unwrap());
|
||||
assert!(compact_content["_compact"]["original_size"].as_u64().unwrap() > preview.len() as u64);
|
||||
assert!(preview.len() < large_output.len());
|
||||
assert!(!preview.contains(&large_output));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t8_7_get_message_returns_full_tool_content_after_compact_list() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
let large_output = "wide rg output\n".repeat(10_000);
|
||||
|
||||
repo.insert_message(&make_acp_tool_message(conv.id, "tool-detail", &large_output, 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let _ = svc
|
||||
.list_messages(
|
||||
USER_ID,
|
||||
&conv.id.to_string(),
|
||||
ListMessagesQuery {
|
||||
content_mode: Some("compact".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let detail = svc.get_message(USER_ID, &conv.id.to_string(), "tool-detail").await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
detail.content["update"]["content"][0]["content"]["text"]
|
||||
.as_str()
|
||||
.unwrap(),
|
||||
large_output
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_1_keyword_match() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
repo.insert_message(&make_message(conv.id, "Rust review report", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "Python test", 100))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "review".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
assert_eq!(result.total, 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
assert_eq!(item.message_type, "text");
|
||||
assert!(item.message_created_at > 0);
|
||||
assert!(item.preview_text.contains("Rust review report"));
|
||||
|
||||
assert_eq!(item.conversation.id, conv.id);
|
||||
assert_eq!(item.conversation.name, conv.name);
|
||||
assert_eq!(item.conversation.extra["workspace"], "/home/user/project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_2_no_match() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "hello world", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "xxxxnotexist".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_3_search_pagination() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
repo.insert_message(&make_message(conv.id, &format!("match keyword item {i}"), i * 100))
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "keyword".into(),
|
||||
page: Some(1),
|
||||
page_size: Some(2),
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 2);
|
||||
assert_eq!(result.total, 5);
|
||||
assert!(result.has_more);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_4_empty_keyword() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let err = svc.search_messages(USER_ID, query).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::BadRequest(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_5_preview_text_extracts_from_json_content() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let complex_msg = MessageRow {
|
||||
id: generate_prefixed_id("msg"),
|
||||
conversation_id: conv.id.clone(),
|
||||
msg_id: None,
|
||||
r#type: "text".to_string(),
|
||||
content: r#"[{"type":"text","content":"Design document for search"},{"type":"text","content":"feature implementation"}]"#.to_string(),
|
||||
position: Some("right".to_string()),
|
||||
status: Some("finish".to_string()),
|
||||
hidden: false,
|
||||
created_at: now_ms(),
|
||||
};
|
||||
repo.insert_message(&complex_msg).await.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "search".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
assert!(!item.preview_text.contains('{'));
|
||||
assert!(!item.preview_text.contains('['));
|
||||
assert!(item.preview_text.contains("Design document for search"));
|
||||
assert!(item.preview_text.contains("feature implementation"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_6_search_result_includes_conversation_model() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
// Search surfaces conversation.model only for nomi (the only type that
|
||||
// carries a top-level model under the nomi-only rule).
|
||||
let nomi_req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "nomi",
|
||||
"model": { "provider_id": "p1", "model": "claude-sonnet-4-20250514" },
|
||||
"extra": { "workspace": "/home/user/project" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, nomi_req).await.unwrap();
|
||||
|
||||
repo.insert_message(&make_message(conv.id, "model test keyword", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "model test".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert_eq!(result.items.len(), 1);
|
||||
|
||||
let item = &result.items[0];
|
||||
let model = item.conversation.model.as_ref().unwrap();
|
||||
assert_eq!(model.provider_id, "p1");
|
||||
assert_eq!(model.model, "claude-sonnet-4-20250514");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t9_7_search_does_not_leak_other_users_messages() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "secret keyword data", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "secret".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
let result = svc.search_messages("other_user_id", query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
assert_eq!(result.total, 0);
|
||||
}
|
||||
|
||||
// ── T10: Associated conversations ──────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_1_same_workspace() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req1: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv A",
|
||||
"extra": { "workspace": "/shared/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv1 = svc.create(USER_ID, req1).await.unwrap();
|
||||
|
||||
let req2: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv B",
|
||||
"extra": { "workspace": "/shared/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv2 = svc.create(USER_ID, req2).await.unwrap();
|
||||
|
||||
// Different workspace
|
||||
let req3: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"name": "Conv C",
|
||||
"extra": { "workspace": "/other/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
svc.create(USER_ID, req3).await.unwrap();
|
||||
|
||||
let associated = svc.list_associated(USER_ID, &conv1.id.to_string()).await.unwrap();
|
||||
assert_eq!(associated.len(), 1);
|
||||
assert_eq!(associated[0].id, conv2.id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_2_no_associated() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
|
||||
let req: CreateConversationRequest = serde_json::from_value(json!({
|
||||
"type": "acp",
|
||||
"extra": { "workspace": "/unique/path" }
|
||||
}))
|
||||
.unwrap();
|
||||
let conv = svc.create(USER_ID, req).await.unwrap();
|
||||
|
||||
let associated = svc.list_associated(USER_ID, &conv.id.to_string()).await.unwrap();
|
||||
assert!(associated.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn t10_3_associated_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let err = svc.list_associated(USER_ID, "nonexistent").await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
// ── T12: Boundary scenarios ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn t12_4_search_sql_injection() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "safe content", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let query = SearchMessagesQuery {
|
||||
keyword: "'; DROP TABLE messages; --".into(),
|
||||
page: None,
|
||||
page_size: None,
|
||||
};
|
||||
// Should return empty results, not crash
|
||||
let result = svc.search_messages(USER_ID, query).await.unwrap();
|
||||
assert!(result.items.is_empty());
|
||||
}
|
||||
|
||||
// ── Ownership cross-cutting ────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn messages_wrong_user_returns_not_found() {
|
||||
let (svc, repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
repo.insert_message(&make_message(conv.id, "hello", 0)).await.unwrap();
|
||||
|
||||
let err = svc
|
||||
.list_messages("other_user", &conv.id.to_string(), ListMessagesQuery::default())
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reset_wrong_user_returns_not_found() {
|
||||
let (svc, _repo, _b) = setup().await;
|
||||
let conv = svc.create(USER_ID, make_create_req()).await.unwrap();
|
||||
|
||||
let err = svc.reset("other_user", &conv.id.to_string()).await.unwrap_err();
|
||||
assert!(matches!(err, nomifun_common::AppError::NotFound(_)));
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
//! Black-box integration tests for the message middleware.
|
||||
//!
|
||||
//! Tests cover the test-plan.md section 6 (消息中间件):
|
||||
//! - Think tag cleaning (6.1)
|
||||
//! - Cron command detection (6.2)
|
||||
//! - MessageMiddleware pipeline end-to-end
|
||||
|
||||
use async_trait::async_trait;
|
||||
use nomifun_conversation::{
|
||||
CronCommand, CronCommandResult, CronCreateParams, CronUpdateParams, ICronService, MessageMiddleware,
|
||||
detect_cron_commands, has_cron_commands, strip_cron_commands, strip_think_tags,
|
||||
};
|
||||
|
||||
// ===========================================================================
|
||||
// 6.1 Think tag cleaning
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn think_tag_before_and_after_text() {
|
||||
let input = "前文<think>内部思考</think>后文";
|
||||
assert_eq!(strip_think_tags(input), "前文后文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thinking_tag_before_answer() {
|
||||
let input = "<thinking>深度思考</thinking>回答";
|
||||
assert_eq!(strip_think_tags(input), "回答");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_think_tags() {
|
||||
// Non-greedy: `<think>外<think>内</think>` matches first close,
|
||||
// then `外</think>后` remains. The second `</think>` is literal text.
|
||||
// Per API spec this is the expected behavior — nested tags are consumed.
|
||||
let input = "<think>外<think>内</think>外</think>后";
|
||||
let result = strip_think_tags(input);
|
||||
// First match: `<think>外<think>内</think>` → removed → "外</think>后"
|
||||
assert_eq!(result, "外</think>后");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_think_tags() {
|
||||
let input = "普通文本";
|
||||
assert_eq!(strip_think_tags(input), "普通文本");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_think_tag() {
|
||||
let input = "a<think></think>b";
|
||||
assert_eq!(strip_think_tags(input), "ab");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn think_tag_with_multiline_content() {
|
||||
let input = "Start\n<think>\nLine 1\nLine 2\nLine 3\n</think>\nEnd";
|
||||
let result = strip_think_tags(input);
|
||||
assert_eq!(result, "Start\n\nEnd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mixed_think_and_thinking_tags() {
|
||||
let input = "<think>a</think>middle<thinking>b</thinking>end";
|
||||
assert_eq!(strip_think_tags(input), "middleend");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unclosed_think_tag_preserved() {
|
||||
let input = "<think>no closing tag";
|
||||
assert_eq!(strip_think_tags(input), "<think>no closing tag");
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 6.2 Cron command detection
|
||||
// ===========================================================================
|
||||
|
||||
#[test]
|
||||
fn detect_cron_create_with_all_fields() {
|
||||
let input = "[CRON_CREATE]\nname: 每日代码审查\nschedule: 0 9 * * MON\nschedule_description: 每周一上午 9 点\nmessage: 请审查本周的代码变更\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Create(params) => {
|
||||
assert_eq!(params.name, "每日代码审查");
|
||||
assert_eq!(params.schedule, "0 9 * * MON");
|
||||
assert_eq!(params.schedule_description, "每周一上午 9 点");
|
||||
assert_eq!(params.message, "请审查本周的代码变更");
|
||||
}
|
||||
_ => panic!("Expected Create"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_list() {
|
||||
let input = "[CRON_LIST]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::List);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_update_with_all_fields() {
|
||||
let input = "[CRON_UPDATE: job-456]\nname: 更新后的任务\nschedule: 0 10 * * MON\nschedule_description: 每周一上午 10 点\nmessage: 请发送更新后的提醒\n[/CRON_UPDATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
match &commands[0] {
|
||||
CronCommand::Update(params) => {
|
||||
assert_eq!(params.job_id, "job-456");
|
||||
assert_eq!(params.name, "更新后的任务");
|
||||
assert_eq!(params.schedule, "0 10 * * MON");
|
||||
assert_eq!(params.schedule_description, "每周一上午 10 点");
|
||||
assert_eq!(params.message, "请发送更新后的提醒");
|
||||
}
|
||||
_ => panic!("Expected Update"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_cron_delete_with_id() {
|
||||
let input = "[CRON_DELETE: job-123]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::Delete("job-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_mixed_content_with_cron() {
|
||||
let input = "Here's what I did:\n\n[CRON_CREATE]\nname: cleanup\nschedule: 0 0 * * *\nschedule_description: daily midnight\nmessage: clean old files\n[/CRON_CREATE]\n\nThen updated one:\n[CRON_UPDATE: job-22]\nname: cleanup-v2\nschedule: 0 1 * * *\nschedule_description: daily 1am\nmessage: clean old files carefully\n[/CRON_UPDATE]\n\nAlso check: [CRON_LIST]\n\nAnd remove old one: [CRON_DELETE: old-123]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 4);
|
||||
assert!(matches!(&commands[0], CronCommand::Create(_)));
|
||||
assert!(matches!(&commands[1], CronCommand::Update(_)));
|
||||
assert_eq!(commands[2], CronCommand::List);
|
||||
assert_eq!(commands[3], CronCommand::Delete("old-123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detect_no_commands_in_normal_text() {
|
||||
let commands = detect_cron_commands("普通回复");
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn has_cron_detects_all_types() {
|
||||
assert!(has_cron_commands("[CRON_CREATE]\nschedule: *\n[/CRON_CREATE]"));
|
||||
assert!(has_cron_commands("[CRON_UPDATE: job-1]\nschedule: *\n[/CRON_UPDATE]"));
|
||||
assert!(has_cron_commands("[CRON_LIST]"));
|
||||
assert!(has_cron_commands("[CRON_DELETE: x]"));
|
||||
assert!(!has_cron_commands("nothing here"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_cron_removes_all_types_preserves_text() {
|
||||
let input = "Before\n[CRON_CREATE]\nname: t\nschedule: *\n[/CRON_CREATE]\nMiddle [CRON_LIST] After [CRON_DELETE: x] Between [CRON_UPDATE: id-7]\nname: t2\nschedule: 0 * * * *\n[/CRON_UPDATE] End";
|
||||
let stripped = strip_cron_commands(input);
|
||||
assert!(!stripped.contains("[CRON_"));
|
||||
assert!(stripped.contains("Before"));
|
||||
assert!(stripped.contains("Middle"));
|
||||
assert!(stripped.contains("After"));
|
||||
assert!(stripped.contains("End"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_create_missing_schedule_not_parsed() {
|
||||
let input = "[CRON_CREATE]\nname: broken\nmessage: no schedule\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert!(commands.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cron_delete_with_whitespace_in_id() {
|
||||
let input = "[CRON_DELETE: spaced-id ]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 1);
|
||||
assert_eq!(commands[0], CronCommand::Delete("spaced-id".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_cron_creates() {
|
||||
let input = "[CRON_CREATE]\nname: first\nschedule: 0 * * * *\n[/CRON_CREATE] text [CRON_CREATE]\nname: second\nschedule: 0 0 * * *\n[/CRON_CREATE]";
|
||||
let commands = detect_cron_commands(input);
|
||||
assert_eq!(commands.len(), 2);
|
||||
match (&commands[0], &commands[1]) {
|
||||
(CronCommand::Create(a), CronCommand::Create(b)) => {
|
||||
assert_eq!(a.name, "first");
|
||||
assert_eq!(b.name, "second");
|
||||
}
|
||||
_ => panic!("Expected two Create commands"),
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// MessageMiddleware end-to-end
|
||||
// ===========================================================================
|
||||
|
||||
/// Test cron service that tracks execution.
|
||||
struct TrackingCronService;
|
||||
|
||||
#[async_trait]
|
||||
impl ICronService for TrackingCronService {
|
||||
async fn create_job(&self, _user_id: &str, _conversation_id: &str, params: &CronCreateParams) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' created with schedule '{}'", params.name, params.schedule),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_job(&self, _user_id: &str, conversation_id: &str, params: &CronUpdateParams) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' updated in conversation '{}'", params.job_id, conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_jobs(&self, _user_id: &str, conversation_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Active jobs for '{}': daily-check (0 9 * * *)", conversation_id),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job(&self, _user_id: &str, job_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: true,
|
||||
message: format!("Job '{}' deleted", job_id),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Failing cron service for error path testing.
|
||||
struct FailingCronService;
|
||||
|
||||
#[async_trait]
|
||||
impl ICronService for FailingCronService {
|
||||
async fn create_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
_params: &CronCreateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Database connection lost".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_job(
|
||||
&self,
|
||||
_user_id: &str,
|
||||
_conversation_id: &str,
|
||||
_params: &CronUpdateParams,
|
||||
) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Update rejected".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_jobs(&self, _user_id: &str, _conversation_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Service unavailable".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_job(&self, _user_id: &str, _job_id: &str) -> CronCommandResult {
|
||||
CronCommandResult {
|
||||
success: false,
|
||||
message: "Permission denied".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_plain_text_passes_through() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let result = mw.process("Hello world!", "u1", "c1").await;
|
||||
assert_eq!(result.message, "Hello world!");
|
||||
assert!(result.display_message.is_none());
|
||||
assert!(result.system_responses.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_strips_think_and_thinking() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let input = "<think>reasoning about the problem</think>The answer is 42.<thinking>more thought</thinking>";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
assert_eq!(result.message, "The answer is 42.");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_create_successfully() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Done! I've set up the job.\n[CRON_CREATE]\nname: daily-review\nschedule: 0 9 * * *\nschedule_description: Daily at 9am\nmessage: Review PRs\n[/CRON_CREATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_CREATE]"));
|
||||
assert!(result.message.contains("Done!"));
|
||||
assert!(result.display_message.is_some());
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("daily-review"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_list() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Here are your jobs: [CRON_LIST]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_LIST]"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("Active jobs for 'c1'"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_update() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Updating it now. [CRON_UPDATE: job-42]\nname: renamed\nschedule: 0 8 * * *\nschedule_description: Daily at 8am\nmessage: New prompt\n[/CRON_UPDATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_UPDATE"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("job-42"));
|
||||
assert!(result.system_responses[0].contains("c1"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_executes_cron_delete() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "Removing it now. [CRON_DELETE: job-42]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert!(!result.message.contains("[CRON_DELETE"));
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("job-42"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_handles_cron_failure() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(FailingCronService)));
|
||||
let input = "[CRON_UPDATE: x]\nname: renamed\nschedule: 0 8 * * *\nschedule_description: Daily at 8am\nmessage: New prompt\n[/CRON_UPDATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("System Error"));
|
||||
assert!(result.system_responses[0].contains("Update rejected"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_no_cron_service_returns_unavailable() {
|
||||
let mw = MessageMiddleware::new(None);
|
||||
let input = "Listing jobs [CRON_LIST]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("not available"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_combined_think_tags_and_cron_commands() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "<thinking>Let me think about this...</thinking>Sure, I'll set that up for you.\n[CRON_CREATE]\nname: weekly\nschedule: 0 0 * * SUN\nschedule_description: Every Sunday\nmessage: Weekly report\n[/CRON_CREATE]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
// Think tags stripped
|
||||
assert!(!result.message.contains("<thinking>"));
|
||||
// Cron commands stripped
|
||||
assert!(!result.message.contains("[CRON_CREATE]"));
|
||||
// Text preserved
|
||||
assert!(result.message.contains("Sure, I'll set that up for you."));
|
||||
// Cron executed
|
||||
assert_eq!(result.system_responses.len(), 1);
|
||||
assert!(result.system_responses[0].contains("weekly"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn middleware_multiple_cron_commands_all_executed() {
|
||||
let mw = MessageMiddleware::new(Some(Box::new(TrackingCronService)));
|
||||
let input = "[CRON_CREATE]\nname: job1\nschedule: 0 * * * *\n[/CRON_CREATE] and [CRON_UPDATE: old]\nname: job2\nschedule: 0 1 * * *\nschedule_description: daily 1am\nmessage: New prompt\n[/CRON_UPDATE] and [CRON_LIST] and [CRON_DELETE: old]";
|
||||
let result = mw.process(input, "u1", "c1").await;
|
||||
|
||||
assert_eq!(result.system_responses.len(), 4);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_ai_agent::{
|
||||
AgentStreamEvent,
|
||||
protocol::events::{FinishEventData, ToolCallEventData, ToolCallStatus},
|
||||
};
|
||||
use nomifun_common::now_ms;
|
||||
use nomifun_conversation::stream_relay::StreamRelay;
|
||||
use nomifun_db::{
|
||||
IConversationRepository, SortOrder, SqliteConversationRepository, init_database_memory, models::ConversationRow,
|
||||
};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use serde_json::json;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
async fn setup_repo() -> (Arc<SqliteConversationRepository>, nomifun_db::Database) {
|
||||
let db = init_database_memory().await.unwrap();
|
||||
let repo = Arc::new(SqliteConversationRepository::new(db.pool().clone()));
|
||||
let now = now_ms();
|
||||
repo.create(&ConversationRow {
|
||||
id: 1,
|
||||
user_id: "system_default_user".into(),
|
||||
name: "Tool call test".into(),
|
||||
r#type: "nomi".into(),
|
||||
extra: "{}".into(),
|
||||
model: None,
|
||||
status: Some("running".into()),
|
||||
source: Some("nomifun".into()),
|
||||
channel_chat_id: None,
|
||||
pinned: false,
|
||||
pinned_at: None,
|
||||
cron_job_id: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
(repo, db)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_tool_call_with_empty_call_id_is_not_persisted() {
|
||||
let (repo, _db) = setup_repo().await;
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let (tx, _) = broadcast::channel(64);
|
||||
|
||||
let relay = StreamRelay::new(
|
||||
"1".into(),
|
||||
"asst-1".into(),
|
||||
"system_default_user".into(),
|
||||
repo.clone(),
|
||||
bus,
|
||||
None,
|
||||
);
|
||||
|
||||
let rx = tx.subscribe();
|
||||
tx.send(AgentStreamEvent::ToolCall(ToolCallEventData {
|
||||
call_id: "".into(),
|
||||
name: "Glob".into(),
|
||||
args: json!({"pattern": "*.rs"}),
|
||||
status: ToolCallStatus::Running,
|
||||
input: Some(json!({"pattern": "*.rs"})),
|
||||
output: None,
|
||||
description: None,
|
||||
}))
|
||||
.unwrap();
|
||||
tx.send(AgentStreamEvent::Finish(FinishEventData::default())).unwrap();
|
||||
|
||||
relay.consume(rx).await;
|
||||
|
||||
let messages = repo.get_messages(1, 1, 100, SortOrder::Asc).await.unwrap();
|
||||
|
||||
assert!(
|
||||
messages.items.iter().all(|row| row.r#type != "tool_call"),
|
||||
"empty call_id tool_call must not be persisted"
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user