Files
EvoScientist-Multi/tests/test_event_loop.py
T
dinos 8b1451cdda refactor(runtime): centralize async bridges under an owned runtime (#376)
* feat(runtime): add application-scoped async runtime

* refactor(cli): use owned runtime for session stats

* refactor(onboard): use the owned async runtime

* docs(runtime): record async bridge ownership

* refactor(middleware): keep sync fallback synchronous

* refactor(mcp): load tools on an owned runtime

* refactor(cli): share owned runtime across entry points

* refactor(channels): make inbound sync bridge explicit

* refactor(stream): run Rich streaming on owned runtime

* chore(runtime): remove nest-asyncio dependency

* refactor(asyncio): require active loops in async code

* docs(runtime): document final event loop ownership

* fix(stream): cancel stalled owned streams

* fix(cli): recover cleanly from stream cancellation

* fix(runtime): drain executor work before shutdown

* fix(runtime): terminate cancelled shell process trees

* fix(models): let fallback bypass selector failures

* fix(cli): reset interrupt handling between turns

* docs: rm implementation spec

* fix(serve): cancel active turns during shutdown

* fix(runtime): protect settlement from waiter cancellation

* fix(backends): reject empty shell commands

* fix(runtime): terminate descendants after shell exit

* fix(mcp): keep standalone discovery off channel loop

* fix(cli): own and settle interactive prompt cancellation

* fix(serve): keep channel sends off runtime loop

* fix(stream): scope cancel context to iterator steps

* refactor(serve): require the owned async runtime

* fix(channels): keep interactive sends off runtime loop

* fix(selector): surface fallback without log spam

* test(runtime): normalize Windows shell marker

* fix(cli): serialize interactive session turns

* fix(shell): bound output drain after termination

* fix(ui): do not retry owned runtime failures

* fix(shell): allow signal-safe registry reentry

* fix(shell): avoid terminating reused process ids

* fix(channels): preserve streaming send order

* fix(cli): report runtime shutdown timeouts cleanly

* fix(mcp): guide async callers to async loader

* docs(runtime): clarify reserved async bridge APIs

* fix(runtime): bound code interpreter cleanup

* test(shell): use active Python for drain regression

---------

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

150 lines
4.7 KiB
Python

"""Owned-runtime tests for the synchronous Rich streaming adapter."""
import asyncio
import threading
from unittest.mock import Mock, patch
import pytest
from EvoScientist.runtime import AsyncRuntime, AsyncRuntimeError
from EvoScientist.stream.display import _run_streaming
from tests.fakes import FakeGraphGateway
def _text_stream(loops, response="test response"):
async def _stream(_request):
loops.append(asyncio.get_running_loop())
yield {"type": "text", "content": response}
yield {"type": "done", "response": response}
return _stream
def test_sequential_streams_reuse_application_runtime_loop():
loops: list[asyncio.AbstractEventLoop] = []
gateway = FakeGraphGateway(stream=_text_stream(loops))
with (
AsyncRuntime(thread_name="test-stream-runtime") as runtime,
patch("EvoScientist.stream.display.Live"),
):
for index in range(3):
result = _run_streaming(
agent=Mock(),
message=f"message {index}",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=gateway,
runtime=runtime,
)
assert result == "test response"
assert len(loops) == 3
assert loops[0] is loops[1] is loops[2]
def test_direct_streaming_call_scopes_and_closes_runtime():
execution: dict[str, object] = {}
async def stream(_request):
execution["thread"] = threading.current_thread().name
execution["loop"] = asyncio.get_running_loop()
yield {"type": "done", "response": "ok"}
with patch("EvoScientist.stream.display.Live"):
result = _run_streaming(
agent=Mock(),
message="message",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=FakeGraphGateway(stream=stream),
)
assert result == "ok"
assert execution["thread"] == "evosci-stream-runtime"
assert isinstance(execution["loop"], asyncio.AbstractEventLoop)
assert not any(
thread.name == "evosci-stream-runtime" and thread.is_alive()
for thread in threading.enumerate()
)
async def test_async_caller_must_offload_synchronous_renderer():
gateway = FakeGraphGateway(stream=_text_stream([]))
with (
AsyncRuntime(thread_name="test-stream-runtime") as runtime,
patch("EvoScientist.stream.display.Live"),
pytest.raises(AsyncRuntimeError, match="running event loop"),
):
_run_streaming(
agent=Mock(),
message="message",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=gateway,
runtime=runtime,
)
@pytest.mark.parametrize(
("second_thinking", "expected_count"),
[(None, 1), ("Revised plan. " * 20, 2)],
)
def test_recursive_streaming_reuses_runtime_and_deduplicates_thinking(
second_thinking, expected_count
):
initial_thinking = "Initial plan. " * 20
stream_calls = 0
loops: list[asyncio.AbstractEventLoop] = []
async def stream(_request):
nonlocal stream_calls
loops.append(asyncio.get_running_loop())
stream_calls += 1
if stream_calls == 1:
yield {"type": "thinking", "content": initial_thinking}
yield {
"type": "ask_user",
"interrupt_id": "ask-1",
"tool_call_id": "tc-1",
"questions": [{"question": "Continue?"}],
}
return
if second_thinking is not None:
yield {"type": "thinking", "content": second_thinking}
else:
yield {"type": "thinking", "content": initial_thinking}
yield {"type": "text", "content": "final answer"}
yield {"type": "done", "response": "final answer"}
sent_thinking: list[str] = []
with (
AsyncRuntime(thread_name="test-stream-runtime") as runtime,
patch("EvoScientist.stream.display.Live"),
):
result = _run_streaming(
agent=Mock(),
message="test message",
thread_id="thread1",
show_thinking=False,
interactive=True,
on_thinking=sent_thinking.append,
ask_user_prompt_fn=lambda _data: {
"answers": ["yes"],
"status": "answered",
},
gateway=FakeGraphGateway(stream=stream),
runtime=runtime,
)
assert result == "final answer"
assert len(sent_thinking) == expected_count
assert sent_thinking[0] == initial_thinking.rstrip()
if second_thinking is not None:
assert sent_thinking[1] == second_thinking.rstrip()
assert loops[0] is loops[1]