Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 58 additions & 1 deletion src/crates/assembly/core/src/agentic/core/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,9 +316,19 @@ impl From<Message> 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,
Expand Down Expand Up @@ -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() {
Expand Down