diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index bbdff6cc5e..be6dfc5bd3 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -316,9 +316,19 @@ impl From for AIMessage { content.push(']'); } + let content = if content.trim().is_empty() { + // Empty multimodal content (no text and no images) must not reach + // the provider as a bare empty string. Return None so downstream + // converters apply their empty-content handling instead of billing + // an empty request round. + None + } else { + Some(content) + }; + Self { role: "user".to_string(), - content: Some(content), + content, reasoning_content: None, thinking_signature: None, tool_calls: None, @@ -776,6 +786,53 @@ mod tests { use bitfun_agent_stream::ToolArgumentRepairKind; use bitfun_core_types::{ModelResponseReplay, ModelResponseReplayItem}; use serde_json::json; + use crate::agentic::image_analysis::ImageContextData; + + #[test] + fn empty_text_empty_images_multimodal_becomes_none() { + let ai_msg = AIMessage::from(Message::user_multimodal(String::new(), vec![])); + + // An empty multimodal message (no text and no images) must not be sent to + // the provider as a bare empty string; it should become None so downstream + // converters apply their empty-content handling. + assert_eq!(ai_msg.content.as_deref(), None); + } + + #[test] + fn non_empty_text_multimodal_preserves_content() { + let ai_msg = AIMessage::from(Message::user_multimodal("hi".to_string(), vec![])); + + assert_eq!(ai_msg.content.as_deref(), Some("hi")); + } + + #[test] + fn multimodal_with_images_kept_when_text_empty() { + let image = ImageContextData { + id: "img_1".to_string(), + image_path: Some("/tmp/photo.png".to_string()), + data_url: None, + mime_type: "image/png".to_string(), + metadata: Some(json!({ "name": "photo" })), + }; + + let ai_msg = AIMessage::from(Message::user_multimodal(String::new(), vec![image])); + + // Image-only multimodal content (no text) is valid and must be preserved, + // not treated as an empty content value. + let content = ai_msg.content.expect("image-attached content should be present"); + assert!(content.contains("[Attached image(s):")); + assert!(content.contains("photo")); + } + + #[test] + fn user_multimodal_constructor_feeds_conversion() { + let ai_msg = AIMessage::from(Message::user_multimodal(String::new(), vec![])); + + // Empty multimodal content built through the constructor still flows + // through the conversion arm and becomes None for the provider. + assert_eq!(ai_msg.role, "user"); + assert_eq!(ai_msg.content.as_deref(), None); + } #[test] fn preserves_empty_reasoning_content_for_provider_replay() {