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
81 lines
3.5 KiB
Python
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
|