Files
hermes-agent/tests/gateway/test_subagent_protection.py
teknium1 982165203d test: purge low-value tests, lane py09 (263 removed)
Change-detectors, tautologies, source-reading tests, redundant duplicates,
mock-echo tests and dead/unrunnable tests. Per-test rationale in the lane
ledger (category + reason for every removal).
2026-09-23 03:15:26 -07:00

240 lines
10 KiB
Python

"""Regression tests for #30170.
#30170: Sending a message while ``delegate_task`` is running killed the
subagent because the gateway always called ``running_agent.interrupt()``
on the parent, which then cascaded synchronously through
``AIAgent._active_children`` and aborted every in-flight subagent. The
reporter (and the linked Phase-1 spec) asked for the gateway to demote
``busy_input_mode='interrupt'`` to ``queue`` semantics whenever the
parent is currently driving subagents, while leaving explicit ``/stop``
and ``/new`` slash commands untouched.
These tests pin down the gateway-side guard introduced for #30170:
* ``GatewayRunner._agent_has_active_subagents`` correctly recognises
parents that own real children, without false-positives from a
``MagicMock()._active_children`` auto-attribute, missing locks, or
the ``_AGENT_PENDING_SENTINEL`` placeholder.
* ``_handle_active_session_busy_message`` demotes the interrupt mode to
queue semantics (no ``interrupt()`` call, message merged into the
pending queue, ack reflects the demotion) when the parent has active
subagents.
* The ``queue`` and ``steer`` configured modes still behave exactly as
before — the guard is interrupt-only.
"""
from __future__ import annotations
import sys
import threading
import types
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ──────────────────────────────────────────────────────────────────────
# Minimal stubs so gateway imports cleanly (mirrors test_busy_session_ack)
# ──────────────────────────────────────────────────────────────────────
_tg = types.ModuleType("telegram")
_tg.constants = types.ModuleType("telegram.constants")
_ct = MagicMock()
_ct.SUPERGROUP = "supergroup"
_ct.GROUP = "group"
_ct.PRIVATE = "private"
_tg.constants.ChatType = _ct
sys.modules.setdefault("telegram", _tg)
sys.modules.setdefault("telegram.constants", _tg.constants)
sys.modules.setdefault("telegram.ext", types.ModuleType("telegram.ext"))
from gateway.platforms.base import (
SessionSource,
build_session_key,
)
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run import GatewayRunner # noqa: E402
# ──────────────────────────────────────────────────────────────────────
# Builders (parallel to tests/gateway/test_busy_session_ack.py)
# ──────────────────────────────────────────────────────────────────────
def _make_event(text: str = "hello", chat_id: str = "123") -> MessageEvent:
source = SessionSource(
platform=MagicMock(value="telegram"),
chat_id=chat_id,
chat_type="private",
user_id="user1",
)
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=source,
message_id="msg1",
)
def _make_runner() -> GatewayRunner:
runner = object.__new__(GatewayRunner)
runner._running_agents = {}
runner._running_agents_ts = {}
runner._pending_messages = {}
runner._busy_ack_ts = {}
runner._draining = False
runner.adapters = {}
runner.config = MagicMock()
runner.session_store = None
runner.hooks = MagicMock()
runner.hooks.emit = AsyncMock()
runner.pairing_store = MagicMock()
runner.pairing_store.is_approved.return_value = True
runner._is_user_authorized = lambda _source: True
return runner
def _make_adapter() -> MagicMock:
adapter = MagicMock()
adapter._pending_messages = {}
adapter._send_with_retry = AsyncMock()
adapter.config = MagicMock()
adapter.config.extra = {}
adapter.platform = MagicMock(value="telegram")
return adapter
def _make_parent_with_subagents(
*, children: int = 1, with_lock: bool = True
) -> MagicMock:
"""A MagicMock shaped like an AIAgent that currently owns *children* subagents."""
parent = MagicMock()
parent._active_children = [MagicMock() for _ in range(children)]
parent._active_children_lock = threading.Lock() if with_lock else None
parent.get_activity_summary.return_value = {
"api_call_count": 7,
"max_iterations": 60,
"current_tool": "delegate_task",
}
return parent
def _make_parent_no_subagents() -> MagicMock:
"""A MagicMock shaped like an AIAgent that is NOT delegating."""
parent = MagicMock()
parent._active_children = []
parent._active_children_lock = threading.Lock()
parent.get_activity_summary.return_value = {
"api_call_count": 3,
"max_iterations": 60,
"current_tool": "terminal",
}
return parent
# ──────────────────────────────────────────────────────────────────────
# _agent_has_active_subagents
# ──────────────────────────────────────────────────────────────────────
class TestAgentHasActiveSubagents:
"""The detection helper must be both precise and defensive."""
def test_returns_false_when_attribute_missing(self) -> None:
"""Production AIAgents always have _active_children, but the helper
must not blow up on test stubs or partial mocks."""
class StubAgent:
pass
assert GatewayRunner._agent_has_active_subagents(StubAgent()) is False
def test_works_without_lock(self) -> None:
"""``_active_children_lock`` is optional in test stubs."""
assert (
GatewayRunner._agent_has_active_subagents(
_make_parent_with_subagents(with_lock=False)
)
is True
)
def test_rejects_truthy_non_collection_attribute(self) -> None:
"""The MagicMock auto-attribute regression. ``MagicMock()._active_children``
is itself a truthy MagicMock — without the isinstance guard, the
helper would falsely report subagents on every test mock."""
parent = MagicMock() # no explicit _active_children setup
assert GatewayRunner._agent_has_active_subagents(parent) is False
# ──────────────────────────────────────────────────────────────────────
# _handle_active_session_busy_message — interrupt demotion
# ──────────────────────────────────────────────────────────────────────
class TestBusyHandlerDemotesInterruptForSubagents:
"""The Phase-1 fix from #30170: parent.interrupt() must NOT fire when
the parent is currently driving subagents."""
@pytest.mark.asyncio
async def test_interrupt_still_fires_when_no_subagents(self) -> None:
"""Regression-guard the other direction: with no subagents the
demotion must NOT trigger and behaviour must be byte-identical
to the pre-#30170 interrupt path."""
runner = _make_runner()
runner._busy_input_mode = "interrupt"
adapter = _make_adapter()
event = _make_event(text="please stop")
sk = build_session_key(event.source)
parent = _make_parent_no_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.platforms.base.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.interrupt.assert_called_once_with("please stop")
@pytest.mark.asyncio
async def test_queue_mode_unchanged_with_subagents(self) -> None:
"""Configured ``queue`` mode is already subagent-safe; the new
guard must not change its behaviour or its ack text."""
runner = _make_runner()
runner._busy_input_mode = "queue"
adapter = _make_adapter()
event = _make_event(text="queued during delegate")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.platforms.base.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.interrupt.assert_not_called()
content = adapter._send_with_retry.call_args.kwargs.get("content", "")
# The vanilla queue copy — NOT the #30170 "Subagent working" copy,
# because the user explicitly asked for queue mode.
assert "Subagent working" not in content
@pytest.mark.asyncio
async def test_steer_mode_still_routes_through_running_agent_steer(
self,
) -> None:
"""Configured ``steer`` mode must reach ``running_agent.steer()``
even when subagents are active — the #30170 demotion is
interrupt-specific so it doesn't accidentally disable steer."""
runner = _make_runner()
runner._busy_input_mode = "steer"
adapter = _make_adapter()
event = _make_event(text="course-correct")
sk = build_session_key(event.source)
parent = _make_parent_with_subagents()
parent.steer = MagicMock(return_value=True)
runner._running_agents[sk] = parent
runner.adapters[event.source.platform] = adapter
with patch("gateway.platforms.base.merge_pending_message_event"):
await runner._handle_active_session_busy_message(event, sk)
parent.steer.assert_called_once()
injected = parent.steer.call_args.args[0]
assert injected.endswith("course-correct")
assert '"chat_id": "123"' in injected
parent.interrupt.assert_not_called()