Files
hermes-agent/tests/gateway/test_background_command.py
kshitijk4poor ab2f4602de refactor: MessageEvent to gateway/platforms/event.py; ElicitationHandler takes a call_context thunk
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two
sites now name the real types.

gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved
out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py
imported helpers.py at module level, so helpers could not name MessageEvent. Now
TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed
(`from gateway.platforms.base import` -> `.event`, preserving each import's layout);
gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT
pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType,
photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event.
Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans).

tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its
MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only
ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]`
and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one
`functools.partial`, run directly or inside the captured Context.

ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved.
(The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional
exposes ~60 unguarded call sites — separate follow-up.)

Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31
failures reproduce identically on origin/main (macOS /private/tmp, systemd socket,
long-path fixtures, live-service tests).
2026-09-07 22:47:33 +05:30

300 lines
11 KiB
Python

"""Tests for /bg gateway slash command.
Tests the _handle_background_command handler (run a prompt in a separate
background session) across gateway messenger platforms.
"""
import asyncio
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import Platform
from gateway.platforms.event import MessageEvent
from gateway.session import SessionSource
def _make_event(text="/bg", platform=Platform.TELEGRAM,
user_id="12345", chat_id="67890"):
"""Build a MessageEvent for testing."""
source = SessionSource(
platform=platform,
user_id=user_id,
chat_id=chat_id,
user_name="testuser",
)
return MessageEvent(text=text, source=source)
def _make_runner():
"""Create a bare GatewayRunner with minimal mocks."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._session_db = None
runner._reasoning_config = None
runner._provider_routing = {}
runner._fallback_model = None
runner._running_agents = {}
runner._background_tasks = set()
mock_store = MagicMock()
# A real SessionStore returns None when no persisted /model override exists.
# MagicMock's default truthy return would otherwise rehydrate a fake model
# and make the session-scoped reasoning resolver receive a MagicMock.
mock_store.get_model_override.return_value = None
runner.session_store = mock_store
from gateway.hooks import HookRegistry
runner.hooks = HookRegistry()
return runner
# ---------------------------------------------------------------------------
# _handle_background_command
# ---------------------------------------------------------------------------
class TestHandleBackgroundCommand:
"""Tests for GatewayRunner._handle_background_command."""
@pytest.mark.asyncio
async def test_no_prompt_shows_usage(self):
"""Running /bg with no prompt shows usage."""
runner = _make_runner()
event = _make_event(text="/bg")
result = await runner._handle_background_command(event)
assert "Usage:" in result
assert "/bg" in result
@pytest.mark.asyncio
async def test_empty_prompt_shows_usage(self):
"""Running /bg with only whitespace shows usage."""
runner = _make_runner()
event = _make_event(text="/bg ")
result = await runner._handle_background_command(event)
assert "Usage:" in result
# ---------------------------------------------------------------------------
# _run_background_task
# ---------------------------------------------------------------------------
class TestRunBackgroundTask:
"""Tests for GatewayRunner._run_background_task (the actual execution)."""
@pytest.mark.asyncio
async def test_no_credentials_sends_error(self):
"""When provider credentials are missing, an error is sent."""
runner = _make_runner()
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": None}):
await runner._run_background_task("test prompt", source, "bg_test")
# Should have sent an error message
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
assert "failed" in call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "").lower()
@pytest.mark.asyncio
async def test_successful_task_sends_result(self):
"""When the agent completes successfully, the result is sent."""
runner = _make_runner()
mock_adapter = AsyncMock()
mock_adapter.send = AsyncMock()
mock_adapter.extract_media = MagicMock(return_value=([], "Hello from background!"))
mock_adapter.extract_images = MagicMock(return_value=([], "Hello from background!"))
runner.adapters[Platform.TELEGRAM] = mock_adapter
source = SessionSource(
platform=Platform.TELEGRAM,
user_id="12345",
chat_id="67890",
user_name="testuser",
)
mock_result = {"final_response": "Hello from background!", "messages": []}
checkpoint_config = {
"checkpoints": {
"enabled": True,
"max_snapshots": 8,
"max_total_size_mb": 222,
"max_file_size_mb": 3,
}
}
with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \
patch("gateway.run._load_gateway_config", return_value=checkpoint_config), \
patch("run_agent.AIAgent") as MockAgent:
mock_agent_instance = MagicMock()
mock_agent_instance.shutdown_memory_provider = MagicMock()
mock_agent_instance.close = MagicMock()
mock_agent_instance.run_conversation.return_value = mock_result
MockAgent.return_value = mock_agent_instance
await runner._run_background_task("say hello", source, "bg_test")
# Should have sent the result
mock_adapter.send.assert_called_once()
call_args = mock_adapter.send.call_args
content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "")
assert "Background task complete" in content
assert "Hello from background!" in content
agent_kwargs = MockAgent.call_args.kwargs
assert agent_kwargs["checkpoints_enabled"] is True
assert agent_kwargs["checkpoint_max_snapshots"] == 8
assert agent_kwargs["checkpoint_max_total_size_mb"] == 222
assert agent_kwargs["checkpoint_max_file_size_mb"] == 3
mock_agent_instance.shutdown_memory_provider.assert_called_once()
mock_agent_instance.close.assert_called_once()
# ---------------------------------------------------------------------------
# /bg in help and known_commands
# ---------------------------------------------------------------------------
class TestBackgroundInHelp:
"""Verify /bg and /btw appear in help text and known commands."""
@pytest.mark.asyncio
async def test_bg_and_btw_in_help_output(self):
"""The /help output includes /bg and /btw."""
runner = _make_runner()
event = _make_event(text="/help")
result = await runner._handle_help_command(event)
assert "/bg" in result
assert "/btw" in result
# ---------------------------------------------------------------------------
# CLI /bg command definition
# ---------------------------------------------------------------------------
class TestBackgroundInCLICommands:
"""Verify /bg and /btw are registered in the CLI command system."""
def test_bg_autocompletes(self):
"""The /bg and /btw commands appear in autocomplete results."""
pytest.importorskip("prompt_toolkit")
from hermes_cli.commands_completion import SlashCommandCompleter
from prompt_toolkit.document import Document
completer = SlashCommandCompleter()
doc = Document("bg") # Partial match
completions = list(completer.get_completions(doc, None))
# Text doesn't start with / so no completions
assert len(completions) == 0
doc = Document("/bg") # With slash prefix
completions = list(completer.get_completions(doc, None))
cmd_displays = [str(c.display) for c in completions]
assert any("/bg" in d for d in cmd_displays)
doc = Document("/btw")
completions = list(completer.get_completions(doc, None))
cmd_displays = [str(c.display) for c in completions]
assert any("/btw" in d for d in cmd_displays)
# ---------------------------------------------------------------------------
# _handle_btw_command
# ---------------------------------------------------------------------------
class TestHandleBtwCommand:
"""Tests for GatewayRunner._handle_btw_command (context-aware side question)."""
@pytest.mark.asyncio
async def test_no_question_shows_usage(self):
runner = _make_runner()
event = _make_event(text="/btw")
result = await runner._handle_btw_command(event)
assert "Usage:" in result
assert "/btw" in result
@pytest.mark.asyncio
async def test_no_history_reports_no_conversation(self):
runner = _make_runner()
store = AsyncMock()
store.get_or_create_session.return_value = MagicMock(session_id="s1")
store.load_transcript.return_value = []
store._store = runner.session_store
runner._async_session_store = store
event = _make_event(text="/btw what did we do?")
result = await runner._handle_btw_command(event)
assert "conversation" in result.lower()
@pytest.mark.asyncio
async def test_dispatches_side_question_and_sends_answer(self):
runner = _make_runner()
store = AsyncMock()
store.get_or_create_session.return_value = MagicMock(session_id="s1")
store.load_transcript.return_value = [
{"role": "user", "content": "fix foo.py"},
{"role": "assistant", "content": "done"},
]
store._store = runner.session_store
runner._async_session_store = store
runner._resolve_session_agent_runtime = MagicMock(
return_value=("test-model", {"api_key": "k", "provider": "p",
"base_url": "u", "api_mode": "chat_completions"})
)
runner._reply_anchor_for_event = MagicMock(return_value=None)
runner._thread_metadata_for_source = MagicMock(return_value=None)
mock_adapter = AsyncMock()
runner._adapter_for_source = MagicMock(return_value=mock_adapter)
event = _make_event(text="/btw which file was that?")
with patch("agent.side_question.answer_side_question",
return_value="it was foo.py") as mock_answer:
result = await runner._handle_btw_command(event)
# Ack returned immediately, worker task registered.
assert "which file was that?" in result
# Drain the fire-and-forget task.
for task in list(runner._background_tasks):
await task
# Snapshot + question reached the engine; live history untouched.
args, kwargs = mock_answer.call_args
assert args[0] == "which file was that?"
assert args[1][0]["content"] == "fix foo.py"
assert kwargs["main_runtime"]["model"] == "test-model"
# The answer was delivered to the chat.
mock_adapter.send.assert_called_once()
sent_text = mock_adapter.send.call_args[0][1]
assert "it was foo.py" in sent_text
@pytest.mark.asyncio
async def test_no_credentials_reports_error(self):
runner = _make_runner()
store = AsyncMock()
store.get_or_create_session.return_value = MagicMock(session_id="s1")
store.load_transcript.return_value = [{"role": "user", "content": "hi"}]
store._store = runner.session_store
runner._async_session_store = store
runner._resolve_session_agent_runtime = MagicMock(
return_value=(None, {"api_key": None})
)
event = _make_event(text="/btw what?")
result = await runner._handle_btw_command(event)
assert "❌" in result