mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-30 06:41:51 +00:00
Remove unused imports (F401) and duplicate/shadowed import redefinitions (F811) across the codebase using ruff's safe autofixes. No behavioral changes -- imports only. - ~1400 safe autofixes applied across 644 files (net -1072 lines) - __init__.py re-exports preserved (excluded from F401 removal so public re-export surfaces stay intact) - Re-exports that are imported or monkeypatched by tests but look unused in their defining module are kept with explicit # noqa: F401 (gateway/run.py load_dotenv; run_agent re-exports from agent.message_sanitization, agent.context_compressor, agent.retry_utils, agent.prompt_builder, agent.process_bootstrap, agent.codex_responses_adapter) - Unsafe F841 (unused-variable) fixes deliberately skipped -- those can change behavior when the RHS has side effects - ruff lints remain disabled in pyproject.toml (only PLW1514 is selected); this is a one-time cleanup, not a config change Verification: - python -m compileall: clean - pytest --collect-only: all 27161 tests collect (zero import errors) - core entry points import clean (run_agent, model_tools, cli, toolsets, hermes_state, batch_runner, gateway) - static scan: every name any test imports directly from an edited module still resolves
511 lines
19 KiB
Python
511 lines
19 KiB
Python
"""Tests for text message batching across all gateway adapters.
|
|
|
|
When a user sends a long message, the messaging client splits it at the
|
|
platform's character limit. Each adapter should buffer rapid successive
|
|
text messages from the same session and aggregate them before dispatching.
|
|
|
|
Covers: Discord, Matrix, WeCom, and the adaptive delay logic for
|
|
Telegram and Feishu.
|
|
"""
|
|
|
|
import asyncio
|
|
from unittest.mock import AsyncMock
|
|
|
|
import pytest
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
from gateway.platforms.base import MessageEvent, MessageType, SessionSource
|
|
|
|
|
|
# =====================================================================
|
|
# Helpers
|
|
# =====================================================================
|
|
|
|
def _make_event(
|
|
text: str,
|
|
platform: Platform,
|
|
chat_id: str = "12345",
|
|
msg_type: MessageType = MessageType.TEXT,
|
|
) -> MessageEvent:
|
|
return MessageEvent(
|
|
text=text,
|
|
message_type=msg_type,
|
|
source=SessionSource(platform=platform, chat_id=chat_id, chat_type="dm"),
|
|
)
|
|
|
|
|
|
# =====================================================================
|
|
# Discord text batching
|
|
# =====================================================================
|
|
|
|
def _make_discord_adapter():
|
|
"""Create a minimal DiscordAdapter for testing text batching."""
|
|
from plugins.platforms.discord.adapter import DiscordAdapter
|
|
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = object.__new__(DiscordAdapter)
|
|
adapter._platform = Platform.DISCORD
|
|
adapter.config = config
|
|
adapter._pending_text_batches = {}
|
|
adapter._pending_text_batch_tasks = {}
|
|
adapter._text_batch_delay_seconds = 0.1 # fast for tests
|
|
adapter._text_batch_split_delay_seconds = 0.3 # fast for tests
|
|
adapter._active_sessions = {}
|
|
adapter._pending_messages = {}
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
class TestDiscordTextBatching:
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_dispatched_after_delay(self):
|
|
adapter = _make_discord_adapter()
|
|
event = _make_event("hello world", Platform.DISCORD)
|
|
|
|
adapter._enqueue_text_event(event)
|
|
|
|
# Not dispatched yet
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
# Wait for flush
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
dispatched = adapter.handle_message.call_args[0][0]
|
|
assert dispatched.text == "hello world"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_messages_aggregated(self):
|
|
"""Two rapid messages from the same chat should be merged."""
|
|
adapter = _make_discord_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("Part one of a long", Platform.DISCORD))
|
|
await asyncio.sleep(0.02)
|
|
adapter._enqueue_text_event(_make_event("message that was split.", Platform.DISCORD))
|
|
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
text = adapter.handle_message.call_args[0][0].text
|
|
assert "Part one" in text
|
|
assert "split" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_three_way_split_aggregated(self):
|
|
adapter = _make_discord_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("chunk 1", Platform.DISCORD))
|
|
await asyncio.sleep(0.02)
|
|
adapter._enqueue_text_event(_make_event("chunk 2", Platform.DISCORD))
|
|
await asyncio.sleep(0.02)
|
|
adapter._enqueue_text_event(_make_event("chunk 3", Platform.DISCORD))
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
text = adapter.handle_message.call_args[0][0].text
|
|
assert "chunk 1" in text
|
|
assert "chunk 2" in text
|
|
assert "chunk 3" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_chats_not_merged(self):
|
|
adapter = _make_discord_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("from A", Platform.DISCORD, chat_id="111"))
|
|
adapter._enqueue_text_event(_make_event("from B", Platform.DISCORD, chat_id="222"))
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
assert adapter.handle_message.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_cleans_up_after_flush(self):
|
|
adapter = _make_discord_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("test", Platform.DISCORD))
|
|
await asyncio.sleep(0.2)
|
|
|
|
assert len(adapter._pending_text_batches) == 0
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adaptive_delay_for_near_limit_chunk(self):
|
|
"""Chunks near the 2000-char limit should trigger longer delay."""
|
|
adapter = _make_discord_adapter()
|
|
# Simulate a chunk near Discord's 2000-char split point
|
|
long_text = "x" * 1950
|
|
adapter._enqueue_text_event(_make_event(long_text, Platform.DISCORD))
|
|
|
|
# After the short delay (0.1s), should NOT have flushed yet (split delay is 0.3s)
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
# After the split delay, should be flushed
|
|
await asyncio.sleep(0.25)
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_shield_protects_handle_message_from_cancel(self):
|
|
"""Regression guard: a follow-up chunk arriving while
|
|
handle_message is mid-flight must NOT cancel the running
|
|
dispatch. _enqueue_text_event fires prior_task.cancel() on
|
|
every new chunk; without asyncio.shield around handle_message
|
|
the cancel propagates into the agent's streaming request and
|
|
aborts the response.
|
|
"""
|
|
adapter = _make_discord_adapter()
|
|
|
|
handle_started = asyncio.Event()
|
|
release_handle = asyncio.Event()
|
|
first_handle_cancelled = asyncio.Event()
|
|
first_handle_completed = asyncio.Event()
|
|
call_count = [0]
|
|
|
|
async def slow_handle(event):
|
|
call_count[0] += 1
|
|
# Only the first call (batch 1) is the one we're protecting.
|
|
if call_count[0] == 1:
|
|
handle_started.set()
|
|
try:
|
|
await release_handle.wait()
|
|
first_handle_completed.set()
|
|
except asyncio.CancelledError:
|
|
first_handle_cancelled.set()
|
|
raise
|
|
# Second call (batch 2) returns immediately — not the subject
|
|
# of this test.
|
|
|
|
adapter.handle_message = slow_handle
|
|
|
|
# Prime batch 1 and wait for it to land inside handle_message.
|
|
adapter._enqueue_text_event(_make_event("batch 1", Platform.DISCORD))
|
|
await asyncio.wait_for(handle_started.wait(), timeout=1.0)
|
|
|
|
# A new chunk arrives — _enqueue_text_event fires
|
|
# prior_task.cancel() on batch 1's flush task, which is
|
|
# currently awaiting inside handle_message.
|
|
adapter._enqueue_text_event(_make_event("batch 2 follow-up", Platform.DISCORD))
|
|
|
|
# Let the cancel propagate.
|
|
await asyncio.sleep(0.05)
|
|
|
|
# CRITICAL ASSERTION: batch 1's handle_message must NOT have
|
|
# been cancelled. Without asyncio.shield this assertion fails
|
|
# because CancelledError propagates from the flush task's
|
|
# `await self.handle_message(event)` into slow_handle.
|
|
assert not first_handle_cancelled.is_set(), (
|
|
"handle_message for batch 1 was cancelled by a follow-up "
|
|
"chunk — asyncio.shield is missing or broken"
|
|
)
|
|
|
|
# Release batch 1's handle_message and let it complete.
|
|
release_handle.set()
|
|
await asyncio.wait_for(first_handle_completed.wait(), timeout=1.0)
|
|
assert first_handle_completed.is_set()
|
|
|
|
# Cleanup
|
|
for task in list(adapter._pending_text_batch_tasks.values()):
|
|
task.cancel()
|
|
await asyncio.sleep(0.01)
|
|
|
|
|
|
# =====================================================================
|
|
# Matrix text batching
|
|
# =====================================================================
|
|
|
|
def _make_matrix_adapter():
|
|
"""Create a minimal MatrixAdapter for testing text batching."""
|
|
from gateway.platforms.matrix import MatrixAdapter
|
|
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = object.__new__(MatrixAdapter)
|
|
adapter._platform = Platform.MATRIX
|
|
adapter.config = config
|
|
adapter._pending_text_batches = {}
|
|
adapter._pending_text_batch_tasks = {}
|
|
adapter._text_batch_delay_seconds = 0.1
|
|
adapter._text_batch_split_delay_seconds = 0.3
|
|
adapter._active_sessions = {}
|
|
adapter._pending_messages = {}
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
class TestMatrixTextBatching:
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_dispatched_after_delay(self):
|
|
adapter = _make_matrix_adapter()
|
|
event = _make_event("hello world", Platform.MATRIX)
|
|
|
|
adapter._enqueue_text_event(event)
|
|
|
|
adapter.handle_message.assert_not_called()
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_messages_aggregated(self):
|
|
adapter = _make_matrix_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("first part", Platform.MATRIX))
|
|
await asyncio.sleep(0.02)
|
|
adapter._enqueue_text_event(_make_event("second part", Platform.MATRIX))
|
|
|
|
adapter.handle_message.assert_not_called()
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
text = adapter.handle_message.call_args[0][0].text
|
|
assert "first part" in text
|
|
assert "second part" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_rooms_not_merged(self):
|
|
adapter = _make_matrix_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("room A", Platform.MATRIX, chat_id="!aaa:matrix.org"))
|
|
adapter._enqueue_text_event(_make_event("room B", Platform.MATRIX, chat_id="!bbb:matrix.org"))
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
assert adapter.handle_message.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adaptive_delay_for_near_limit_chunk(self):
|
|
"""Chunks near the 4000-char limit should trigger longer delay."""
|
|
adapter = _make_matrix_adapter()
|
|
long_text = "x" * 3950
|
|
adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
|
|
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
await asyncio.sleep(0.25)
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_cleans_up_after_flush(self):
|
|
adapter = _make_matrix_adapter()
|
|
adapter._enqueue_text_event(_make_event("test", Platform.MATRIX))
|
|
await asyncio.sleep(0.2)
|
|
assert len(adapter._pending_text_batches) == 0
|
|
|
|
|
|
# =====================================================================
|
|
# WeCom text batching
|
|
# =====================================================================
|
|
|
|
def _make_wecom_adapter():
|
|
"""Create a minimal WeComAdapter for testing text batching."""
|
|
from gateway.platforms.wecom import WeComAdapter
|
|
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = object.__new__(WeComAdapter)
|
|
adapter._platform = Platform.WECOM
|
|
adapter.config = config
|
|
adapter._pending_text_batches = {}
|
|
adapter._pending_text_batch_tasks = {}
|
|
adapter._text_batch_delay_seconds = 0.1
|
|
adapter._text_batch_split_delay_seconds = 0.3
|
|
adapter._active_sessions = {}
|
|
adapter._pending_messages = {}
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
class TestWeComTextBatching:
|
|
@pytest.mark.asyncio
|
|
async def test_single_message_dispatched_after_delay(self):
|
|
adapter = _make_wecom_adapter()
|
|
event = _make_event("hello world", Platform.WECOM)
|
|
|
|
adapter._enqueue_text_event(event)
|
|
|
|
adapter.handle_message.assert_not_called()
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
assert adapter.handle_message.call_args[0][0].text == "hello world"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_messages_aggregated(self):
|
|
adapter = _make_wecom_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("first part", Platform.WECOM))
|
|
await asyncio.sleep(0.02)
|
|
adapter._enqueue_text_event(_make_event("second part", Platform.WECOM))
|
|
|
|
adapter.handle_message.assert_not_called()
|
|
await asyncio.sleep(0.2)
|
|
|
|
adapter.handle_message.assert_called_once()
|
|
text = adapter.handle_message.call_args[0][0].text
|
|
assert "first part" in text
|
|
assert "second part" in text
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_different_chats_not_merged(self):
|
|
adapter = _make_wecom_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("chat A", Platform.WECOM, chat_id="chat_a"))
|
|
adapter._enqueue_text_event(_make_event("chat B", Platform.WECOM, chat_id="chat_b"))
|
|
|
|
await asyncio.sleep(0.2)
|
|
|
|
assert adapter.handle_message.call_count == 2
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_adaptive_delay_for_near_limit_chunk(self):
|
|
"""Chunks near the 4000-char limit should trigger longer delay."""
|
|
adapter = _make_wecom_adapter()
|
|
long_text = "x" * 3950
|
|
adapter._enqueue_text_event(_make_event(long_text, Platform.WECOM))
|
|
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
await asyncio.sleep(0.25)
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_batch_cleans_up_after_flush(self):
|
|
adapter = _make_wecom_adapter()
|
|
adapter._enqueue_text_event(_make_event("test", Platform.WECOM))
|
|
await asyncio.sleep(0.2)
|
|
assert len(adapter._pending_text_batches) == 0
|
|
|
|
|
|
# =====================================================================
|
|
# Telegram adaptive delay (PR #6891)
|
|
# =====================================================================
|
|
|
|
def _make_telegram_adapter():
|
|
"""Create a minimal TelegramAdapter for testing adaptive delay."""
|
|
from gateway.platforms.telegram import TelegramAdapter
|
|
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = object.__new__(TelegramAdapter)
|
|
adapter._platform = Platform.TELEGRAM
|
|
adapter.config = config
|
|
adapter._pending_text_batches = {}
|
|
adapter._pending_text_batch_tasks = {}
|
|
adapter._text_batch_delay_seconds = 0.1
|
|
adapter._text_batch_split_delay_seconds = 0.3
|
|
adapter._active_sessions = {}
|
|
adapter._pending_messages = {}
|
|
adapter._message_handler = AsyncMock()
|
|
adapter.handle_message = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
class TestTelegramAdaptiveDelay:
|
|
@pytest.mark.asyncio
|
|
async def test_short_chunk_uses_normal_delay(self):
|
|
adapter = _make_telegram_adapter()
|
|
adapter._enqueue_text_event(_make_event("short msg", Platform.TELEGRAM))
|
|
|
|
# Should flush after the normal 0.1s delay
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_near_limit_chunk_uses_split_delay(self):
|
|
"""A chunk near the 4096-char limit should trigger longer delay."""
|
|
adapter = _make_telegram_adapter()
|
|
long_text = "x" * 4050 # near the 4096 limit
|
|
adapter._enqueue_text_event(_make_event(long_text, Platform.TELEGRAM))
|
|
|
|
# After the short delay, should NOT have flushed yet
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_not_called()
|
|
|
|
# After the split delay, should be flushed
|
|
await asyncio.sleep(0.25)
|
|
adapter.handle_message.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_continuation_merged(self):
|
|
"""Two near-limit chunks should both be merged."""
|
|
adapter = _make_telegram_adapter()
|
|
|
|
adapter._enqueue_text_event(_make_event("x" * 4050, Platform.TELEGRAM))
|
|
await asyncio.sleep(0.05)
|
|
adapter._enqueue_text_event(_make_event("continuation text", Platform.TELEGRAM))
|
|
|
|
# Short chunk arrived → should use normal delay now
|
|
await asyncio.sleep(0.15)
|
|
adapter.handle_message.assert_called_once()
|
|
text = adapter.handle_message.call_args[0][0].text
|
|
assert "continuation text" in text
|
|
|
|
|
|
# =====================================================================
|
|
# Feishu adaptive delay
|
|
# =====================================================================
|
|
|
|
def _make_feishu_adapter():
|
|
"""Create a minimal FeishuAdapter for testing adaptive delay."""
|
|
from gateway.platforms.feishu import FeishuAdapter, FeishuBatchState
|
|
|
|
config = PlatformConfig(enabled=True, token="test-token")
|
|
adapter = object.__new__(FeishuAdapter)
|
|
adapter._platform = Platform.FEISHU
|
|
adapter.config = config
|
|
batch_state = FeishuBatchState()
|
|
adapter._pending_text_batches = batch_state.events
|
|
adapter._pending_text_batch_tasks = batch_state.tasks
|
|
adapter._pending_text_batch_counts = batch_state.counts
|
|
adapter._text_batch_delay_seconds = 0.1
|
|
adapter._text_batch_split_delay_seconds = 0.3
|
|
adapter._text_batch_max_messages = 20
|
|
adapter._text_batch_max_chars = 50000
|
|
adapter._active_sessions = {}
|
|
adapter._pending_messages = {}
|
|
adapter._message_handler = AsyncMock()
|
|
adapter._handle_message_with_guards = AsyncMock()
|
|
return adapter
|
|
|
|
|
|
class TestFeishuAdaptiveDelay:
|
|
@pytest.mark.asyncio
|
|
async def test_short_chunk_uses_normal_delay(self):
|
|
adapter = _make_feishu_adapter()
|
|
event = _make_event("short msg", Platform.FEISHU)
|
|
await adapter._enqueue_text_event(event)
|
|
|
|
await asyncio.sleep(0.15)
|
|
adapter._handle_message_with_guards.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_near_limit_chunk_uses_split_delay(self):
|
|
"""A chunk near the 4096-char limit should trigger longer delay."""
|
|
adapter = _make_feishu_adapter()
|
|
long_text = "x" * 4050
|
|
event = _make_event(long_text, Platform.FEISHU)
|
|
await adapter._enqueue_text_event(event)
|
|
|
|
await asyncio.sleep(0.15)
|
|
adapter._handle_message_with_guards.assert_not_called()
|
|
|
|
await asyncio.sleep(0.25)
|
|
adapter._handle_message_with_guards.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_split_continuation_merged(self):
|
|
adapter = _make_feishu_adapter()
|
|
|
|
await adapter._enqueue_text_event(_make_event("x" * 4050, Platform.FEISHU))
|
|
await asyncio.sleep(0.05)
|
|
await adapter._enqueue_text_event(_make_event("continuation text", Platform.FEISHU))
|
|
|
|
await asyncio.sleep(0.15)
|
|
adapter._handle_message_with_guards.assert_called_once()
|
|
text = adapter._handle_message_with_guards.call_args[0][0].text
|
|
assert "continuation text" in text
|