From def022366621f9ea4f7ad18c326f4eedc53731f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=A3=B9?= Date: Thu, 13 Aug 2026 11:46:59 +0900 Subject: [PATCH 1/2] fix: skip the respond stage on an empty message chain --- astrbot/core/pipeline/respond/stage.py | 6 + tests/test_respond_stage.py | 147 +++++++++++++++++++++++++ 2 files changed, 153 insertions(+) create mode 100644 tests/test_respond_stage.py diff --git a/astrbot/core/pipeline/respond/stage.py b/astrbot/core/pipeline/respond/stage.py index 433acb4de5..66c6ba5419 100644 --- a/astrbot/core/pipeline/respond/stage.py +++ b/astrbot/core/pipeline/respond/stage.py @@ -179,6 +179,12 @@ async def process( if result.result_content_type == ResultContentType.STREAMING_FINISH: event.set_extra("_streaming_finished", True) return + if ( + not result.chain + and result.result_content_type != ResultContentType.STREAMING_RESULT + ): + # 空消息链没有任何可发内容,直接返回,不打日志、也不触发 after_message_sent + return sent_plain_texts = event.get_extra( "_send_message_to_user_current_session_plain_texts", [], diff --git a/tests/test_respond_stage.py b/tests/test_respond_stage.py new file mode 100644 index 0000000000..954392bdff --- /dev/null +++ b/tests/test_respond_stage.py @@ -0,0 +1,147 @@ +"""RespondStage 对空消息链的处理。 + +背景:stop_event() 在 _result 为 None 时会新建一个空结果,而 RespondStage 结尾刚好 +清过结果;再加上调度器会在生成器阶段耗尽后把后续阶段重走一遍,插件「yield 结果后再 +stop_event」就会多出一条内容为空的 Prepare to send 与一次多余的 after_message_sent。 +""" + +from typing import Any + +import pytest + +import astrbot.core.message.components as Comp +from astrbot.core.message.message_event_result import ( + MessageEventResult, + ResultContentType, +) +from astrbot.core.pipeline.respond import stage as respond_stage + + +class FakeEvent: + """够 RespondStage 走完前半段的最小事件。""" + + def __init__(self, result: MessageEventResult | None) -> None: + self._result = result + self._extras: dict[str, Any] = {} + self.sent: list[Any] = [] + self.streamed: list[Any] = [] + + def get_result(self) -> MessageEventResult | None: + return self._result + + def clear_result(self) -> None: + self._result = None + + def get_extra(self, key: str | None = None, default=None) -> Any: + if key is None: + return self._extras + return self._extras.get(key, default) + + def set_extra(self, key: str, value: Any) -> None: + self._extras[key] = value + + def get_sender_name(self) -> str: + return "時" + + def get_sender_id(self) -> str: + return "U1" + + def get_platform_id(self) -> str: + return "line" + + def _outline_chain(self, chain) -> str: + return " ".join(getattr(comp, "text", "") for comp in chain or []) + + async def send(self, chain) -> None: + self.sent.append(chain) + + async def send_streaming(self, stream, realtime_segmenting) -> None: + self.streamed.append(stream) + + +@pytest.fixture +def captured_logs(monkeypatch): + """收集 respond stage 打出的 info 日志。""" + logs: list[str] = [] + monkeypatch.setattr( + respond_stage.logger, + "info", + lambda message, *args, **kwargs: logs.append(str(message)), + ) + return logs + + +@pytest.fixture +def hook_calls(monkeypatch): + """替换 after_message_sent 钩子入口,记录调用次数。""" + calls: list[Any] = [] + + async def fake_hook(event, hook_type): + calls.append(hook_type) + return False + + monkeypatch.setattr(respond_stage, "call_event_hook", fake_hook) + return calls + + +@pytest.mark.asyncio +async def test_empty_chain_result_is_skipped_silently(captured_logs, hook_calls): + """stop_event() 造出的空结果不该产生日志、发送或 after_message_sent。""" + result = MessageEventResult().stop_event() + assert result.chain == [] + + event = FakeEvent(result) + await respond_stage.RespondStage().process(event) + + assert captured_logs == [] + assert event.sent == [] + assert hook_calls == [] + # 提前返回不清结果:停止传播靠的是 _force_stopped,这里无需也不该改动它。 + assert event.get_result() is result + + +@pytest.mark.asyncio +async def test_streaming_result_with_empty_chain_still_delivered(captured_logs): + """流式结果的内容在 async_stream 上,chain 为空属正常,不得被空链短路吃掉。""" + + async def stream(): + yield MessageChainStub() + + class MessageChainStub: + pass + + generator = stream() + result = MessageEventResult() + result.result_content_type = ResultContentType.STREAMING_RESULT + result.async_stream = generator + + event = FakeEvent(result) + stage = respond_stage.RespondStage() + stage.config = {"provider_settings": {}} + + await stage.process(event) + + assert event.streamed == [generator] + assert any("Prepare to send" in line for line in captured_logs) + + +@pytest.mark.asyncio +async def test_non_empty_chain_is_not_skipped(captured_logs, hook_calls): + """有内容的结果照常走完发送与 after_message_sent。""" + event = FakeEvent(MessageEventResult(chain=[Comp.Plain("hi")])) + + stage = respond_stage.RespondStage() + stage.platform_settings = {} + stage.is_seg_reply_required = lambda event: False + + async def not_empty(chain): + return False + + stage._is_empty_message_chain = not_empty + + await stage.process(event) + + assert len(event.sent) == 1 + assert len(hook_calls) == 1 + assert any("Prepare to send" in line for line in captured_logs) + assert event.get_result() is None From c6ab21e510f46bd168907f9576d537b78492f0a2 Mon Sep 17 00:00:00 2001 From: Soulter <37870767+Soulter@users.noreply.github.com> Date: Tue, 1 Sep 2026 11:22:26 +0800 Subject: [PATCH 2/2] Delete tests/test_respond_stage.py --- tests/test_respond_stage.py | 147 ------------------------------------ 1 file changed, 147 deletions(-) delete mode 100644 tests/test_respond_stage.py diff --git a/tests/test_respond_stage.py b/tests/test_respond_stage.py deleted file mode 100644 index 954392bdff..0000000000 --- a/tests/test_respond_stage.py +++ /dev/null @@ -1,147 +0,0 @@ -"""RespondStage 对空消息链的处理。 - -背景:stop_event() 在 _result 为 None 时会新建一个空结果,而 RespondStage 结尾刚好 -清过结果;再加上调度器会在生成器阶段耗尽后把后续阶段重走一遍,插件「yield 结果后再 -stop_event」就会多出一条内容为空的 Prepare to send 与一次多余的 after_message_sent。 -""" - -from typing import Any - -import pytest - -import astrbot.core.message.components as Comp -from astrbot.core.message.message_event_result import ( - MessageEventResult, - ResultContentType, -) -from astrbot.core.pipeline.respond import stage as respond_stage - - -class FakeEvent: - """够 RespondStage 走完前半段的最小事件。""" - - def __init__(self, result: MessageEventResult | None) -> None: - self._result = result - self._extras: dict[str, Any] = {} - self.sent: list[Any] = [] - self.streamed: list[Any] = [] - - def get_result(self) -> MessageEventResult | None: - return self._result - - def clear_result(self) -> None: - self._result = None - - def get_extra(self, key: str | None = None, default=None) -> Any: - if key is None: - return self._extras - return self._extras.get(key, default) - - def set_extra(self, key: str, value: Any) -> None: - self._extras[key] = value - - def get_sender_name(self) -> str: - return "時" - - def get_sender_id(self) -> str: - return "U1" - - def get_platform_id(self) -> str: - return "line" - - def _outline_chain(self, chain) -> str: - return " ".join(getattr(comp, "text", "") for comp in chain or []) - - async def send(self, chain) -> None: - self.sent.append(chain) - - async def send_streaming(self, stream, realtime_segmenting) -> None: - self.streamed.append(stream) - - -@pytest.fixture -def captured_logs(monkeypatch): - """收集 respond stage 打出的 info 日志。""" - logs: list[str] = [] - monkeypatch.setattr( - respond_stage.logger, - "info", - lambda message, *args, **kwargs: logs.append(str(message)), - ) - return logs - - -@pytest.fixture -def hook_calls(monkeypatch): - """替换 after_message_sent 钩子入口,记录调用次数。""" - calls: list[Any] = [] - - async def fake_hook(event, hook_type): - calls.append(hook_type) - return False - - monkeypatch.setattr(respond_stage, "call_event_hook", fake_hook) - return calls - - -@pytest.mark.asyncio -async def test_empty_chain_result_is_skipped_silently(captured_logs, hook_calls): - """stop_event() 造出的空结果不该产生日志、发送或 after_message_sent。""" - result = MessageEventResult().stop_event() - assert result.chain == [] - - event = FakeEvent(result) - await respond_stage.RespondStage().process(event) - - assert captured_logs == [] - assert event.sent == [] - assert hook_calls == [] - # 提前返回不清结果:停止传播靠的是 _force_stopped,这里无需也不该改动它。 - assert event.get_result() is result - - -@pytest.mark.asyncio -async def test_streaming_result_with_empty_chain_still_delivered(captured_logs): - """流式结果的内容在 async_stream 上,chain 为空属正常,不得被空链短路吃掉。""" - - async def stream(): - yield MessageChainStub() - - class MessageChainStub: - pass - - generator = stream() - result = MessageEventResult() - result.result_content_type = ResultContentType.STREAMING_RESULT - result.async_stream = generator - - event = FakeEvent(result) - stage = respond_stage.RespondStage() - stage.config = {"provider_settings": {}} - - await stage.process(event) - - assert event.streamed == [generator] - assert any("Prepare to send" in line for line in captured_logs) - - -@pytest.mark.asyncio -async def test_non_empty_chain_is_not_skipped(captured_logs, hook_calls): - """有内容的结果照常走完发送与 after_message_sent。""" - event = FakeEvent(MessageEventResult(chain=[Comp.Plain("hi")])) - - stage = respond_stage.RespondStage() - stage.platform_settings = {} - stage.is_seg_reply_required = lambda event: False - - async def not_empty(chain): - return False - - stage._is_empty_message_chain = not_empty - - await stage.process(event) - - assert len(event.sent) == 1 - assert len(hook_calls) == 1 - assert any("Prepare to send" in line for line in captured_logs) - assert event.get_result() is None