Files
EvoScientist-Multi/tests/test_async_subagent_swap.py
T
Xi Zhang 80f1f4fa0f feat: Implement async sub-agent auto-notification system (#214)
* feat: Implement async sub-agent auto-notification system

- Added async notifier functionality to handle notifications for sub-agents reaching terminal states.
- Introduced `AsyncTaskNotification` dataclass for structured notification data.
- Implemented `watch_run_and_notify` to monitor agent runs and enqueue notifications.
- Created `spawn_watcher` to manage watcher tasks and ensure proper cancellation of previous watchers.
- Developed `consume_notifications` to process notifications, deduplicate them, and format messages for LLM.
- Added tests for notification handling, including draining, deduplication, and formatting.
- Patched deepagents to integrate the new watcher functionality into start and update tools.

* Enhance async notifier with per-thread notification routing and error handling

- Introduced `origin_cli_thread_id` to `AsyncTaskNotification` for routing notifications back to the originating CLI session.
- Implemented per-thread notification queues to handle notifications based on the originating thread.
- Updated `has_pending_notifications` and `drain_notifications` to respect thread-specific queues.
- Enhanced `watch_run_and_notify` to detect in-band error events from the SSE stream and handle clean exits.
- Modified tests to verify the new notification routing behavior and ensure proper handling of notifications across threads.
- Added a fixture to restore the async watcher patch state in tests to prevent state leakage.
- Updated deepagents patching to capture the main agent's CLI thread ID for notification routing.

* feat: Enhance async notifier with thread-specific watcher management and notification filtering

* test: Enhance notification draining logic for cleaner test setup

* refactor: Remove summary field from AsyncTaskNotification and update related tests

* feat: Enhance async notification handling with target thread ID support

* Refactor async notifier and middleware for improved task management

- Removed the no-op shutdown watcher loop from async_notifier.py as it is no longer needed.
- Updated watch_run_and_notify to clarify notification handling and race conditions.
- Cleaned up shutdown handling in commands.py, interactive.py, and tui_interactive.py by removing obsolete shutdown watcher calls.
- Deleted the deepagents async watcher patch from patches.py, transitioning to a new middleware approach.
- Introduced AsyncWatcherMiddleware to handle async task notifications directly during tool calls.
- Updated tests to validate the new middleware functionality and ensure proper watcher spawning and cancellation.
- Enhanced test coverage for async watcher middleware, including edge cases and error handling.

* feat(tests): add fixture to reset notifier state before each test
2026-05-07 16:03:02 +01:00

254 lines
8.9 KiB
Python

"""Tests for ``EvoScientist._maybe_swap_async_subagents``.
Covers the fallback / swap / strip-internal-flag paths that decide whether
sub-agents are routed in-process (sync ``task`` tool) or to the langgraph
dev subprocess (``AsyncSubAgent`` over HTTP).
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from EvoScientist.EvoScientist import _maybe_swap_async_subagents
def _sub(name: str, *, async_flag: bool, description: str = "desc") -> dict:
"""Build a sub-agent dict shaped like ``utils.load_subagents`` output."""
return {
"name": name,
"description": description,
"system_prompt": "x",
"tools": [],
"_async": async_flag,
}
# =============================================================================
# Async disabled in config → return unchanged
# =============================================================================
def test_returns_unchanged_when_async_disabled_and_strips_flag():
"""No swap when config.enable_async_subagents is False (the default).
Even in this disabled-path, the internal ``_async`` flag must be stripped
before sub-agents reach deepagents (which may schema-validate the dicts).
"""
cfg = SimpleNamespace(enable_async_subagents=False)
subs = [
_sub("planner-agent", async_flag=False),
_sub("writing-agent", async_flag=True),
]
with patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg):
out = _maybe_swap_async_subagents(subs)
assert out is subs
for s in out:
assert "_async" not in s, f"_async leaked into {s['name']}"
# =============================================================================
# Async enabled but langgraph dev unreachable → strip flag, return as sync
# =============================================================================
class TestFallbackPath:
def _setup(self):
return SimpleNamespace(
enable_async_subagents=True,
langgraph_dev_port=6174,
)
def test_returns_subs_unchanged(self):
cfg = self._setup()
subs = [
_sub("planner-agent", async_flag=False),
_sub("writing-agent", async_flag=True),
]
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=False,
),
):
out = _maybe_swap_async_subagents(subs)
assert out is subs
def test_strips_async_flag_from_all_subs(self):
"""Even fallback path must strip _async before deepagents handoff."""
cfg = self._setup()
subs = [
_sub("planner-agent", async_flag=False),
_sub("writing-agent", async_flag=True),
]
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=False,
),
):
out = _maybe_swap_async_subagents(subs)
for s in out:
assert "_async" not in s, f"_async leaked into {s['name']}"
# =============================================================================
# Async enabled + reachable + nothing flagged async → return all as sync (stripped)
# =============================================================================
def test_no_async_flagged_subs_strips_and_returns():
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
subs = [
_sub("planner-agent", async_flag=False),
_sub("research-agent", async_flag=False),
]
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
out = _maybe_swap_async_subagents(subs)
assert out is subs # nothing to swap, returned as-is
for s in out:
assert "_async" not in s
# =============================================================================
# Async enabled + reachable + has async-flagged subs → swap to AsyncSubAgent
# =============================================================================
def test_swaps_async_flagged_subs():
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
subs = [
_sub("planner-agent", async_flag=False, description="plan"),
_sub("writing-agent", async_flag=True, description="write report"),
_sub("data-analysis-agent", async_flag=True, description="analyze"),
]
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
out = _maybe_swap_async_subagents(subs)
assert len(out) == 3
# Sync sub kept as a plain dict, _async stripped.
by_name = {s["name"]: s for s in out}
planner = by_name["planner-agent"]
assert isinstance(planner, dict)
assert "_async" not in planner
# Async subs are AsyncSubAgent specs (TypedDict) pointing at the right URL.
writing = by_name["writing-agent"]
assert writing["graph_id"] == "writing-agent"
assert writing["url"] == "http://localhost:6174"
assert writing["description"] == "write report"
data = by_name["data-analysis-agent"]
assert data["graph_id"] == "data-analysis-agent"
assert data["url"] == "http://localhost:6174"
def test_swap_uses_configured_port():
"""AsyncSubAgent.url should reflect cfg.langgraph_dev_port, not hardcoded."""
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=9999)
subs = [_sub("writing-agent", async_flag=True)]
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
out = _maybe_swap_async_subagents(subs)
assert out[0]["url"] == "http://localhost:9999"
# =============================================================================
# AsyncWatcherMiddleware appended on swap
# =============================================================================
def test_maybe_swap_appends_watcher_middleware_when_enabled():
"""When async subagents are swapped, AsyncWatcherMiddleware must be appended."""
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
middleware: list = []
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
subs = [_sub("writing-agent", async_flag=True)]
_maybe_swap_async_subagents(subs, middleware)
assert len(middleware) == 1
assert isinstance(middleware[0], AsyncWatcherMiddleware)
def test_maybe_swap_skips_middleware_when_no_async_flagged():
"""Without any _async-flagged subagents, no middleware is appended."""
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
middleware: list = []
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
subs = [_sub("planner-agent", async_flag=False)]
_maybe_swap_async_subagents(subs, middleware)
assert middleware == []
def test_maybe_swap_skips_middleware_when_langgraph_unreachable():
"""Fallback path must not append middleware (no watchers will fire)."""
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
middleware: list = []
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=False,
),
):
subs = [_sub("writing-agent", async_flag=True)]
_maybe_swap_async_subagents(subs, middleware)
assert middleware == []
def test_maybe_swap_no_middleware_arg_does_not_crash():
"""Backward-compat: middleware parameter is optional."""
cfg = SimpleNamespace(enable_async_subagents=True, langgraph_dev_port=6174)
with (
patch("EvoScientist.EvoScientist._ensure_config", return_value=cfg),
patch(
"EvoScientist.langgraph_dev.manager.is_async_subagents_available",
return_value=True,
),
):
subs = [_sub("writing-agent", async_flag=True)]
out = _maybe_swap_async_subagents(subs)
assert len(out) == 1
assert out[0]["graph_id"] == "writing-agent"