diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index 9312b8c3af..bc757713d1 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -1442,8 +1442,19 @@ async def build_main_agent( for comp in reply_comps: has_embedded_image = False if comp.chain: - for reply_comp in comp.chain: + for chain_index, reply_comp in enumerate(comp.chain): if isinstance(reply_comp, Image): + if not ( + (reply_comp.url or "").strip() + or (reply_comp.file or "").strip() + ): + logger.warning( + "Skip quoted image without file or URL for umo=%s, reply_id=%s, chain_index=%s", + event.unified_msg_origin, + getattr(comp, "id", None), + chain_index, + ) + continue has_embedded_image = True path = await reply_comp.convert_to_file_path() image_path = await _compress_image_for_provider( diff --git a/tests/unit/test_astr_main_agent.py b/tests/unit/test_astr_main_agent.py index 254d1ea7ef..b09fbff1f7 100644 --- a/tests/unit/test_astr_main_agent.py +++ b/tests/unit/test_astr_main_agent.py @@ -801,9 +801,7 @@ async def test_inline_genui_prompt_is_added_with_custom_persona( mock_context.persona_manager.resolve_selected_persona = AsyncMock( return_value=("conv-persona", persona, None, False) ) - mock_event.get_extra.side_effect = ( - lambda key: key == "enable_inline_genui" - ) + mock_event.get_extra.side_effect = lambda key: key == "enable_inline_genui" req = ProviderRequest() req.conversation = MagicMock(persona_id="conv-persona") @@ -818,9 +816,7 @@ async def test_inline_genui_prompt_does_not_require_conversation( ): """Test inline GenUI instructions are added before conversation setup.""" module = ama - mock_event.get_extra.side_effect = ( - lambda key: key == "enable_inline_genui" - ) + mock_event.get_extra.side_effect = lambda key: key == "enable_inline_genui" req = ProviderRequest() await module._ensure_persona_and_skills(req, {}, mock_context, mock_event) @@ -1591,6 +1587,60 @@ async def test_build_main_agent_skips_caption_when_main_provider_supports_images ) mock_provider.text_chat.assert_not_called() + @pytest.mark.asyncio + @pytest.mark.parametrize( + "image_kwargs", + [ + {"file": ""}, + {"file": None, "url": ""}, + {"file": "", "url": ""}, + {"file": " ", "url": None}, + {"file": None, "url": " "}, + ], + ) + async def test_build_main_agent_skips_empty_quoted_image_and_uses_fallback( + self, mock_event, mock_context, mock_provider, image_kwargs + ): + """Quoted image placeholders without a file or URL should not abort the request.""" + module = ama + mock_reply = Reply( + id="reply-1", + chain=[Plain(text="quoted text"), Image(**image_kwargs)], + sender_nickname="", + message_str="quoted text", + ) + mock_event.message_obj.message = [Plain(text="Hello"), mock_reply] + + mock_context.get_provider_by_id.return_value = None + mock_context.get_using_provider.return_value = mock_provider + mock_context.get_config.return_value = {} + + conv_mgr = mock_context.conversation_manager + _setup_conversation_for_build(conv_mgr) + + with ( + patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls, + patch("astrbot.core.astr_main_agent.AstrAgentContext"), + patch( + "astrbot.core.astr_main_agent.extract_quoted_message_images", + AsyncMock(return_value=["fallback-quoted-image-ref"]), + ) as mock_extract_quoted_images, + ): + mock_runner = MagicMock() + mock_runner.reset = AsyncMock() + mock_runner_cls.return_value = mock_runner + + result = await module.build_main_agent( + event=mock_event, + plugin_context=mock_context, + config=module.MainAgentBuildConfig(tool_call_timeout=60), + provider=mock_provider, + ) + + assert result is not None + assert result.provider_request.image_urls == ["fallback-quoted-image-ref"] + mock_extract_quoted_images.assert_awaited_once() + @pytest.mark.asyncio async def test_build_main_agent_does_not_caption_quoted_image_twice( self, mock_event, mock_context