Files
hermes-agent/tests/gateway/test_stop_ends_background_delegations.py
teknium1 b71359066c fix: /stop halts background subagents and returns their partial results as interrupted completions
A turn's hard interrupt fans out only to `_active_children`; background
delegate_task units are detached from the parent at dispatch
(`_dispatch_background` / honor_parent_interrupt=False), so /stop left them
running to completion and their result arrived minutes later as a parked
wake. Every stop surface now also calls
`tools.async_delegation.interrupt_for_session` for the session's units:

- gateway: `_interrupt_and_clear_session` (busy /stop, /new share it — /new
  already did this in `_handle_reset_command`, the second request is
  idempotent) and the idle `_handle_stop_command` tail, which replied
  "No active task to stop." while a background child was running; it now
  stops them and replies "Stopped".
- tui_gateway `session.interrupt` (Desktop Stop / TUI stop) — own UI sid +
  spawner id only, so a viewer tab never kills gateway work.
- acp_adapter `cancel`.
- CLI `/stop` already used `interrupt_all` (process-wide); unchanged.

The stop recurses: depth>0 delegations are always synchronous
(`_model_background_value`), so the child's hard interrupt reaches its
workers through its own `_active_children` fan-out, and each level's
interrupted partial result rolls up as that child's completion.

An interrupted child's entry now carries what it actually had: the loop's
`final_response` is the "Operation interrupted." placeholder (also appended
as the closing assistant row), so `_build_result_entry` takes the child's
last real assistant text as `summary` and keeps the placeholder as `error`.
The unit finalizes normally and re-enters at once as its completion notice
(status=interrupted, "Partial output: ...", "Subagent Task Interrupted" on
TUI/Desktop) instead of the chat waiting for the child's budget to run out.

Docs: delegate_task description, tools/AGENTS.md, delegation.md,
gateway-session-lifecycle.md.

Part of #114456
2026-09-18 20:34:42 -07:00

81 lines
3.5 KiB
Python

"""Invariant: ``/stop`` ends the session's BACKGROUND delegate_task children too — whether the session is
mid-turn (busy fast path) or idle after the dispatching turn already ended — instead of only the in-turn
children, leaving the detached unit to run to completion and wake the chat later. See #114456.
Real ``GatewayRunner`` + real ``BasePlatformAdapter`` subclass; the background unit is a real
``tools.async_delegation`` registry record whose ``interrupt_fn`` is the observable.
"""
from unittest.mock import MagicMock
import pytest
import tools.async_delegation as ad
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.base import BasePlatformAdapter, SendResult
from gateway.platforms.event import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
class _Adapter(BasePlatformAdapter):
def __init__(self):
super().__init__(PlatformConfig(enabled=True, token="x"), Platform.TELEGRAM)
@property
def name(self):
return "telegram"
async def connect(self, *, is_reconnect=False):
return True
async def disconnect(self):
pass
async def send(self, chat_id, content, reply_to=None, metadata=None):
return SendResult(success=True)
async def get_chat_info(self, chat_id):
return {"id": chat_id, "type": "private"}
@pytest.fixture(autouse=True)
def _reset_async_delegation():
ad._reset_for_tests()
yield
ad._reset_for_tests()
def _seed_unit(session_key: str, parent_session_id: str = "") -> MagicMock:
"""A live background unit as ``_dispatch_background`` registers it (running, routed by session_key)."""
fn = MagicMock()
with ad._records_lock:
ad._records["deleg_bg1"] = {"delegation_id": "deleg_bg1", "status": "running", "session_key": session_key,
"origin_ui_session_id": "", "parent_session_id": parent_session_id, "interrupt_fn": fn}
return fn
@pytest.mark.asyncio
@pytest.mark.parametrize("session_state", ["idle", "busy"])
async def test_stop_ends_background_delegations_of_the_session(monkeypatch, session_state):
monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "u1")
adapter = _Adapter()
runner = GatewayRunner(config=GatewayConfig(platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="x")}))
runner.adapters = {Platform.TELEGRAM: adapter}
source = SessionSource(platform=Platform.TELEGRAM, chat_id="c1", chat_type="dm", user_id="u1", user_name="tester")
key = adapter._event_session_key(MessageEvent(text="", message_type=MessageType.TEXT, source=source))
stop_fn = _seed_unit(key)
other_fn = MagicMock()
with ad._records_lock:
ad._records["deleg_other"] = {"delegation_id": "deleg_other", "status": "running", "session_key": "agent:main:telegram:dm:c2",
"origin_ui_session_id": "", "parent_session_id": "", "interrupt_fn": other_fn}
if session_state == "busy":
await runner._interrupt_and_clear_session(key, source, interrupt_reason="stop", invalidation_reason="stop_command")
else:
reply = await runner._handle_stop_command(MessageEvent(text="/stop", message_type=MessageType.TEXT, source=source))
# The chat is told something WAS stopped, not "No active task to stop."
assert "Stopped" in str(getattr(reply, "text", reply))
stop_fn.assert_called_once()
other_fn.assert_not_called() # another chat's background work is untouched