From 33f01d5df2ddc041b4977d0ef423b238d9e83b11 Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Sun, 14 Jun 2026 19:08:42 +0800 Subject: [PATCH 1/6] fix: deduplicate send_message_to_user calls to prevent duplicate messages Some LLMs (notably mimo) may return both completion_text and send_message_to_user tool calls in the same response, or call send_message_to_user multiple times across consecutive responses. This causes the user to receive duplicate messages. Two-layer fix: 1. Suppress completion_text/result_chain yield when send_message_to_user is in the tool calls list, preventing RespondStage from sending the same content that the tool will send directly. 2. Add fingerprint-based dedup in send_message_to_user tool itself, skipping identical sends within a 30-second window. Co-Authored-By: Claude --- .../agent/runners/tool_loop_agent_runner.py | 37 ++++++++++++++----- astrbot/core/tools/message_tools.py | 27 ++++++++++++++ 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index 3f74f0ec9b..fb16091d3a 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -792,6 +792,13 @@ async def step(self): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 + # 当 LLM 同时返回 completion_text 和 send_message_to_user 工具调用时, + # 抑制 completion_text 的 yield,避免 respond 阶段重复发送相同内容。 + # 这是 mimo 模型的已知问题:它会在同一响应中既输出文本又调用 send_message_to_user。 + _has_send_message_tool = ( + llm_resp.tools_call_name + and "send_message_to_user" in llm_resp.tools_call_name + ) if llm_resp.reasoning_content: yield AgentResponse( type="llm_result", @@ -802,17 +809,27 @@ async def step(self): ), ) if llm_resp.result_chain: - yield AgentResponse( - type="llm_result", - data=AgentResponseData(chain=llm_resp.result_chain), - ) + if _has_send_message_tool: + logger.info( + "检测到 send_message_to_user 工具调用,抑制 result_chain 以避免重复发送。" + ) + else: + yield AgentResponse( + type="llm_result", + data=AgentResponseData(chain=llm_resp.result_chain), + ) elif llm_resp.completion_text: - yield AgentResponse( - type="llm_result", - data=AgentResponseData( - chain=MessageChain().message(llm_resp.completion_text), - ), - ) + if _has_send_message_tool: + logger.info( + "检测到 send_message_to_user 工具调用,抑制 completion_text 以避免重复发送。" + ) + else: + yield AgentResponse( + type="llm_result", + data=AgentResponseData( + chain=MessageChain().message(llm_resp.completion_text), + ), + ) # 如果有工具调用,还需处理工具调用 if llm_resp.tools_call_name: diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 40516d5297..d220351fb4 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -1,6 +1,8 @@ +import hashlib import json import os import shlex +import time import uuid from pathlib import Path @@ -26,6 +28,13 @@ get_astrbot_temp_path, ) +# 消息发送去重:防止 LLM 在同一 agent run 中重复调用 send_message_to_user 发送相同内容。 +# 已知触发模型:mimo(会在同一响应中同时返回 completion_text 和 send_message_to_user 工具调用, +# 或在连续多次响应中重复调用同一工具)。 +# 指纹 = md5(session + 序列化后的 messages),在时间窗口内相同则跳过。 +_recent_sends: dict[str, float] = {} +_DEDUP_WINDOW_SECONDS = 30.0 + def _file_send_allowed_roots(umo: str | None) -> tuple[Path, ...]: roots = [] @@ -316,6 +325,24 @@ async def call( else: return f"error: invalid session: {session}" + # 去重:计算消息指纹,跳过短时间内重复发送的相同内容 + global _recent_sends + now = time.time() + _recent_sends = { + k: v for k, v in _recent_sends.items() + if now - v < _DEDUP_WINDOW_SECONDS + } + fingerprint = hashlib.md5( + (str(session) + json.dumps(messages, ensure_ascii=False, sort_keys=True)).encode() + ).hexdigest() + if fingerprint in _recent_sends: + logger.info( + f"[send_message_to_user] 检测到重复发送,已跳过。" + f" session={session}, fingerprint={fingerprint[:8]}" + ) + return f"Message skipped (duplicate), session={target_session}" + _recent_sends[fingerprint] = now + await context.context.context.send_message( target_session, MessageChain(chain=components), From a4b112db1efe1b173e2bb97d06c9afdb768b6bbe Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Sun, 14 Jun 2026 20:59:56 +0800 Subject: [PATCH 2/6] fix: address review feedback for dedup logic - Use asyncio.Lock for concurrency safety - Use target_session (canonical) instead of raw session in fingerprint - Add sender_id and platform_id to dedup key for finer granularity Co-Authored-By: Claude --- astrbot/core/tools/message_tools.py | 46 ++++++++++++++++++----------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index d220351fb4..a2edcead77 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -1,3 +1,4 @@ +import asyncio import hashlib import json import os @@ -31,8 +32,10 @@ # 消息发送去重:防止 LLM 在同一 agent run 中重复调用 send_message_to_user 发送相同内容。 # 已知触发模型:mimo(会在同一响应中同时返回 completion_text 和 send_message_to_user 工具调用, # 或在连续多次响应中重复调用同一工具)。 -# 指纹 = md5(session + 序列化后的 messages),在时间窗口内相同则跳过。 +# 指纹 = md5(target_session + sender_id + platform_id + 序列化后的 messages), +# 在时间窗口内相同则跳过。使用 asyncio.Lock 保证并发安全。 _recent_sends: dict[str, float] = {} +_recent_sends_lock = asyncio.Lock() _DEDUP_WINDOW_SECONDS = 30.0 @@ -325,23 +328,32 @@ async def call( else: return f"error: invalid session: {session}" - # 去重:计算消息指纹,跳过短时间内重复发送的相同内容 - global _recent_sends + # 去重:计算消息指纹,跳过短时间内重复发送的相同内容。 + # 使用 target_session(已解析的规范会话标识)而非原始 session 字符串, + # 并加入 sender_id 和 platform_id 以区分不同来源的相同消息内容。 + global _recent_sends, _recent_sends_lock now = time.time() - _recent_sends = { - k: v for k, v in _recent_sends.items() - if now - v < _DEDUP_WINDOW_SECONDS - } - fingerprint = hashlib.md5( - (str(session) + json.dumps(messages, ensure_ascii=False, sort_keys=True)).encode() - ).hexdigest() - if fingerprint in _recent_sends: - logger.info( - f"[send_message_to_user] 检测到重复发送,已跳过。" - f" session={session}, fingerprint={fingerprint[:8]}" - ) - return f"Message skipped (duplicate), session={target_session}" - _recent_sends[fingerprint] = now + event = context.context.event + dedup_key = "|".join([ + str(target_session), + str(getattr(event, "sender_id", "")), + str(getattr(event, "platform_id", "")), + json.dumps(messages, ensure_ascii=False, sort_keys=True), + ]) + fingerprint = hashlib.md5(dedup_key.encode()).hexdigest() + async with _recent_sends_lock: + _recent_sends = { + k: v for k, v in _recent_sends.items() + if now - v < _DEDUP_WINDOW_SECONDS + } + if fingerprint in _recent_sends: + logger.info( + f"[send_message_to_user] 检测到重复发送,已跳过。" + f" session={session}, target_session={target_session}," + f" fingerprint={fingerprint[:8]}" + ) + return f"Message skipped (duplicate), session={target_session}" + _recent_sends[fingerprint] = now await context.context.context.send_message( target_session, From 584eeaebbe9dbcfd62283cf564543f884d159a6f Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Mon, 15 Jun 2026 12:51:55 +0800 Subject: [PATCH 3/6] fix: narrow dedup suppression to proven duplicates Address review feedback: 1. Runner: Only suppress completion_text when its content matches the send_message_to_user payload exactly. Previously suppressed whenever the tool was present, which broke legitimate scenarios (e.g., admin sending notification to another session + explaining to current user). 2. Tool: Replace process-global 30s dedup dict with event-scoped fingerprint set. Each event tracks its own sent fingerprints via event.get_extra/set_extra, automatically cleaned up when the event is garbage collected. No false positives across different events. Co-Authored-By: Claude --- .../agent/runners/tool_loop_agent_runner.py | 60 ++++++++++++++----- astrbot/core/tools/message_tools.py | 53 ++++++---------- 2 files changed, 62 insertions(+), 51 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index fb16091d3a..becf82fe57 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -107,6 +107,33 @@ class _ToolExecutionInterrupted(Exception): ToolExecutorResultT = T.TypeVar("ToolExecutorResultT") +def _extract_send_message_text( + tools_call_name: list[str] | None, + tools_call_args: list[dict] | None, +) -> str | None: + """从 send_message_to_user 工具调用参数中提取纯文本内容。 + + 用于比对 completion_text 与工具 payload 是否一致,判断是否为重复发送。 + 仅提取 type="plain" 的文本部分。 + """ + if not tools_call_name or not tools_call_args: + return None + for name, args in zip(tools_call_name, tools_call_args): + if name == "send_message_to_user" and isinstance(args, dict): + messages = args.get("messages") + if not isinstance(messages, list): + continue + texts = [] + for msg in messages: + if isinstance(msg, dict) and msg.get("type") == "plain": + text = msg.get("text", "").strip() + if text: + texts.append(text) + if texts: + return " ".join(texts) + return None + + class ToolLoopAgentRunner(BaseAgentRunner[TContext]): TOOL_RESULT_MAX_ESTIMATED_TOKENS = 27_500 TOOL_RESULT_PREVIEW_MAX_ESTIMATED_TOKENS = 7000 @@ -792,13 +819,24 @@ async def step(self): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 - # 当 LLM 同时返回 completion_text 和 send_message_to_user 工具调用时, - # 抑制 completion_text 的 yield,避免 respond 阶段重复发送相同内容。 - # 这是 mimo 模型的已知问题:它会在同一响应中既输出文本又调用 send_message_to_user。 - _has_send_message_tool = ( + # 当 send_message_to_user 的 payload 与 completion_text 内容一致时, + # 抑制 completion_text 的 yield,避免 respond 阶段重复发送。 + # 仅在内容匹配时抑制,不影响发到其他会话或内容不同的场景。 + _should_suppress_text = False + if ( llm_resp.tools_call_name and "send_message_to_user" in llm_resp.tools_call_name - ) + ): + _tool_text = _extract_send_message_text( + llm_resp.tools_call_name, llm_resp.tools_call_args + ) + _completion = (llm_resp.completion_text or "").strip() + if _tool_text and _completion and _tool_text == _completion: + _should_suppress_text = True + logger.info( + "send_message_to_user payload 与 completion_text 一致," + "抑制以避免重复发送。" + ) if llm_resp.reasoning_content: yield AgentResponse( type="llm_result", @@ -809,21 +847,13 @@ async def step(self): ), ) if llm_resp.result_chain: - if _has_send_message_tool: - logger.info( - "检测到 send_message_to_user 工具调用,抑制 result_chain 以避免重复发送。" - ) - else: + if not _should_suppress_text: yield AgentResponse( type="llm_result", data=AgentResponseData(chain=llm_resp.result_chain), ) elif llm_resp.completion_text: - if _has_send_message_tool: - logger.info( - "检测到 send_message_to_user 工具调用,抑制 completion_text 以避免重复发送。" - ) - else: + if not _should_suppress_text: yield AgentResponse( type="llm_result", data=AgentResponseData( diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index a2edcead77..905f516f2a 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -1,9 +1,7 @@ -import asyncio import hashlib import json import os import shlex -import time import uuid from pathlib import Path @@ -29,14 +27,6 @@ get_astrbot_temp_path, ) -# 消息发送去重:防止 LLM 在同一 agent run 中重复调用 send_message_to_user 发送相同内容。 -# 已知触发模型:mimo(会在同一响应中同时返回 completion_text 和 send_message_to_user 工具调用, -# 或在连续多次响应中重复调用同一工具)。 -# 指纹 = md5(target_session + sender_id + platform_id + 序列化后的 messages), -# 在时间窗口内相同则跳过。使用 asyncio.Lock 保证并发安全。 -_recent_sends: dict[str, float] = {} -_recent_sends_lock = asyncio.Lock() -_DEDUP_WINDOW_SECONDS = 30.0 def _file_send_allowed_roots(umo: str | None) -> tuple[Path, ...]: @@ -328,32 +318,23 @@ async def call( else: return f"error: invalid session: {session}" - # 去重:计算消息指纹,跳过短时间内重复发送的相同内容。 - # 使用 target_session(已解析的规范会话标识)而非原始 session 字符串, - # 并加入 sender_id 和 platform_id 以区分不同来源的相同消息内容。 - global _recent_sends, _recent_sends_lock - now = time.time() - event = context.context.event - dedup_key = "|".join([ - str(target_session), - str(getattr(event, "sender_id", "")), - str(getattr(event, "platform_id", "")), - json.dumps(messages, ensure_ascii=False, sort_keys=True), - ]) - fingerprint = hashlib.md5(dedup_key.encode()).hexdigest() - async with _recent_sends_lock: - _recent_sends = { - k: v for k, v in _recent_sends.items() - if now - v < _DEDUP_WINDOW_SECONDS - } - if fingerprint in _recent_sends: - logger.info( - f"[send_message_to_user] 检测到重复发送,已跳过。" - f" session={session}, target_session={target_session}," - f" fingerprint={fingerprint[:8]}" - ) - return f"Message skipped (duplicate), session={target_session}" - _recent_sends[fingerprint] = now + # 去重:按事件作用域记录已发送的消息指纹,拦截同一 agent run 内的重复调用。 + # 作用域限定在当前事件,不影响其他事件的合法重复发送。 + fingerprint = hashlib.md5( + (str(target_session) + json.dumps(messages, ensure_ascii=False, sort_keys=True)).encode() + ).hexdigest() + sent_fingerprints = context.context.event.get_extra("_send_message_fingerprints") + if sent_fingerprints is None: + sent_fingerprints = set() + if fingerprint in sent_fingerprints: + logger.info( + f"[send_message_to_user] 当前事件内重复发送,已跳过。" + f" session={session}, target_session={target_session}," + f" fingerprint={fingerprint[:8]}" + ) + return f"Message skipped (duplicate), session={target_session}" + sent_fingerprints.add(fingerprint) + context.context.event.set_extra("_send_message_fingerprints", sent_fingerprints) await context.context.context.send_message( target_session, From ba6df8fd859acfea6e9ff3ffd96f4bcb81e632d0 Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Mon, 15 Jun 2026 13:11:00 +0800 Subject: [PATCH 4/6] fix: line length formatting for ruff Co-Authored-By: Claude --- .../core/agent/runners/tool_loop_agent_runner.py | 9 +++++---- astrbot/core/tools/message_tools.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index becf82fe57..f479893df9 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -113,8 +113,8 @@ def _extract_send_message_text( ) -> str | None: """从 send_message_to_user 工具调用参数中提取纯文本内容。 - 用于比对 completion_text 与工具 payload 是否一致,判断是否为重复发送。 - 仅提取 type="plain" 的文本部分。 + 用于比对 completion_text 与工具 payload 是否一致, + 判断是否为重复发送。仅提取 type="plain" 的文本部分。 """ if not tools_call_name or not tools_call_args: return None @@ -819,8 +819,9 @@ async def step(self): await self._complete_with_assistant_response(llm_resp) # 返回 LLM 结果 - # 当 send_message_to_user 的 payload 与 completion_text 内容一致时, - # 抑制 completion_text 的 yield,避免 respond 阶段重复发送。 + # 当 send_message_to_user 的 payload 与 completion_text + # 内容一致时,抑制 completion_text 的 yield, + # 避免 respond 阶段重复发送。 # 仅在内容匹配时抑制,不影响发到其他会话或内容不同的场景。 _should_suppress_text = False if ( diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 905f516f2a..871f950198 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -318,12 +318,16 @@ async def call( else: return f"error: invalid session: {session}" - # 去重:按事件作用域记录已发送的消息指纹,拦截同一 agent run 内的重复调用。 + # 去重:按事件作用域记录已发送的消息指纹, + # 拦截同一 agent run 内的重复调用。 # 作用域限定在当前事件,不影响其他事件的合法重复发送。 - fingerprint = hashlib.md5( - (str(target_session) + json.dumps(messages, ensure_ascii=False, sort_keys=True)).encode() - ).hexdigest() - sent_fingerprints = context.context.event.get_extra("_send_message_fingerprints") + dedup_key = str(target_session) + json.dumps( + messages, ensure_ascii=False, sort_keys=True + ) + fingerprint = hashlib.md5(dedup_key.encode()).hexdigest() + sent_fingerprints = context.context.event.get_extra( + "_send_message_fingerprints" + ) if sent_fingerprints is None: sent_fingerprints = set() if fingerprint in sent_fingerprints: From d485ea4b876e530e88fe9109c330b3e30128f595 Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Mon, 15 Jun 2026 13:15:29 +0800 Subject: [PATCH 5/6] fix: robustify dedup logic 1. Check result_chain in addition to completion_text 2. Use set(existing) for explicit copy instead of relying on get_extra reference behavior 3. Normalize text before comparison (strip + merge whitespace) 4. Handle multiple send_message_to_user calls in same response 5. Log comparison details for debugging Co-Authored-By: Claude --- .../agent/runners/tool_loop_agent_runner.py | 63 ++++++++++++++----- astrbot/core/tools/message_tools.py | 9 +-- 2 files changed, 52 insertions(+), 20 deletions(-) diff --git a/astrbot/core/agent/runners/tool_loop_agent_runner.py b/astrbot/core/agent/runners/tool_loop_agent_runner.py index f479893df9..8f48c9c20c 100644 --- a/astrbot/core/agent/runners/tool_loop_agent_runner.py +++ b/astrbot/core/agent/runners/tool_loop_agent_runner.py @@ -107,17 +107,37 @@ class _ToolExecutionInterrupted(Exception): ToolExecutorResultT = T.TypeVar("ToolExecutorResultT") -def _extract_send_message_text( +def _normalize_text(text: str) -> str: + """标准化文本用于比对:strip + 合并连续空白。""" + return " ".join(text.strip().split()) + + +def _extract_text_from_chain(chain) -> str | None: + """从 MessageChain 中提取纯文本内容。""" + if not chain or not hasattr(chain, "chain") or not chain.chain: + return None + texts = [] + for comp in chain.chain: + if hasattr(comp, "text"): + text = comp.text.strip() + if text: + texts.append(text) + return " ".join(texts) if texts else None + + +def _extract_send_message_texts( tools_call_name: list[str] | None, tools_call_args: list[dict] | None, -) -> str | None: - """从 send_message_to_user 工具调用参数中提取纯文本内容。 +) -> list[str]: + """从所有 send_message_to_user 调用中提取纯文本。 - 用于比对 completion_text 与工具 payload 是否一致, - 判断是否为重复发送。仅提取 type="plain" 的文本部分。 + 用于比对 completion_text / result_chain 与工具 payload + 是否一致,判断是否为重复发送。 + 仅提取 type="plain" 的文本部分。 """ if not tools_call_name or not tools_call_args: - return None + return [] + results = [] for name, args in zip(tools_call_name, tools_call_args): if name == "send_message_to_user" and isinstance(args, dict): messages = args.get("messages") @@ -130,8 +150,8 @@ def _extract_send_message_text( if text: texts.append(text) if texts: - return " ".join(texts) - return None + results.append(" ".join(texts)) + return results class ToolLoopAgentRunner(BaseAgentRunner[TContext]): @@ -820,7 +840,7 @@ async def step(self): # 返回 LLM 结果 # 当 send_message_to_user 的 payload 与 completion_text - # 内容一致时,抑制 completion_text 的 yield, + # 或 result_chain 内容一致时,抑制 yield, # 避免 respond 阶段重复发送。 # 仅在内容匹配时抑制,不影响发到其他会话或内容不同的场景。 _should_suppress_text = False @@ -828,16 +848,27 @@ async def step(self): llm_resp.tools_call_name and "send_message_to_user" in llm_resp.tools_call_name ): - _tool_text = _extract_send_message_text( + _tool_texts = _extract_send_message_texts( llm_resp.tools_call_name, llm_resp.tools_call_args ) - _completion = (llm_resp.completion_text or "").strip() - if _tool_text and _completion and _tool_text == _completion: - _should_suppress_text = True - logger.info( - "send_message_to_user payload 与 completion_text 一致," - "抑制以避免重复发送。" + if _tool_texts: + _completion = _normalize_text( + llm_resp.completion_text or "" ) + _chain_text = _normalize_text( + _extract_text_from_chain(llm_resp.result_chain) or "" + ) + for _tt in _tool_texts: + _nt = _normalize_text(_tt) + if (_nt and _completion and _nt == _completion) or ( + _nt and _chain_text and _nt == _chain_text + ): + _should_suppress_text = True + logger.info( + "send_message_to_user payload 与响应文本一致," + f"抑制以避免重复发送。 text={_tt[:50]!r}" + ) + break if llm_resp.reasoning_content: yield AgentResponse( type="llm_result", diff --git a/astrbot/core/tools/message_tools.py b/astrbot/core/tools/message_tools.py index 871f950198..6e65452bf8 100644 --- a/astrbot/core/tools/message_tools.py +++ b/astrbot/core/tools/message_tools.py @@ -325,11 +325,10 @@ async def call( messages, ensure_ascii=False, sort_keys=True ) fingerprint = hashlib.md5(dedup_key.encode()).hexdigest() - sent_fingerprints = context.context.event.get_extra( + existing = context.context.event.get_extra( "_send_message_fingerprints" ) - if sent_fingerprints is None: - sent_fingerprints = set() + sent_fingerprints = set(existing) if existing else set() if fingerprint in sent_fingerprints: logger.info( f"[send_message_to_user] 当前事件内重复发送,已跳过。" @@ -338,7 +337,9 @@ async def call( ) return f"Message skipped (duplicate), session={target_session}" sent_fingerprints.add(fingerprint) - context.context.event.set_extra("_send_message_fingerprints", sent_fingerprints) + context.context.event.set_extra( + "_send_message_fingerprints", sent_fingerprints + ) await context.context.context.send_message( target_session, From d2c9478cd3e4ae853f9aeebc9ffa3741677f79f3 Mon Sep 17 00:00:00 2001 From: Rrttttttt <3463984433@qq.com> Date: Mon, 15 Jun 2026 14:59:40 +0800 Subject: [PATCH 6/6] fix: add get_extra/set_extra to test mock The test mock used SimpleNamespace which doesn't have get_extra/set_extra methods. Replaced with _MockEvent class that supports the event extras API used by the dedup logic. Co-Authored-By: Claude --- tests/unit/test_message_tools.py | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/tests/unit/test_message_tools.py b/tests/unit/test_message_tools.py index 2b4659f5b2..1dec7681a8 100644 --- a/tests/unit/test_message_tools.py +++ b/tests/unit/test_message_tools.py @@ -8,6 +8,24 @@ from astrbot.core.tools.message_tools import SendMessageToUserTool +class _MockEvent: + """Minimal event mock with get_extra/set_extra support.""" + + def __init__(self, unified_msg_origin, role): + self.unified_msg_origin = unified_msg_origin + self.role = role + self._extras: dict = {} + + def get_sender_id(self): + return "user-1" + + def get_extra(self, key, default=None): + return self._extras.get(key, default) + + def set_extra(self, key, value): + self._extras[key] = value + + def _make_context( current_session="feishu:GroupMessage:oc_xxx", role="admin", @@ -23,11 +41,7 @@ def _make_context( } return SimpleNamespace( context=SimpleNamespace( - event=SimpleNamespace( - unified_msg_origin=current_session, - role=role, - get_sender_id=lambda: "user-1", - ), + event=_MockEvent(current_session, role), context=SimpleNamespace( get_config=lambda umo: cfg, send_message=AsyncMock(),