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

887 lines
30 KiB
Rust

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:?}"
);
}