Files
EvoScientist-Multi/tests/test_stream_recovery.py
T
dinos 690b903f85 test: standardize async tests on pytest-asyncio auto mode (#338)
* 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).
2026-07-08 18:37:48 +00:00

94 lines
2.9 KiB
Python

"""Targeted tests for interrupted-graph-state recovery.
These run against a real compiled LangGraph graph with a checkpointer,
so they actually verify the two claims the recovery rests on:
1. After a mid-run crash, ``aupdate_state(config, None, as_node=END)`` clears the
stuck ``next`` tuple while preserving channel values.
2. A legitimate human-in-the-loop ``interrupt()`` (also a non-empty ``next``) is
left intact, so a pending question is never silently discarded.
"""
from typing import TypedDict
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.graph import END, START, StateGraph
from langgraph.types import interrupt
from EvoScientist.stream.events import _clear_interrupted_graph_state
class _S(TypedDict):
x: int
def _crashing_app():
# Node 'b' crashes once, then succeeds — so a post-recovery run can complete
# and prove the graph is genuinely unstuck (not replaying the dead step).
crashed = {"v": False}
def a(state):
return {"x": state["x"] + 1}
def b(state):
if not crashed["v"]:
crashed["v"] = True
raise RuntimeError("boom")
return {"x": state["x"] + 100}
g = StateGraph(_S)
g.add_node("a", a)
g.add_node("b", b)
g.add_edge(START, "a")
g.add_edge("a", "b")
g.add_edge("b", END)
return g.compile(checkpointer=InMemorySaver())
def _interrupting_app():
def ask(state):
interrupt({"question": "continue?"})
return {"x": state["x"] + 1}
g = StateGraph(_S)
g.add_node("ask", ask)
g.add_edge(START, "ask")
g.add_edge("ask", END)
return g.compile(checkpointer=InMemorySaver())
async def test_recovery_clears_stuck_state_after_crash():
app = _crashing_app()
cfg = {"configurable": {"thread_id": "t1"}}
try:
app.invoke({"x": 0}, cfg)
except Exception:
pass # LangGraph re-raises the node error (wrapped); we only care about state
# The crash left the graph frozen at node 'b'.
assert app.get_state(cfg).next == ("b",)
await _clear_interrupted_graph_state(app, cfg)
snap = app.get_state(cfg)
assert snap.next == () # stuck state actually cleared
assert snap.values == {"x": 1} # channel values (history) preserved
# And the graph is genuinely unstuck: a fresh run completes (a: +1, b: +100)
# instead of replaying the dead node.
assert app.invoke({"x": 41}, cfg)["x"] == 142
async def test_recovery_preserves_pending_hitl_interrupt():
app = _interrupting_app()
cfg = {"configurable": {"thread_id": "t1"}}
app.invoke({"x": 0}, cfg) # parks at interrupt()
before = app.get_state(cfg)
assert before.next == ("ask",)
assert before.interrupts
await _clear_interrupted_graph_state(app, cfg)
after = app.get_state(cfg)
assert after.next == ("ask",) # interrupt left intact, still resumable
assert after.interrupts