Files
EvoScientist-Multi/tests/test_async_watcher_middleware.py
T
dinos 01845f4311 refactor: route middleware display events through an injected event sink (#343)
* chore: add pytest-asyncio in auto mode

* test: migrate channel and stream tests to native async

Convert run_async() wrapper tests to plain 'async def test_*' under
pytest-asyncio auto mode. collect_events() in stream_v3_fakes becomes a
coroutine awaited at every call site.

* test: migrate command and model/middleware tests to native async

Convert run_async() wrappers (import, alias, and fixture forms) to plain
'async def test_*'. Multi-call tests merge onto one loop as sequential
awaits; none asserted on loop identity.

* test: migrate TUI, notifier, gateway, and session tests to native async

TUI/notifier/gateway files convert run_async wrappers to plain async
tests. test_sessions.py's unittest.TestCase classes move to
unittest.IsolatedAsyncioTestCase (pytest-asyncio does not await async
methods on plain TestCase; converting blindly would have made ~70 tests
silently vacuous). Its setUpClass keeps a one-shot asyncio.run() since
IsolatedAsyncioTestCase has no async class-level hook. TestLoadingWidget
in test_tui_widgets.py drops its TestCase base for the same reason.

* test: replace direct asyncio.run() calls with native async tests

Convert tests that called asyncio.run() (directly or via a local _run
helper) to plain 'async def test_*'; delete the local helpers.

* test: drop undeclared anyio markers and delete run_async helper

The @pytest.mark.anyio tests relied on anyio being a transitive dep of
httpx; auto-mode pytest-asyncio collects them natively. run_async() and
its fixture are unreferenced after the migration, so remove them —
pytest-asyncio's per-test loop teardown covers the pending-task
cancellation the helper existed for (verified: full suite runs with no
'Event loop is closed' errors or destroyed-task warnings).

* test: add autouse fixture for watcher cleanup

* refactor: remove redundant hasattr calls

* refactor: add typed middleware event sink and thread through assembly

Add MiddlewareEventSink protocol + NoOpSink in middleware/events.py
with a documented any-thread non-blocking contract (contract test uses a
deliberately-slow fake sink). Thread an optional `events` parameter
through create_cli_agent -> _get_default_middleware -> tool selector /
model fallback constructors; subagent stacks are always forced to
NoOpSink.

* refactor: inject a notifier port into async-watcher and background middleware

Add public pre_cancel_watcher() and enqueue_task_notification() to
cli/async_notifier.py and a small NotifierPort protocol
(middleware/notifier.py) that the module satisfies structurally.
AsyncWatcherMiddleware and BackgroundExecutionMiddleware now receive the
port by constructor injection at the composition root, deleting the lazy
'from ..cli import async_notifier' imports and the private
_watcher_by_thread / _enqueue pokes.

* refactor: invert tool-selection ownership onto a frontend event sink

The adaptive tool selector now reports on_tool_selection_started /
on_tool_selection / on_tool_selection_ended to the injected sink instead
of writing four process-global module variables. The frontend sink
(stream/sink.py FrontendEventSink) owns the selected/total/active state
with consume-once + dedup-vs-last-emitted semantics;
stream/tool_selection.py reads that sink object (a ToolSelectionView)
rather than reaching into tool_selector's globals.

Deleted: the 4 module globals, the cross-module mutations in
tool_selection.py, the track_stream_selection flag, the now-vestigial
_ToolSelectionTrackerMiddleware, reset_tool_selection_state_for_tests,
and the autouse conftest fixture. The sink is threaded from the two
interactive frontends through create_runtime_gateways ->
LocalGraphGateway (read side) and _load_agent -> create_cli_agent (write
side); subagent / headless stacks get NoOpSink.

* refactor: route model-fallback narration through the injected event sink

Delete the _ui_emit_fn / set_ui_emit module global and the
..stream.console import from model_fallback.py. The fallback middleware
now reports through its injected sink: the fallback transition via the
structured on_model_fallback (the frontend formats the '-> Falling back
to ...' line), and the surrounding narration (primary-failure header,
per-attempt outcome, exhaustion, non-fallbackable rejection) via
emit_fallback_notice, preserving the exact user-facing text. The TUI
binds its _append_system as the sink's fallback display where it used to
call set_ui_emit (cleared on exit); the Rich CLI's sink prints to the
console. _try_fallbacks / _guard_and_fallback take the sink.

* refactor: declare events on the GraphGateway protocol

Both gateway implementations now carry an explicit events attribute
(LangGraphServerGateway holds None — no frontend renders middleware
events across the HTTP boundary), so the four call sites use plain
attribute access instead of getattr probing an implicit contract.

* refactor: bind fallback display via the closure-scoped concrete sink

The App methods used gateway.events (typed as the read-side view) and
hasattr-probed for the concrete FrontendEventSink API. The enclosing
factory creates that sink two hundred lines up — close over it directly:
no probing, fully typed, and it becomes a constructor parameter
naturally when the App class is hoisted out of the factory.

* fix: end tool selection before fallback handler

* fix: keep fallback display errors non-fatal

* fix: preserve selector suppression for default streams

* fix: restore fallback notice console display

* refactor: consolidate fallback narration events

* refactor: clean middleware event sink plumbing

* fix: type gateway session events

* refactor: make all event protocols runtime-checkable

MiddlewareEventSink already carried @runtime_checkable (the stream
binding guard isinstance-checks it); ToolSelectionView and SessionEvents
now match, so mirroring that pattern against any of the three protocols
works instead of raising TypeError.

* fix(cli): close QuickJS workers after one-shot failures

* fix(cli): honor no-thinking in final output

* fix(channels): report failed startup accurately

* fix(channels): make Telegram cleanup idempotent

* fix(tui): skip command sync during exit

* fix(channels): preserve startup state during retries

* refactor(channels): share pending startup status

* refactor(cli): expose channel startup snapshot

* fix(tui): move channel startup off event loop

* test(channels): release retry gate on assertion failure

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-07-14 22:34:17 +00:00

470 lines
14 KiB
Python

"""Tests for ``EvoScientist.middleware.async_watcher.AsyncWatcherMiddleware``.
The middleware is the public-API replacement for the old monkey-patch on
deepagents internals. It hooks into ``awrap_tool_call`` and only fires on
``start_async_task`` / ``update_async_task`` tool invocations.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from EvoScientist.cli import async_notifier
def _drain_all_notifications():
"""Drain every routed/unrouted/legacy queue between tests."""
if hasattr(async_notifier, "_notifications_by_thread"):
for q in list(async_notifier._notifications_by_thread.values()):
while not q.empty():
try:
q.get_nowait()
except Exception:
break
for attr in ("_unrouted_queue", "_notification_queue"):
if hasattr(async_notifier, attr):
q = getattr(async_notifier, attr)
while not q.empty():
try:
q.get_nowait()
except Exception:
break
@pytest.fixture(autouse=True)
def _clean_notifier_state():
"""Reset shared module-level notifier state before and after every test.
Cleared here:
- All notification queues (per-thread, unrouted, legacy global)
- ``_watcher_by_thread`` (replacement-on-update registry)
- ``_active_watchers`` (in-flight watcher → origin-thread index used
by ``consume_notifications`` to gate the grace-window wait)
Without this, a test that touches any of these dicts/queues would
silently leak state into the next test. ``_active_watchers`` is cleared
even though current tests patch ``spawn_watcher`` (so they never insert
into it) — kept as a safeguard for future tests that exercise the real
spawn path.
"""
_drain_all_notifications()
async_notifier._watcher_by_thread.clear()
async_notifier._active_watchers.clear()
yield
_drain_all_notifications()
async_notifier._watcher_by_thread.clear()
async_notifier._active_watchers.clear()
def _build_request(tool_name: str, args: dict, *, thread_id: str | None = None):
"""Construct a minimal ToolCallRequest stand-in.
The middleware reads only ``request.tool_call`` and ``request.runtime``.
"""
runtime = SimpleNamespace(
config={"configurable": {"thread_id": thread_id}} if thread_id else {}
)
return SimpleNamespace(
tool_call={
"name": tool_name,
"args": args,
"id": "call-1",
"type": "tool_call",
},
runtime=runtime,
state={},
tool=None,
)
def _make_middleware():
"""Build an AsyncWatcherMiddleware with a stubbed ``_ClientCache``."""
from EvoScientist.middleware.async_watcher import AsyncWatcherMiddleware
fake_client = MagicMock(name="LangGraphClient")
fake_cache = MagicMock(name="ClientCache")
fake_cache.get_async.return_value = fake_client
with patch(
"deepagents.middleware.async_subagents._ClientCache",
return_value=fake_cache,
):
mw = AsyncWatcherMiddleware(
{
"writing-agent": {
"name": "writing-agent",
"url": "http://x",
"graph_id": "writing-agent",
}
},
notifier=async_notifier,
)
return mw, fake_client
async def test_middleware_spawns_watcher_on_start_async_task():
"""A successful start_async_task tool call must spawn one watcher per task."""
from langgraph.types import Command
mw, _fake_client = _make_middleware()
spawn_calls = []
def fake_spawn(
client, thread_id, run_id, agent_name, prompt="", origin_cli_thread_id=None
):
spawn_calls.append(
(thread_id, run_id, agent_name, prompt, origin_cli_thread_id)
)
request = _build_request(
"start_async_task",
{"description": "do thing", "subagent_type": "writing-agent"},
thread_id="cli-thread-A",
)
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"task-1": {
"task_id": "task-1",
"agent_name": "writing-agent",
"run_id": "run-1",
"thread_id": "task-1",
"status": "running",
}
}
}
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
result = await mw.awrap_tool_call(request, fake_handler)
assert isinstance(result, Command)
assert spawn_calls == [
("task-1", "run-1", "writing-agent", "do thing", "cli-thread-A")
]
async def test_middleware_spawns_watcher_on_update_async_task():
"""A successful update_async_task call must also spawn a (replacement) watcher."""
from langgraph.types import Command
mw, _ = _make_middleware()
spawn_calls = []
def fake_spawn(*args, **kwargs):
spawn_calls.append((args, kwargs))
request = _build_request(
"update_async_task",
{"task_id": "task-1", "message": "do more"},
thread_id="cli-thread-A",
)
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"task-1": {
"task_id": "task-1",
"agent_name": "writing-agent",
"run_id": "run-2",
"thread_id": "task-1",
"status": "running",
}
}
}
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
assert len(spawn_calls) == 1
args, kwargs = spawn_calls[0]
# spawn_watcher(client, task_id, run_id, agent_name, prompt=..., origin_cli_thread_id=...)
assert args[1] == "task-1"
assert args[2] == "run-2"
assert args[3] == "writing-agent"
assert kwargs["prompt"] == "do more"
assert kwargs["origin_cli_thread_id"] == "cli-thread-A"
async def test_middleware_pre_cancels_old_watcher_on_update():
"""update_async_task must cancel the existing watcher BEFORE invoking the handler.
Otherwise the new run interrupts the old run's stream, which closes
cleanly, and the old watcher would enqueue a stale "success" notification.
"""
from langgraph.types import Command
mw, _ = _make_middleware()
old_watcher = MagicMock()
old_watcher.done.return_value = False
async_notifier._watcher_by_thread["task-1"] = old_watcher
cancel_observed_before_handler = {"value": False}
async def fake_handler(req):
cancel_observed_before_handler["value"] = old_watcher.cancel.called
return Command(update={"async_tasks": {}})
request = _build_request(
"update_async_task", {"task_id": "task-1", "message": "x"}, thread_id="t"
)
try:
with patch.object(async_notifier, "spawn_watcher"):
await mw.awrap_tool_call(request, fake_handler)
finally:
async_notifier._watcher_by_thread.pop("task-1", None)
assert cancel_observed_before_handler["value"] is True
async def test_middleware_passes_through_unrelated_tools():
"""A non-launch tool call must not spawn any watcher and must return result unchanged."""
mw, _ = _make_middleware()
sentinel = object()
async def fake_handler(req):
return sentinel
request = _build_request("ls", {"path": "/"}, thread_id="t")
with patch.object(async_notifier, "spawn_watcher") as mock_spawn:
result = await mw.awrap_tool_call(request, fake_handler)
assert result is sentinel
assert mock_spawn.call_count == 0
async def test_middleware_handles_non_command_results_gracefully():
"""If the launch tool returns a string (validation error), no watcher is spawned."""
mw, _ = _make_middleware()
async def fake_handler(req):
return "Unknown async subagent type `bogus`"
request = _build_request(
"start_async_task",
{"description": "x", "subagent_type": "bogus"},
thread_id="t",
)
with patch.object(async_notifier, "spawn_watcher") as mock_spawn:
result = await mw.awrap_tool_call(request, fake_handler)
assert result == "Unknown async subagent type `bogus`"
assert mock_spawn.call_count == 0
async def test_middleware_origin_thread_id_is_none_when_runtime_config_missing():
"""When runtime.config is empty, origin_cli_thread_id must be None (not crash)."""
from langgraph.types import Command
mw, _ = _make_middleware()
captured = {}
def fake_spawn(*args, **kwargs):
captured.update(kwargs)
request = _build_request(
"start_async_task",
{"description": "x", "subagent_type": "writing-agent"},
thread_id=None,
)
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"t1": {
"task_id": "t1",
"agent_name": "writing-agent",
"run_id": "r1",
"thread_id": "t1",
"status": "running",
}
}
}
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
assert captured.get("origin_cli_thread_id") is None
async def test_middleware_swallows_spawn_exceptions():
"""spawn_watcher errors must not propagate up — middleware logs and continues."""
from langgraph.types import Command
mw, _ = _make_middleware()
def boom(*a, **kw):
raise RuntimeError("intentional")
request = _build_request(
"start_async_task",
{"description": "x", "subagent_type": "writing-agent"},
thread_id="t",
)
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"t1": {
"task_id": "t1",
"agent_name": "writing-agent",
"run_id": "r1",
"thread_id": "t1",
"status": "running",
}
}
}
)
with patch.object(async_notifier, "spawn_watcher", side_effect=boom):
# Should not raise.
result = await mw.awrap_tool_call(request, fake_handler)
assert isinstance(result, Command)
@pytest.mark.parametrize(
("tool_name", "args", "prompt_field"),
[
(
"start_async_task",
{"description": "from start", "subagent_type": "writing-agent"},
"from start",
),
(
"update_async_task",
{"task_id": "t1", "message": "from update"},
"from update",
),
],
)
async def test_middleware_picks_correct_prompt_field_per_tool(
tool_name, args, prompt_field
):
"""start_async_task uses 'description'; update_async_task uses 'message'."""
from langgraph.types import Command
mw, _ = _make_middleware()
captured_prompt = {}
def fake_spawn(*a, prompt="", **kw):
captured_prompt["value"] = prompt
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"t1": {
"task_id": "t1",
"agent_name": "writing-agent",
"run_id": "r1",
"thread_id": "t1",
"status": "running",
}
}
}
)
request = _build_request(tool_name, args, thread_id="t")
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
assert captured_prompt["value"] == prompt_field
async def test_middleware_prompt_field_is_tool_name_gated_not_fallback_chained():
"""update_async_task with extra `description` arg must still use `message`.
Guards against the previous `args.get('description') or args.get('message')`
chained-fallback shape, which would have picked `description` for an update
call that happened to carry both fields.
"""
from langgraph.types import Command
mw, _ = _make_middleware()
captured_prompt = {}
def fake_spawn(*a, prompt="", **kw):
captured_prompt["value"] = prompt
async def fake_handler(req):
return Command(
update={
"async_tasks": {
"t1": {
"task_id": "t1",
"agent_name": "writing-agent",
"run_id": "r1",
"thread_id": "t1",
"status": "running",
}
}
}
)
request = _build_request(
"update_async_task",
{
"task_id": "t1",
"message": "use this",
"description": "do NOT use this",
},
thread_id="t",
)
with patch.object(async_notifier, "spawn_watcher", side_effect=fake_spawn):
await mw.awrap_tool_call(request, fake_handler)
assert captured_prompt["value"] == "use this"
async def test_middleware_pre_cancel_swallows_unexpected_errors():
"""A faulty old-watcher handle must not block the handler from running."""
from langgraph.types import Command
mw, _ = _make_middleware()
bad_watcher = MagicMock()
bad_watcher.done.side_effect = RuntimeError("watcher state corrupted")
async_notifier._watcher_by_thread["t1"] = bad_watcher
handler_called = {"value": False}
async def fake_handler(req):
handler_called["value"] = True
return Command(update={"async_tasks": {}})
request = _build_request(
"update_async_task", {"task_id": "t1", "message": "x"}, thread_id="t"
)
try:
with patch.object(async_notifier, "spawn_watcher"):
# Should not raise.
await mw.awrap_tool_call(request, fake_handler)
finally:
async_notifier._watcher_by_thread.pop("t1", None)
assert handler_called["value"] is True