From d6f774179359386feb7a51fd1858f7aa1fbd2d53 Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 11:24:14 +0800 Subject: [PATCH] fix(agent): empty multimodal content becomes None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Multimodal arm of `impl From for AIMessage` built content by starting from the text value and only appending attached-image markers when images were present, then always wrapped the result in `Some(content)`. A message with empty text and no images therefore produced `Some("")` — a bare empty string sent straight to the provider, where a strict API rejects it (400) and a permissive gateway silently bills an empty round. The Text and Mixed arms both guard empty content (Mixed returns None for empty text), so the Multimodal arm was the only one without defence. The `user_multimodal` constructor returns `Self` and cannot reject, and serde deserialization rebuilds the fields directly, so the conversion arm is the correct choke point to defend. After building content, return None when it trims to empty. Non-empty multimodal content (text, images, or both) is unaffected, and an image-only message keeps its image markers. The constructor is left unchanged. Test: added 4 behavioral cases (empty text+images to None, non-empty text preserved, image-only kept, constructor-fed conversion) in message.rs; agentic::core::message::tests pass. AI: generated with review; verified with `cargo test -p bitfun-core --features agent-runtime --jobs 4` (message.rs tests green; one unrelated pre-existing coordinator test still fails on the clean baseline). --- .../assembly/core/src/agentic/core/message.rs | 59 ++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) 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() {