8b1451cdda
* 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>
236 lines
6.5 KiB
Python
236 lines
6.5 KiB
Python
"""Tests for UI backend runtime selection."""
|
|
|
|
import asyncio
|
|
import threading
|
|
import time
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from EvoScientist.cli.tui_runtime import (
|
|
StreamCancellationTimeout,
|
|
normalize_ui_backend,
|
|
resolve_ui_backend,
|
|
run_streaming,
|
|
run_streaming_async,
|
|
)
|
|
from EvoScientist.runtime import AsyncRuntimeError
|
|
from tests.fakes import FakeGraphGateway
|
|
|
|
|
|
def test_normalize_ui_backend_defaults_to_cli():
|
|
assert normalize_ui_backend(None) == "cli"
|
|
assert normalize_ui_backend("") == "cli"
|
|
|
|
|
|
def test_normalize_ui_backend_accepts_known_values():
|
|
assert normalize_ui_backend("cli") == "cli"
|
|
assert normalize_ui_backend("tui") == "tui"
|
|
assert normalize_ui_backend("TUI") == "tui"
|
|
|
|
|
|
def test_normalize_ui_backend_maps_legacy_values():
|
|
assert normalize_ui_backend("textual") == "tui"
|
|
assert normalize_ui_backend("Textual") == "tui"
|
|
assert normalize_ui_backend("rich") == "cli"
|
|
assert normalize_ui_backend("Rich") == "cli"
|
|
|
|
|
|
def test_normalize_ui_backend_unknown_falls_back_to_cli():
|
|
assert normalize_ui_backend("unknown-ui") == "cli"
|
|
|
|
|
|
def test_resolve_ui_backend_falls_back_when_textual_unavailable(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime._has_textual_support", lambda: False
|
|
)
|
|
assert resolve_ui_backend("tui") == "cli"
|
|
|
|
|
|
def test_resolve_ui_backend_keeps_tui_when_available(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime._has_textual_support", lambda: True
|
|
)
|
|
assert resolve_ui_backend("tui") == "tui"
|
|
|
|
|
|
@dataclass
|
|
class _BrokenBackend:
|
|
name: str = "tui"
|
|
|
|
def run_streaming(self, **kwargs):
|
|
raise RuntimeError("boom")
|
|
|
|
|
|
def test_run_streaming_falls_back_to_cli_on_runtime_error(monkeypatch):
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.get_backend", lambda *a, **k: _BrokenBackend()
|
|
)
|
|
|
|
class _RichStub:
|
|
def run_streaming(self, **kwargs):
|
|
return "fallback-ok"
|
|
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.RichStreamingBackend", lambda: _RichStub()
|
|
)
|
|
|
|
result = run_streaming(
|
|
ui_backend="tui",
|
|
agent=object(),
|
|
message="hello",
|
|
thread_id="t1",
|
|
show_thinking=False,
|
|
interactive=True,
|
|
gateway=FakeGraphGateway(),
|
|
)
|
|
assert result == "fallback-ok"
|
|
|
|
|
|
def test_run_streaming_does_not_retry_on_owned_runtime_error(monkeypatch):
|
|
attempts = 0
|
|
|
|
class _RuntimeFailureBackend:
|
|
def run_streaming(self, **kwargs):
|
|
nonlocal attempts
|
|
attempts += 1
|
|
raise AsyncRuntimeError("owned runtime failed")
|
|
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.get_backend",
|
|
lambda *a, **k: _RuntimeFailureBackend(),
|
|
)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.RichStreamingBackend",
|
|
lambda: _RuntimeFailureBackend(),
|
|
)
|
|
|
|
with pytest.raises(AsyncRuntimeError, match="owned runtime failed"):
|
|
run_streaming(
|
|
ui_backend="tui",
|
|
agent=object(),
|
|
message="hello",
|
|
thread_id="t1",
|
|
show_thinking=False,
|
|
interactive=True,
|
|
gateway=FakeGraphGateway(),
|
|
)
|
|
|
|
assert attempts == 1
|
|
|
|
|
|
async def test_async_streaming_cancellation_stops_and_joins_worker(monkeypatch):
|
|
from EvoScientist.stream.display import (
|
|
discard_stream_cancel,
|
|
is_stream_cancel_requested,
|
|
)
|
|
|
|
scope = "test:async-renderer-cancel"
|
|
started = threading.Event()
|
|
finished = threading.Event()
|
|
|
|
def fake_run_streaming(**kwargs):
|
|
assert kwargs["cancel_scope"] == scope
|
|
started.set()
|
|
while not is_stream_cancel_requested(scope):
|
|
time.sleep(0.001)
|
|
finished.set()
|
|
return "stopped"
|
|
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
|
|
)
|
|
|
|
task = asyncio.create_task(run_streaming_async(cancel_scope=scope))
|
|
assert await asyncio.to_thread(started.wait, 1)
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
|
|
assert finished.is_set()
|
|
discard_stream_cancel(scope)
|
|
|
|
|
|
async def test_async_streaming_can_recover_foreground_task_after_cancel(monkeypatch):
|
|
from EvoScientist.stream.display import (
|
|
discard_stream_cancel,
|
|
is_stream_cancel_requested,
|
|
)
|
|
|
|
scope = "test:async-renderer-recover"
|
|
started = threading.Event()
|
|
|
|
def fake_run_streaming(**kwargs):
|
|
started.set()
|
|
while not is_stream_cancel_requested(scope):
|
|
time.sleep(0.001)
|
|
return "[Stopped.]"
|
|
|
|
cleanup_called = False
|
|
|
|
async def fake_cleanup() -> None:
|
|
nonlocal cleanup_called
|
|
cleanup_called = True
|
|
|
|
monkeypatch.setattr(
|
|
"EvoScientist.cli.tui_runtime.run_streaming", fake_run_streaming
|
|
)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
|
|
fake_cleanup,
|
|
)
|
|
|
|
task = asyncio.create_task(
|
|
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
|
|
)
|
|
assert await asyncio.to_thread(started.wait, 1)
|
|
task.cancel()
|
|
|
|
assert await task == "[Stopped.]"
|
|
assert cleanup_called
|
|
assert not task.cancelled()
|
|
discard_stream_cancel(scope)
|
|
|
|
|
|
async def test_noncooperative_worker_reports_settlement_timeout(monkeypatch):
|
|
"""Cancellation timeout is an ordinary lifecycle error, not BaseException."""
|
|
from EvoScientist.cli import tui_runtime
|
|
from EvoScientist.stream.display import discard_stream_cancel
|
|
|
|
scope = "test:async-renderer-timeout"
|
|
started = threading.Event()
|
|
release = threading.Event()
|
|
finished = threading.Event()
|
|
|
|
def fake_run_streaming(**_kwargs):
|
|
started.set()
|
|
release.wait()
|
|
finished.set()
|
|
return "late"
|
|
|
|
async def fake_cleanup() -> None:
|
|
return None
|
|
|
|
monkeypatch.setattr(tui_runtime, "run_streaming", fake_run_streaming)
|
|
monkeypatch.setattr(tui_runtime, "STREAM_CANCEL_SETTLE_TIMEOUT", 0.05)
|
|
monkeypatch.setattr(
|
|
"EvoScientist.middleware.code_interpreter.aclose_code_interpreters",
|
|
fake_cleanup,
|
|
)
|
|
|
|
task = asyncio.create_task(
|
|
run_streaming_async(cancel_scope=scope, recover_on_cancel=True)
|
|
)
|
|
assert await asyncio.to_thread(started.wait, 1)
|
|
task.cancel()
|
|
|
|
with pytest.raises(StreamCancellationTimeout, match="did not stop"):
|
|
await task
|
|
|
|
assert not finished.is_set()
|
|
release.set()
|
|
assert await asyncio.to_thread(finished.wait, 1)
|
|
await asyncio.sleep(0)
|
|
discard_stream_cancel(scope)
|