Files
EvoScientist/tests/test_event_loop.py
T
dinos bd307f3a11 refactor: LangGraph gateway layer for UI-agnostic graph and thread access (#295)
* feat(gateway): graph gateway protocol

* refactor(cli): wire gateway in cli/tui

* refactor(gateway): centralize runtime gateway init

* chore(gateway): restrict RunRequest message type

* feat(gateway): add langgraph server gateway

* chore(cli): tighten serve runtime state typing

* refactor(cli): route async task state reads through graph gateway

* refactor(gateway): support graph targets in server gateway

* refactor(cli): route session commands through graph gateway

* refactor(cli): fold thread store under graph gateway

* refactor(gateway): route graph state access through gateway

* refactor(channels): wire graph gateway

* refactor(memory): preserve graph threads for cloning

* feat(gateway): add thread cloning

* fix(tui): pass effective workspace for thread creation

* chore(memory): add workspare dir to memory worker metadata

* fix(sessions): filter preloaded UUID registy entries by the current scope

* test(fakes): use https

* refactor(consumer): consolidate imports

* fix(stream): optional summarization event

* fix(gateway): resolve abbreviated thread IDs by search

* fix(gateway): page server thread listings

* fix(gateway): emit pending interrupt events

* style: fmt

* feat(gateway): persist workspace_dir & model in thread metadata

* fix(gateway): page server thread prefix resolution

* fix(gateway): expose server thread list metadata

* refactor: add back type def

* refactor: tighten types

* revert: add back worker thread deletion

The worker thread forking changes are out of scope for now, so to
maintain parity with the existing behavior we'll leave this intact.

* fix(gateway): apply compaction to server thread history

* refactor(stream): restore direct summary replay suppression

* fix(gateway): preserve compaction state and server stream output

* fix(gateway): close local stream generator on cancellation

---------

Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
2026-06-22 13:54:14 +00:00

326 lines
9.9 KiB
Python

"""Tests for event loop management in streaming display."""
import asyncio
from unittest.mock import Mock, patch
import pytest
from EvoScientist.stream.display import _create_event_loop, _get_event_loop
from tests.fakes import FakeGraphGateway
class _TrackingEventLoopPolicy(asyncio.DefaultEventLoopPolicy):
"""Event loop policy that records loops created by one test."""
def __init__(self):
super().__init__()
self.created_loops: list[asyncio.AbstractEventLoop] = []
def new_event_loop(self) -> asyncio.AbstractEventLoop:
loop = super().new_event_loop()
self.created_loops.append(loop)
return loop
@pytest.fixture(autouse=True)
def isolated_event_loop_policy():
previous_policy = asyncio.get_event_loop_policy()
test_policy = _TrackingEventLoopPolicy()
asyncio.set_event_loop_policy(test_policy)
try:
yield
finally:
try:
for loop in test_policy.created_loops:
if not loop.is_closed():
loop.close()
finally:
asyncio.set_event_loop_policy(previous_policy)
class TestCreateEventLoop:
"""Tests for _create_event_loop helper."""
def test_creates_new_loop(self):
"""Should create a new event loop and set it as current."""
# Get initial loop (if any)
try:
initial_loop = asyncio.get_event_loop()
initial_loop.close()
except RuntimeError:
pass
# Create new loop
loop = _create_event_loop()
assert loop is not None
assert not loop.is_closed()
assert asyncio.get_event_loop() is loop
# Cleanup
loop.close()
def test_replaces_closed_loop(self):
"""Should replace a closed loop."""
old_loop = asyncio.new_event_loop()
asyncio.set_event_loop(old_loop)
old_loop.close()
new_loop = _create_event_loop()
assert new_loop is not old_loop
assert not new_loop.is_closed()
assert asyncio.get_event_loop() is new_loop
# Cleanup
new_loop.close()
class TestGetEventLoop:
"""Tests for _get_event_loop helper."""
def test_returns_existing_open_loop(self):
"""Should return existing event loop if it's open."""
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
result = _get_event_loop()
assert result is loop
assert not result.is_closed()
# Cleanup
loop.close()
def test_creates_new_loop_when_closed(self):
"""Should create new event loop if current one is closed."""
old_loop = asyncio.new_event_loop()
asyncio.set_event_loop(old_loop)
old_loop.close()
result = _get_event_loop()
assert result is not old_loop
assert not result.is_closed()
# Cleanup
result.close()
def test_handles_no_event_loop(self):
"""Should handle RuntimeError when no event loop exists (edge case)."""
# This test simulates what happens in a worker thread
# In practice, get_event_loop() returns a closed loop, not RuntimeError
# But we handle the RuntimeError case defensively
loop = _get_event_loop()
assert loop is not None
assert not loop.is_closed()
# Cleanup
loop.close()
class TestMultipleStreamingCalls:
"""Tests for the main bug fix: multiple _run_streaming calls."""
def test_sequential_streaming_calls(self):
"""Multiple sequential calls should work without 'Event loop is closed' error."""
from EvoScientist.stream.display import _run_streaming
# Mock agent that returns simple events
mock_agent = Mock()
async def mock_stream(_request):
"""Mock event stream."""
yield {"type": "text", "content": "test response"}
yield {"type": "done", "response": "test response"}
# Clean up any existing event loop to start fresh
try:
existing_loop = asyncio.get_event_loop()
if not existing_loop.is_closed():
existing_loop.close()
except RuntimeError:
pass
gateway = FakeGraphGateway(stream=mock_stream)
# Patch Live to avoid terminal output during tests
with patch("EvoScientist.stream.display.Live"):
# First call
_run_streaming(
agent=mock_agent,
message="test message 1",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=gateway,
)
# Second call - this would fail with "Event loop is closed" before the fix
_run_streaming(
agent=mock_agent,
message="test message 2",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=gateway,
)
# Third call for good measure
_run_streaming(
agent=mock_agent,
message="test message 3",
thread_id="thread1",
show_thinking=False,
interactive=True,
gateway=gateway,
)
def test_loop_reused_across_calls(self):
"""Event loop should be reused across multiple calls."""
# Create a fresh loop
loop = _create_event_loop()
# Simulate multiple calls
for _ in range(3):
current_loop = _get_event_loop()
assert not current_loop.is_closed()
# Run a simple coroutine
async def dummy():
return "ok"
result = current_loop.run_until_complete(dummy())
assert result == "ok"
# Loop should still be open
assert not loop.is_closed()
# Cleanup
loop.close()
def test_closed_loop_recovery(self):
"""If loop gets closed, next call should create a new one."""
# Create and close a loop
loop1 = _create_event_loop()
loop1.close()
# Next call should detect closed loop and create new one
loop2 = _get_event_loop()
assert loop2 is not loop1
assert not loop2.is_closed()
# Should be able to use the new loop
async def dummy():
return "success"
result = loop2.run_until_complete(dummy())
assert result == "success"
# Cleanup
loop2.close()
def test_recursive_streaming_does_not_resend_same_thinking(self):
"""Resumed runs should not replay the original thinking to channels."""
from EvoScientist.stream.display import _run_streaming
mock_agent = Mock()
thinking = "Initial plan. " * 20
stream_calls = 0
async def mock_stream(_request):
nonlocal stream_calls
stream_calls += 1
if stream_calls == 1:
yield {"type": "thinking", "content": thinking}
yield {
"type": "ask_user",
"interrupt_id": "ask-1",
"tool_call_id": "tc-1",
"questions": [{"question": "Continue?"}],
}
return
yield {"type": "text", "content": "final answer"}
yield {"type": "done", "response": "final answer"}
sent_thinking: list[str] = []
with patch("EvoScientist.stream.display.Live"):
result = _run_streaming(
agent=mock_agent,
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=mock_stream),
)
assert result == "final answer"
assert sent_thinking == [thinking.rstrip()]
def test_recursive_streaming_sends_new_thinking_after_resume(self):
"""Genuinely new thinking in resumed rounds should be relayed."""
from EvoScientist.stream.display import _run_streaming
mock_agent = Mock()
thinking_r1 = "Initial plan. " * 20
thinking_r2 = "Revised plan. " * 20
stream_calls = 0
async def mock_stream(_request):
nonlocal stream_calls
stream_calls += 1
if stream_calls == 1:
yield {"type": "thinking", "content": thinking_r1}
yield {
"type": "ask_user",
"interrupt_id": "ask-1",
"tool_call_id": "tc-1",
"questions": [{"question": "Continue?"}],
}
return
yield {"type": "thinking", "content": thinking_r2}
yield {"type": "text", "content": "final answer"}
yield {"type": "done", "response": "final answer"}
sent_thinking: list[str] = []
with patch("EvoScientist.stream.display.Live"):
result = _run_streaming(
agent=mock_agent,
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=mock_stream),
)
assert result == "final answer"
assert sent_thinking == [thinking_r1.rstrip(), thinking_r2.rstrip()]
class TestEventLoopThreadSafety:
"""Tests for thread safety edge cases."""
def test_main_thread_normal_case(self):
"""Normal case in main thread should work."""
loop = _get_event_loop()
assert loop is not None
assert not loop.is_closed()
# Cleanup
loop.close()