Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -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(_)));
}
@@ -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"
);
}