From cc7e90846098bbda2499b34c1348c25b6174488c Mon Sep 17 00:00:00 2001 From: user Date: Sun, 30 Aug 2026 11:21:47 +0800 Subject: [PATCH] fix(agent): stop fabricating empty message content The Text arm of `impl From for AIMessage` injected a synthetic placeholder for empty text to avoid sending empty content to the provider: empty user text became "(empty message)", empty system became "You are a helpful assistant.", and any other empty text became a single space. The model then treated these fabricated strings as a real user utterance or a system directive. The fabricated value also defeats the downstream converters' existing empty-content handling (openai/gemini filter out empty content, anthropic skips an empty system message), because a non-empty fabricated string bypasses the filter. The Mixed arm already produces None for empty text and is the correct in-file precedent to reuse. Return None for empty text in the Text arm instead, keeping the existing warn! diagnostic. Nothing synthetic reaches the provider, the empty-content semantics of the converters apply, and the normal non-empty path is unchanged. Test: added 4 behavioral cases (empty user/system/assistant to None, and non-empty text preserved) 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 | 46 +++++++++++++++---- 1 file changed, 38 insertions(+), 8 deletions(-) diff --git a/src/crates/assembly/core/src/agentic/core/message.rs b/src/crates/assembly/core/src/agentic/core/message.rs index bbdff6cc5e..f4abdc2622 100644 --- a/src/crates/assembly/core/src/agentic/core/message.rs +++ b/src/crates/assembly/core/src/agentic/core/message.rs @@ -268,15 +268,12 @@ impl From for AIMessage { MessageContent::Text(text) => { // Check if text is empty to avoid sending empty content to API let content = if text.trim().is_empty() { - // Should not have empty text messages, but provide default value for defensive programming + // Empty text is a degenerate input; do not fabricate a placeholder. + // Return None so downstream converters apply their existing + // empty-content semantics (skip/drop) instead of treating a fake + // token as a real user/assistant utterance or a system directive. warn!("Empty text message detected: role={}", role); - if role == "user" { - Some("(empty message)".to_string()) - } else if role == "system" { - Some("You are a helpful assistant.".to_string()) - } else { - Some(" ".to_string()) // Minimum valid value - } + None } else { Some(text) }; @@ -788,6 +785,39 @@ mod tests { assert_eq!(ai_msg.thinking_signature.as_deref(), Some("sig_1")); } + #[test] + fn empty_text_user_becomes_none_content() { + let ai_msg = AIMessage::from(Message::user(String::new())); + + // Empty user text must not be turned into a fabricated "(empty message)" + // token that the model would treat as a real user utterance. + assert_eq!(ai_msg.content.as_deref(), None); + } + + #[test] + fn empty_text_system_becomes_none_content() { + let ai_msg = AIMessage::from(Message::system(String::new())); + + // Empty system text must not be turned into a fabricated system directive. + assert_eq!(ai_msg.content.as_deref(), None); + } + + #[test] + fn empty_text_assistant_becomes_none_content() { + let ai_msg = AIMessage::from(Message::assistant(String::new())); + + // Empty assistant text must not be replaced with a whitespace placeholder. + assert_eq!(ai_msg.content.as_deref(), None); + } + + #[test] + fn non_empty_text_preserves_content() { + let ai_msg = AIMessage::from(Message::user("hi".to_string())); + + // Normal non-empty text is unaffected by the empty-text handling. + assert_eq!(ai_msg.content.as_deref(), Some("hi")); + } + #[test] fn persists_and_restores_model_response_replay() { let message = Message::assistant("done".to_string()).with_model_response_replay(Some(