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>>, } impl TestBroadcaster { fn new() -> Self { Self { events: Mutex::new(vec![]), } } fn take_events(&self) -> Vec> { std::mem::take(&mut self.events.lock().unwrap()) } } impl EventBroadcaster for TestBroadcaster { fn broadcast(&self, event: WebSocketMessage) { self.events.lock().unwrap().push(event); } } struct NoopTaskManager; #[async_trait::async_trait] impl IWorkerTaskManager for NoopTaskManager { fn get_task(&self, _: &str) -> Option { None } async fn get_or_build_task( &self, _: &str, _: nomifun_ai_agent::types::BuildTaskOptions, ) -> Result { Err(AppError::Internal("noop".into())) } fn kill(&self, _: &str, _: Option) -> Result<(), AppError> { Ok(()) } fn kill_and_wait( &self, _: &str, _: Option, ) -> std::pin::Pin + Send>> { Box::pin(std::future::ready(())) } fn clear(&self) {} fn active_count(&self) -> usize { 0 } fn collect_idle(&self, _: TimestampMs) -> Vec { vec![] } } struct EmptySkillResolver; #[async_trait::async_trait] impl SkillResolver for EmptySkillResolver { async fn auto_inject_names(&self) -> Vec { Vec::new() } async fn resolve_skills(&self, _names: &[String]) -> Vec { 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, Arc) { 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 = Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone())); let acp_session_repo: Arc = Arc::new(nomifun_db::SqliteAcpSessionRepository::new(db.pool().clone())); let task_mgr: Arc = 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 = 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::>() }); 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 = Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone())); let acp_session_repo: Arc = Arc::new(SqliteAcpSessionRepository::new(db.pool().clone())); let task_mgr: Arc = 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 = Arc::new(nomifun_db::SqliteAgentMetadataRepository::new(db.pool().clone())); let acp_session_repo: Arc = Arc::new(SqliteAcpSessionRepository::new(db.pool().clone())); let task_mgr: Arc = 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:?}" ); }