diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 4c41c823..695f4ac9 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -706,14 +706,21 @@ def convert_messages_to_anthropic( result.append({"role": "user", "content": [tool_result]}) continue - # Regular user message + # Regular user message — validate non-empty content (Anthropic rejects empty) if isinstance(content, list): converted_blocks = _convert_content_to_anthropic(content) - result.append({ - "role": "user", - "content": converted_blocks or [{"type": "text", "text": ""}], - }) + # Check if all text blocks are empty + if not converted_blocks or all( + b.get("text", "").strip() == "" + for b in converted_blocks + if isinstance(b, dict) and b.get("type") == "text" + ): + converted_blocks = [{"type": "text", "text": "(empty message)"}] + result.append({"role": "user", "content": converted_blocks}) else: + # Validate string content is non-empty + if not content or (isinstance(content, str) and not content.strip()): + content = "(empty message)" result.append({"role": "user", "content": content}) # Strip orphaned tool_use blocks (no matching tool_result follows) diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index cb5bab1f..7ee1d3d7 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -2096,6 +2096,11 @@ class DiscordAdapter(BasePlatformAdapter): if pending_text_injection: event_text = f"{pending_text_injection}\n\n{event_text}" if event_text else pending_text_injection + # Defense-in-depth: prevent empty user messages from entering session + # (can happen when user sends @mention-only with no other text) + if not event_text or not event_text.strip(): + event_text = "(The user sent a message with no text content)" + event = MessageEvent( text=event_text, message_type=msg_type, diff --git a/tests/test_anthropic_adapter.py b/tests/test_anthropic_adapter.py index 71638f0d..00f78098 100644 --- a/tests/test_anthropic_adapter.py +++ b/tests/test_anthropic_adapter.py @@ -801,6 +801,48 @@ class TestConvertMessages: assert all(not (b.get("type") == "text" and b.get("text") == "") for b in assistant_blocks) assert any(b.get("type") == "tool_use" for b in assistant_blocks) + def test_empty_user_message_string_gets_placeholder(self): + """Empty user message strings should get '(empty message)' placeholder. + + Anthropic rejects requests with empty user message content. + Regression test for #3143 — Discord @mention-only messages. + """ + messages = [ + {"role": "user", "content": ""}, + ] + _, result = convert_messages_to_anthropic(messages) + assert result[0]["role"] == "user" + assert result[0]["content"] == "(empty message)" + + def test_whitespace_only_user_message_gets_placeholder(self): + """Whitespace-only user messages should also get placeholder.""" + messages = [ + {"role": "user", "content": " \n\t "}, + ] + _, result = convert_messages_to_anthropic(messages) + assert result[0]["content"] == "(empty message)" + + def test_empty_user_message_list_gets_placeholder(self): + """Empty content list for user messages should get placeholder block.""" + messages = [ + {"role": "user", "content": []}, + ] + _, result = convert_messages_to_anthropic(messages) + assert result[0]["role"] == "user" + assert isinstance(result[0]["content"], list) + assert len(result[0]["content"]) == 1 + assert result[0]["content"][0] == {"type": "text", "text": "(empty message)"} + + def test_user_message_with_empty_text_blocks_gets_placeholder(self): + """User message with only empty text blocks should get placeholder.""" + messages = [ + {"role": "user", "content": [{"type": "text", "text": ""}, {"type": "text", "text": " "}]}, + ] + _, result = convert_messages_to_anthropic(messages) + assert result[0]["role"] == "user" + assert isinstance(result[0]["content"], list) + assert result[0]["content"] == [{"type": "text", "text": "(empty message)"}] + # --------------------------------------------------------------------------- # Build kwargs