Skip to content

Commit def0223

Browse files
committed
fix: skip the respond stage on an empty message chain
1 parent 19c029e commit def0223

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

astrbot/core/pipeline/respond/stage.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,12 @@ async def process(
179179
if result.result_content_type == ResultContentType.STREAMING_FINISH:
180180
event.set_extra("_streaming_finished", True)
181181
return
182+
if (
183+
not result.chain
184+
and result.result_content_type != ResultContentType.STREAMING_RESULT
185+
):
186+
# 空消息链没有任何可发内容,直接返回,不打日志、也不触发 after_message_sent
187+
return
182188
sent_plain_texts = event.get_extra(
183189
"_send_message_to_user_current_session_plain_texts",
184190
[],

tests/test_respond_stage.py

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
"""RespondStage 对空消息链的处理。
2+
3+
背景:stop_event() 在 _result 为 None 时会新建一个空结果,而 RespondStage 结尾刚好
4+
清过结果;再加上调度器会在生成器阶段耗尽后把后续阶段重走一遍,插件「yield 结果后再
5+
stop_event」就会多出一条内容为空的 Prepare to send 与一次多余的 after_message_sent。
6+
"""
7+
8+
from typing import Any
9+
10+
import pytest
11+
12+
import astrbot.core.message.components as Comp
13+
from astrbot.core.message.message_event_result import (
14+
MessageEventResult,
15+
ResultContentType,
16+
)
17+
from astrbot.core.pipeline.respond import stage as respond_stage
18+
19+
20+
class FakeEvent:
21+
"""够 RespondStage 走完前半段的最小事件。"""
22+
23+
def __init__(self, result: MessageEventResult | None) -> None:
24+
self._result = result
25+
self._extras: dict[str, Any] = {}
26+
self.sent: list[Any] = []
27+
self.streamed: list[Any] = []
28+
29+
def get_result(self) -> MessageEventResult | None:
30+
return self._result
31+
32+
def clear_result(self) -> None:
33+
self._result = None
34+
35+
def get_extra(self, key: str | None = None, default=None) -> Any:
36+
if key is None:
37+
return self._extras
38+
return self._extras.get(key, default)
39+
40+
def set_extra(self, key: str, value: Any) -> None:
41+
self._extras[key] = value
42+
43+
def get_sender_name(self) -> str:
44+
return "時"
45+
46+
def get_sender_id(self) -> str:
47+
return "U1"
48+
49+
def get_platform_id(self) -> str:
50+
return "line"
51+
52+
def _outline_chain(self, chain) -> str:
53+
return " ".join(getattr(comp, "text", "") for comp in chain or [])
54+
55+
async def send(self, chain) -> None:
56+
self.sent.append(chain)
57+
58+
async def send_streaming(self, stream, realtime_segmenting) -> None:
59+
self.streamed.append(stream)
60+
61+
62+
@pytest.fixture
63+
def captured_logs(monkeypatch):
64+
"""收集 respond stage 打出的 info 日志。"""
65+
logs: list[str] = []
66+
monkeypatch.setattr(
67+
respond_stage.logger,
68+
"info",
69+
lambda message, *args, **kwargs: logs.append(str(message)),
70+
)
71+
return logs
72+
73+
74+
@pytest.fixture
75+
def hook_calls(monkeypatch):
76+
"""替换 after_message_sent 钩子入口,记录调用次数。"""
77+
calls: list[Any] = []
78+
79+
async def fake_hook(event, hook_type):
80+
calls.append(hook_type)
81+
return False
82+
83+
monkeypatch.setattr(respond_stage, "call_event_hook", fake_hook)
84+
return calls
85+
86+
87+
@pytest.mark.asyncio
88+
async def test_empty_chain_result_is_skipped_silently(captured_logs, hook_calls):
89+
"""stop_event() 造出的空结果不该产生日志、发送或 after_message_sent。"""
90+
result = MessageEventResult().stop_event()
91+
assert result.chain == []
92+
93+
event = FakeEvent(result)
94+
await respond_stage.RespondStage().process(event)
95+
96+
assert captured_logs == []
97+
assert event.sent == []
98+
assert hook_calls == []
99+
# 提前返回不清结果:停止传播靠的是 _force_stopped,这里无需也不该改动它。
100+
assert event.get_result() is result
101+
102+
103+
@pytest.mark.asyncio
104+
async def test_streaming_result_with_empty_chain_still_delivered(captured_logs):
105+
"""流式结果的内容在 async_stream 上,chain 为空属正常,不得被空链短路吃掉。"""
106+
107+
async def stream():
108+
yield MessageChainStub()
109+
110+
class MessageChainStub:
111+
pass
112+
113+
generator = stream()
114+
result = MessageEventResult()
115+
result.result_content_type = ResultContentType.STREAMING_RESULT
116+
result.async_stream = generator
117+
118+
event = FakeEvent(result)
119+
stage = respond_stage.RespondStage()
120+
stage.config = {"provider_settings": {}}
121+
122+
await stage.process(event)
123+
124+
assert event.streamed == [generator]
125+
assert any("Prepare to send" in line for line in captured_logs)
126+
127+
128+
@pytest.mark.asyncio
129+
async def test_non_empty_chain_is_not_skipped(captured_logs, hook_calls):
130+
"""有内容的结果照常走完发送与 after_message_sent。"""
131+
event = FakeEvent(MessageEventResult(chain=[Comp.Plain("hi")]))
132+
133+
stage = respond_stage.RespondStage()
134+
stage.platform_settings = {}
135+
stage.is_seg_reply_required = lambda event: False
136+
137+
async def not_empty(chain):
138+
return False
139+
140+
stage._is_empty_message_chain = not_empty
141+
142+
await stage.process(event)
143+
144+
assert len(event.sent) == 1
145+
assert len(hook_calls) == 1
146+
assert any("Prepare to send" in line for line in captured_logs)
147+
assert event.get_result() is None

0 commit comments

Comments
 (0)