hermes-agent/tests/gateway/test_text_batching.py
ethernet 0fce82164a Pluginify provider/platform/terminal backends
Move provider adapters (anthropic, bedrock, azure), platform adapters
(telegram, slack, discord, feishu, dingtalk, matrix), and terminal backends
(modal, daytona) out of core into plugins/ workspace members. Core references
them via the plugin registries (get_provider_namespace / get_provider_service /
get_tool_provider / get_credential_pool_hook) instead of direct imports.

- Provider/platform/terminal adapters relocated under plugins/; pyproject
  extras reference workspace members; nix variants aggregate per-platform extras.
- Anthropic credential discovery + OAuth-masquerade guard live in the plugin's
  credential_pool_hook; browser-open guarded by _can_open_graphical_browser.
- Vercel AI Gateway + Vercel Sandbox removed (upstream deletion); get_bedrock_model_ids
  removed (replaced by bedrock_model_ids_or_none + discover_bedrock_models).
- Terminal backends resolve ModalEnvironment / DaytonaEnvironment lazily from
  the plugin registry.
- uv.lock regenerated against the pluginified workspace.

Plugin test suites updated for the relocation: imports point at
hermes_agent_<plat>.adapter, caplog logger-name filters and monkeypatch targets
use the new module paths, and credential/rollback tests patch
registries.get_provider_service rather than the removed agent.*_adapter modules.

Verified: zero dead imports of relocated modules in core (import smoke test +
rename-map grep); nix develop succeeds; targeted plugin suites green
(bedrock, anthropic-auxiliary, matrix, dingtalk, feishu, credential_pool,
switch_model_rollback). Remaining full-suite failures are pre-existing on the
pre-merge tree (telegram setUpModule __code__) or environmental (voice/media/
PTY/network-dependent), not introduced here.
2026-05-29 09:28:00 -04:00

512 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 hermes_agent_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 hermes_agent_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 hermes_agent_feishu import FeishuAdapter
from hermes_agent_feishu.adapter import 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