diff --git a/src/ai/llamacpp.rs b/src/ai/llamacpp.rs index 2a03aed..77cd05f 100644 --- a/src/ai/llamacpp.rs +++ b/src/ai/llamacpp.rs @@ -985,4 +985,91 @@ mod tests { assert!(vision.has_vision); assert!(other.has_vision); } + + #[test] + fn vision_model_defaults_to_primary() { + let c = LlamaCppClient::new(None, Some("qwen3:32b".into())); + assert_eq!(c.primary_model, "qwen3:32b"); + assert_eq!(c.vision_model, "qwen3:32b"); + } + + #[test] + fn vision_model_explicit_override_diverges_from_primary() { + let mut c = LlamaCppClient::new(None, Some("qwen3:32b".into())); + c.set_vision_model("minicpm-v".into()); + assert_eq!(c.primary_model, "qwen3:32b"); + assert_eq!(c.vision_model, "minicpm-v"); + } + + #[test] + fn cloned_local_with_model_override_keeps_all_slots_consistent() { + // Simulates what resolve_backend does for the `local` client: + // clone the configured client, then override primary + vision + // to match the user-selected chat model. This prevents mid-turn + // model swaps in llama-swap exclusive mode. + let mut base = LlamaCppClient::new(None, Some("chat".into())); + base.set_vision_model("vision".into()); + base.set_embedding_model("embed".into()); + + let mut local = base.clone(); + let user_selected = "qwen3:32b"; + local.primary_model = user_selected.to_string(); + local.set_vision_model(user_selected.to_string()); + + // Chat generation (rerank) routes through primary_model. + assert_eq!(local.primary_model, user_selected); + // describe_image routes through vision_model. + assert_eq!(local.vision_model, user_selected); + // Embeddings stay on the dedicated slot — separate endpoint, + // no model swap conflict. + assert_eq!(local.embedding_model, "embed"); + } + + #[test] + fn assistant_tool_calls_emit_null_content() { + let msg = ChatMessage { + role: "assistant".into(), + content: String::new(), + tool_calls: Some(vec![ToolCall { + id: Some("call_1".into()), + function: ToolCallFunction { + name: "search".into(), + arguments: json!({}), + }, + }]), + images: None, + }; + let wire = LlamaCppClient::messages_to_openai(&[msg]); + assert!(wire[0]["content"].is_null(), "empty content + tool_calls should emit null"); + } + + #[test] + fn assistant_with_content_and_tool_calls_preserves_content() { + let msg = ChatMessage { + role: "assistant".into(), + content: "Let me search for that.".into(), + tool_calls: Some(vec![ToolCall { + id: Some("call_1".into()), + function: ToolCallFunction { + name: "search".into(), + arguments: json!({}), + }, + }]), + images: None, + }; + let wire = LlamaCppClient::messages_to_openai(&[msg]); + assert_eq!(wire[0]["content"], "Let me search for that."); + } + + #[test] + fn assistant_without_tool_calls_keeps_empty_string_content() { + let msg = ChatMessage { + role: "assistant".into(), + content: String::new(), + tool_calls: None, + images: None, + }; + let wire = LlamaCppClient::messages_to_openai(&[msg]); + assert_eq!(wire[0]["content"], ""); + } }