import asyncio import concurrent.futures import threading from unittest.mock import AsyncMock, MagicMock, patch from behave import given, then, when from rx.scheduler.eventloop import AsyncIOScheduler from cleveragents.langgraph.graph import ( MAX_EXECUTION_HISTORY, GraphConfig, LangGraph, ) from cleveragents.langgraph.nodes import Edge, NodeConfig, NodeType from cleveragents.langgraph.state import GraphState, StateManager from cleveragents.reactive.stream_router import StreamMessage # Existing steps cover constructor scheduling, execution dispatch, start validation, # cycle detection, and node executor state updates. @given("a langgraph config with layered nodes") def step_layered_config(context): nodes = { "a": NodeConfig(name="a", type=NodeType.FUNCTION), "b": NodeConfig(name="b", type=NodeType.FUNCTION), "c": NodeConfig(name="c", type=NodeType.FUNCTION), } edges = [Edge(source="a", target="b"), Edge(source="b", target="c")] context.graph = _fresh_graph( GraphConfig(name="graph-levels", nodes=nodes, edges=edges) ) @when("I compute the topological levels") def step_compute_levels(context): context.levels = context.graph._topological_levels() # pylint: disable=protected-access @then("the levels should list each layer") def step_assert_levels(context): assert context.levels[0] == {"a"} assert context.levels[1] == {"b"} assert context.levels[2] == {"c"} @given("a langgraph instance with recorded history") def step_history_instance(context): config = GraphConfig(name="graph-history") context.graph = _fresh_graph(config) context.graph.execution_history.extend(["x", "y"]) @when("I request execution history") def step_get_history(context): context.history_result = context.graph.get_execution_history() @then("it should return a copy of the recorded history") def step_assert_history_copy(context): assert context.history_result == ["x", "y"] assert context.history_result is not context.graph.execution_history @given("a langgraph config with explicit start and end nodes") def step_explicit_guards(context): start = NodeConfig(name="start", type=NodeType.START, metadata={"guard": True}) end = NodeConfig(name="end", type=NodeType.END, metadata={"guard": True}) nodes = { "start": start, "end": end, "worker": NodeConfig(name="worker", type=NodeType.FUNCTION), } context.graph = _fresh_graph(GraphConfig(name="graph-guards", nodes=nodes)) @when("I construct the LangGraph with that config") def step_construct_with_guards(_context): # Construction already performed in the given step return @then("initialization should reuse the provided start and end nodes") def step_assert_guard_reuse(context): assert context.graph.nodes["start"].config is context.graph.config.nodes["start"] assert context.graph.nodes["end"].config is context.graph.config.nodes["end"] @given("a langgraph instance prepared to observe node streams") def step_prepare_node_stream(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-observe", nodes=nodes)) context.worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] context.graph.logger = MagicMock() @when("the node stream emits next error and completion events") def step_emit_stream_events(context): context.worker_stream.on_next("payload") context.worker_stream.on_error(ValueError("boom")) context.worker_stream.on_completed() @then("the subscription handlers should be exercised") def step_assert_stream_handlers(context): context.graph.logger.error.assert_called_once() @given("a langgraph config with converging branches") def step_converging_config(context): nodes = { "a": NodeConfig(name="a", type=NodeType.FUNCTION), "b": NodeConfig(name="b", type=NodeType.FUNCTION), "c": NodeConfig(name="c", type=NodeType.FUNCTION), } edges = [ Edge(source="start", target="a"), Edge(source="start", target="b"), Edge(source="a", target="c"), Edge(source="b", target="c"), Edge(source="c", target="end"), ] context.graph = _fresh_graph( GraphConfig(name="graph-parallel", nodes=nodes, edges=edges) ) @when("I construct the LangGraph for parallel groups") def step_construct_parallel(_context): return @then("parallel groups should reflect staged execution order") def step_assert_parallel_groups(context): assert context.graph.parallel_groups[0] == {"start"} assert context.graph.parallel_groups[1] == {"a", "b"} assert context.graph.parallel_groups[2] == {"c"} assert context.graph.parallel_groups[3] == {"end"} @given("a langgraph instance missing required guard nodes") def step_missing_guard_nodes(context): config = GraphConfig(name="graph-missing-guards") context.graph = _fresh_graph(config) context.graph.nodes.pop("start", None) @when("I validate the graph explicitly") def step_validate_graph(context): context.validation_error = None try: context.graph._validate_graph() # pylint: disable=protected-access except Exception as exc: # pylint: disable=broad-except context.validation_error = exc @then("a guard validation error should be raised") def step_assert_guard_validation(context): assert isinstance(context.validation_error, ValueError) @given("a langgraph instance ready to start") def step_ready_to_start(context): config = GraphConfig(name="graph-start") context.graph = _fresh_graph(config) context.start_stream = f"__{context.graph.name}_node_start__" context.graph.stream_router.send_message = MagicMock() @when("I start without input then stop and fetch state") def step_start_and_stop(context): context.graph.start() context.graph.stop() context.final_state = context.graph.get_state() @then("it should use default state and update running flags") def step_assert_start_stop(context): context.graph.stream_router.send_message.assert_called_with( context.start_stream, GraphState() ) assert context.graph.is_running is False assert isinstance(context.final_state, GraphState) def _fresh_graph(config: GraphConfig) -> LangGraph: """Construct a LangGraph with isolated scheduler and router.""" return LangGraph(config=config, stream_router=None, scheduler=None) @given("a langgraph config with only start and end nodes") def step_minimal_config(context): context.graph_config = GraphConfig(name="graph-minimal") @given("asyncio has no running loop") def step_no_running_loop(_context): try: loop = asyncio.get_running_loop() if loop.is_running(): loop.stop() except RuntimeError: # Expected when no loop is running pass @when("I construct a LangGraph without providing a scheduler") def step_construct_graph(context): context.graph = _fresh_graph(context.graph_config) @then("it should create an AsyncIOScheduler and initialize streams") def step_assert_scheduler_and_streams(context): assert isinstance(context.graph.scheduler, AsyncIOScheduler) start_stream = f"__{context.graph.name}_node_start__" assert start_stream in context.graph.stream_router.streams @given("a langgraph instance with a fresh state manager") def step_graph_with_state_manager(context): config = GraphConfig(name="graph-exec") context.graph = _fresh_graph(config) context.graph.is_running = True context.start_stream = f"__{context.graph.name}_node_start__" context.graph.stream_router.send_message = MagicMock() @when("I execute the graph with input data") def step_execute_graph(context): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: result = loop.run_until_complete(context.graph.execute({"messages": []})) context.execute_result = result finally: loop.close() replacement_loop = asyncio.new_event_loop() asyncio.set_event_loop(replacement_loop) context.add_cleanup(replacement_loop.close) @then("the start stream should receive the state and execution returns a GraphState") def step_assert_execute(context): context.graph.stream_router.send_message.assert_called_once() called_state = context.graph.stream_router.send_message.call_args.args[1] assert isinstance(called_state, GraphState) assert isinstance(context.execute_result, GraphState) @given("a langgraph instance with a removed start stream") def step_graph_missing_start(context): config = GraphConfig(name="graph-missing-start") context.graph = _fresh_graph(config) start_stream = f"__{context.graph.name}_node_start__" context.graph.stream_router.streams.pop(start_stream, None) @when("I call start on the graph") def step_call_start(context): context.start_error = None try: context.graph.start() except Exception as exc: # pylint: disable=broad-except context.start_error = exc @then("a start stream error should be raised") def step_assert_start_error(context): assert isinstance(context.start_error, ValueError) @given("a langgraph config with parallel execution disabled and a cycle") def step_cycle_config(context): nodes = { "a": NodeConfig(name="a", type=NodeType.FUNCTION), } edges = [Edge(source="start", target="a"), Edge(source="a", target="start")] context.graph_config = GraphConfig( name="graph-cycle", nodes=nodes, edges=edges, parallel_execution=False, ) @when("I construct the LangGraph") def step_construct_cycle_graph(context): context.graph = _fresh_graph(context.graph_config) @then("the graph should report cycles and empty parallel groups") def step_assert_cycles(context): assert context.graph.has_cycles is True assert context.graph.parallel_groups == [] @given("a langgraph instance with a patched state manager") def step_graph_with_executor(context): nodes = { "worker": NodeConfig(name="worker", type=NodeType.FUNCTION), } config = GraphConfig(name="graph-executor", nodes=nodes) graph = _fresh_graph(config) graph.is_running = True graph.state_manager.get_state = MagicMock(return_value=GraphState()) graph.state_manager.update_state = MagicMock() graph.nodes["worker"].execute = AsyncMock(return_value={"messages": ["m"]}) context.executor = graph._node_executors["worker"] # pylint: disable=protected-access context.graph = graph @when("I invoke the builtin node executor") def step_invoke_executor(context): result = context.executor(StreamMessage(content="x", metadata={})) context.executor_result = result @then("it should update state and record execution history") def step_assert_executor(context): context.graph.state_manager.update_state.assert_called_once() # deque does not compare equal to list; convert first assert list(context.graph.execution_history) == ["worker"] assert isinstance(context.executor_result, StreamMessage) # ── Node stream on_next wiring tests ────────────────────────────────── @given("a langgraph instance with a mocked node executor") def step_prepare_mocked_executor(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-exec-wired", nodes=nodes)) context.mock_executor = MagicMock( return_value=StreamMessage(content={}, metadata={}) ) context.graph._node_executors["worker"] = context.mock_executor # pylint: disable=protected-access @when("a message is emitted on the worker node stream") def step_emit_worker_stream_message(context): worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] worker_stream.on_next(StreamMessage(content="test-payload", metadata={})) @then("the registered executor should be called with the message") def step_assert_executor_called(context): context.mock_executor.assert_called_once() call_args = context.mock_executor.call_args msg = call_args[0][0] assert isinstance(msg, StreamMessage) assert msg.content == "test-payload" @when("a raw value is emitted on the worker node stream") def step_emit_raw_value_on_stream(context): worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] worker_stream.on_next("raw-string-payload") @then("the executor should receive a StreamMessage wrapping the raw value") def step_assert_wrapped_message(context): context.mock_executor.assert_called_once() call_args = context.mock_executor.call_args msg = call_args[0][0] assert isinstance(msg, StreamMessage) assert msg.content == "raw-string-payload" assert msg.metadata == {} @given("a langgraph instance with a removed node executor") def step_prepare_missing_executor(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-no-exec", nodes=nodes)) context.graph._node_executors.pop("worker", None) # pylint: disable=protected-access context.graph.logger = MagicMock() @when("a message is emitted on the executorless worker stream") def step_emit_executorless_message(context): worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] worker_stream.on_next("test") @then("a warning about missing executor should be logged") def step_assert_warning_logged(context): context.graph.logger.warning.assert_called_once() args, _kwargs = context.graph.logger.warning.call_args assert args[0] == "No executor found for node %s" assert args[1] == "worker" @given("a langgraph instance with multiple mocked node executors") def step_prepare_multi_executors(context): nodes = { "alpha": NodeConfig(name="alpha", type=NodeType.FUNCTION), "beta": NodeConfig(name="beta", type=NodeType.FUNCTION), } context.graph = _fresh_graph(GraphConfig(name="graph-multi", nodes=nodes)) context.alpha_executor = MagicMock( return_value=StreamMessage(content={}, metadata={}) ) context.beta_executor = MagicMock( return_value=StreamMessage(content={}, metadata={}) ) context.graph._node_executors["alpha"] = context.alpha_executor # pylint: disable=protected-access context.graph._node_executors["beta"] = context.beta_executor # pylint: disable=protected-access @when("messages are emitted on both alpha and beta node streams") def step_emit_both_messages(context): alpha_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_alpha__" ] beta_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_beta__" ] alpha_stream.on_next(StreamMessage(content="alpha-payload", metadata={})) beta_stream.on_next(StreamMessage(content="beta-payload", metadata={})) @then("each executor should receive its own message independently") def step_assert_independent_executors(context): context.alpha_executor.assert_called_once() context.beta_executor.assert_called_once() alpha_msg = context.alpha_executor.call_args[0][0] beta_msg = context.beta_executor.call_args[0][0] assert isinstance(alpha_msg, StreamMessage) assert isinstance(beta_msg, StreamMessage) assert alpha_msg.content == "alpha-payload" assert beta_msg.content == "beta-payload" # ── Executor exception handling (M3) ───────────────────────────────── @given("a langgraph instance with a failing node executor") def step_prepare_failing_executor(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-fail-exec", nodes=nodes)) context.graph._node_executors["worker"] = MagicMock( # pylint: disable=protected-access side_effect=RuntimeError("executor boom") ) context.graph.logger = MagicMock() @when("a message is emitted on the failing worker stream") def step_emit_failing_worker_message(context): worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] context.on_next_error = None try: worker_stream.on_next(StreamMessage(content="trigger", metadata={})) except Exception as exc: # pylint: disable=broad-except context.on_next_error = exc @then("the exception should be caught and logged without propagating") def step_assert_exception_caught(context): assert context.on_next_error is None, ( f"on_next raised unexpectedly: {context.on_next_error}" ) context.graph.logger.exception.assert_called_once() args, _kwargs = context.graph.logger.exception.call_args assert args[0] == "Executor failed for node %s" assert args[1] == "worker" # ── Missing observable branch coverage (m3) ────────────────────────── @given("a langgraph instance with a removed observable") def step_prepare_removed_observable(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-no-obs", nodes=nodes)) stream_name = f"__{context.graph.name}_node_worker__" context.graph.stream_router.observables.pop(stream_name, None) context.graph.logger = MagicMock() @when("node stream subscriptions are set up") def step_rerun_subscriptions(context): # Note: calling _setup_node_stream_subscriptions a second time creates # duplicate subscriptions for start/end nodes. This is expected and # harmless in this test context — we only care about the worker branch. context.graph._setup_node_stream_subscriptions() # pylint: disable=protected-access @then("no executor should be called and no error should occur") def step_assert_no_error_on_missing_observable(context): context.graph.logger.debug.assert_any_call( "No observable for stream %s; skipping subscription", f"__{context.graph.name}_node_worker__", ) context.graph.logger.error.assert_not_called() context.graph.logger.warning.assert_not_called() # ── Execute raises when start stream is missing (m1) ───────────────── @given("a langgraph instance with a removed start stream for execute") def step_prepare_execute_missing_start(context): config = GraphConfig(name="graph-exec-no-start") context.graph = _fresh_graph(config) context.graph.is_running = True start_stream = f"__{context.graph.name}_node_start__" context.graph.stream_router.streams.pop(start_stream, None) @when("I execute the graph expecting a start stream error") def step_execute_expecting_error(context): context.execute_error = None loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(context.graph.execute({"messages": []})) except Exception as exc: # pylint: disable=broad-except context.execute_error = exc finally: loop.close() replacement_loop = asyncio.new_event_loop() asyncio.set_event_loop(replacement_loop) context.add_cleanup(replacement_loop.close) @then("a start stream missing error should be raised from execute") def step_assert_execute_start_error(context): assert isinstance(context.execute_error, ValueError) assert "Start stream not initialized" in str(context.execute_error) # ── run_coroutine_threadsafe path coverage (M5) ────────────────────── def _cleanup_bg_loop(context): """Shut down the background event loop and join the thread.""" bg_loop = getattr(context, "_bg_loop", None) bg_thread = getattr(context, "_bg_thread", None) try: if bg_loop is not None: bg_loop.call_soon_threadsafe(bg_loop.stop) except RuntimeError: # Loop may already be closed; ensure thread join still happens. pass if bg_thread is not None: bg_thread.join(timeout=5.0) if bg_loop is not None: bg_loop.close() @given( "a langgraph instance with a scheduler whose loop is running in a background thread" ) def step_prepare_running_loop_graph(context): bg_loop = asyncio.new_event_loop() def run_loop() -> None: asyncio.set_event_loop(bg_loop) bg_loop.run_forever() bg_thread = threading.Thread(target=run_loop, daemon=True) bg_thread.start() context._bg_loop = bg_loop context._bg_thread = bg_thread # Register cleanup so the background loop is always stopped, even if # assertions fail in the @then step. context.add_cleanup(_cleanup_bg_loop, context) scheduler = AsyncIOScheduler(bg_loop) nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} config = GraphConfig(name="graph-running-loop", nodes=nodes) context.graph = LangGraph(config=config, stream_router=None, scheduler=scheduler) context.graph.is_running = True context.graph.state_manager.get_state = MagicMock(return_value=GraphState()) context.graph.state_manager.update_state = MagicMock() context.graph.nodes["worker"].execute = AsyncMock(return_value={"messages": ["bg"]}) @when("a message is emitted on the worker stream via the running loop path") def step_emit_via_running_loop(context): executor = context.graph._node_executors["worker"] # pylint: disable=protected-access with patch( "asyncio.run_coroutine_threadsafe", wraps=asyncio.run_coroutine_threadsafe ) as mock_rcts: context.executor_result = executor( StreamMessage(content="bg-test", metadata={}) ) context._mock_run_coroutine_threadsafe = mock_rcts @then("the executor should complete successfully via run_coroutine_threadsafe") def step_assert_running_loop_executor(context): assert isinstance(context.executor_result, StreamMessage) assert context.executor_result.metadata["node"] == "worker" context.graph.state_manager.update_state.assert_called_once() # Verify that run_coroutine_threadsafe was actually used (not the # thread-pool fallback path). The mock retains call history after # the patch context manager exits in the @when step. context._mock_run_coroutine_threadsafe.assert_called_once() # ── Execution history cap test (m5) ────────────────────────────────── @given("a langgraph instance with execution history at capacity") def step_prepare_full_history(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} config = GraphConfig(name="graph-history-cap", nodes=nodes) context.graph = _fresh_graph(config) context.graph.is_running = True context.graph.state_manager.get_state = MagicMock(return_value=GraphState()) context.graph.state_manager.update_state = MagicMock() context.graph.nodes["worker"].execute = AsyncMock(return_value={"messages": ["m"]}) # Pre-fill to capacity for i in range(MAX_EXECUTION_HISTORY): context.graph.execution_history.append(f"entry-{i}") context.oldest_entry = "entry-0" @when("one more executor invocation is recorded") def step_invoke_one_more(context): executor = context.graph._node_executors["worker"] # pylint: disable=protected-access executor(StreamMessage(content="x", metadata={})) @then( "the history length should remain at the maximum and the oldest entry should be dropped" ) def step_assert_history_cap(context): assert len(context.graph.execution_history) == MAX_EXECUTION_HISTORY history_list = list(context.graph.execution_history) assert context.oldest_entry not in history_list assert history_list[-1] == "worker" # ── Concurrent state update lock test (m8) ─────────────────────────── @given("a state manager configured for concurrent access") def step_prepare_concurrent_state_manager(context): context.state_manager = StateManager(initial_state=GraphState()) context.num_threads = 20 context.updates_per_thread = 10 @when("multiple threads perform state updates simultaneously") def step_concurrent_updates(context): barrier = threading.Barrier(context.num_threads) def updater() -> None: barrier.wait(timeout=10.0) for _ in range(context.updates_per_thread): context.state_manager.update_state( {"metadata": {"t": threading.current_thread().name}} ) threads = [threading.Thread(target=updater) for _ in range(context.num_threads)] for t in threads: t.start() for t in threads: t.join(timeout=30.0) assert all(not t.is_alive() for t in threads), "Some threads did not complete" @then("the final execution count should equal the total number of updates") def step_assert_concurrent_count(context): expected = context.num_threads * context.updates_per_thread actual = context.state_manager.get_state().execution_count assert actual == expected, f"Expected {expected}, got {actual}" # ── TimeoutError branch coverage (M5) ──────────────────────────────── @given( "a langgraph instance with a slow executor on a background loop and a short timeout" ) def step_prepare_slow_executor_bg_loop(context): bg_loop = asyncio.new_event_loop() def run_loop() -> None: asyncio.set_event_loop(bg_loop) bg_loop.run_forever() bg_thread = threading.Thread(target=run_loop, daemon=True) bg_thread.start() context._bg_loop = bg_loop context._bg_thread = bg_thread context.add_cleanup(_cleanup_bg_loop, context) scheduler = AsyncIOScheduler(bg_loop) # Use a very short timeout (0.01s) so the test completes quickly. nodes = { "worker": NodeConfig(name="worker", type=NodeType.FUNCTION, timeout=0.01), } config = GraphConfig(name="graph-timeout-rcts", nodes=nodes) context.graph = LangGraph(config=config, stream_router=None, scheduler=scheduler) context.graph.is_running = True context.graph.state_manager.get_state = MagicMock(return_value=GraphState()) context.graph.state_manager.update_state = MagicMock() async def slow_execute(state): # Use the original (un-patched) asyncio.sleep so the test-infrastructure # 10 ms sleep cap does not race with the 0.01 s executor timeout. _real_sleep = getattr(asyncio, "_original_sleep", asyncio.sleep) await _real_sleep(0.5) return {"messages": ["never"]} context.graph.nodes["worker"].execute = slow_execute @when("the slow executor is invoked via the running loop path") def step_invoke_slow_executor_rcts(context): executor = context.graph._node_executors["worker"] # pylint: disable=protected-access context.timeout_error = None try: executor(StreamMessage(content="slow", metadata={})) except TimeoutError as exc: context.timeout_error = exc @then("a TimeoutError should be raised with the node name and timeout duration") def step_assert_timeout_rcts(context): assert context.timeout_error is not None, "Expected TimeoutError was not raised" msg = str(context.timeout_error) assert "worker" in msg assert "0.01" in msg @given("a langgraph instance with a slow executor and a short timeout") def step_prepare_slow_executor_tp(context): nodes = { "worker": NodeConfig(name="worker", type=NodeType.FUNCTION, timeout=0.01), } config = GraphConfig(name="graph-timeout-tp", nodes=nodes) context.graph = _fresh_graph(config) context.graph.is_running = True context.graph.state_manager.get_state = MagicMock(return_value=GraphState()) context.graph.state_manager.update_state = MagicMock() async def slow_execute(state): # Use the original (un-patched) asyncio.sleep so the test-infrastructure # 10 ms sleep cap does not race with the 0.01 s executor timeout. _real_sleep = getattr(asyncio, "_original_sleep", asyncio.sleep) await _real_sleep(0.5) return {"messages": ["never"]} context.graph.nodes["worker"].execute = slow_execute @when("the slow executor is invoked via the thread pool path") def step_invoke_slow_executor_tp(context): executor = context.graph._node_executors["worker"] # pylint: disable=protected-access context.timeout_error = None try: executor(StreamMessage(content="slow", metadata={})) except TimeoutError as exc: context.timeout_error = exc @then("a TimeoutError should be raised from the thread pool path with the node name") def step_assert_timeout_tp(context): assert context.timeout_error is not None, "Expected TimeoutError was not raised" msg = str(context.timeout_error) assert "worker" in msg assert "0.01" in msg # ── Restart-after-stop test (m7) ───────────────────────────────────── @given("a langgraph instance that has been started and stopped") def step_prepare_restart_graph(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} config = GraphConfig(name="graph-restart", nodes=nodes) context.graph = _fresh_graph(config) context.graph.stream_router.send_message = MagicMock() context.graph.state_manager.get_state = MagicMock(return_value=GraphState()) context.graph.state_manager.update_state = MagicMock() context.graph.nodes["worker"].execute = AsyncMock( return_value={"messages": ["restarted"]} ) context.graph.start() context.graph.stop() assert context.graph.is_running is False @when("I restart the graph and invoke an executor") def step_restart_and_invoke(context): context.graph.start() # Ensure the pool is cleaned up even if assertions fail later. context.add_cleanup(context.graph.stop) # Invoke the executor directly to verify the pool is recreated after # restart. This does NOT verify the full stream→executor subscription # path; that is covered by the on_next wiring scenarios above. executor = context.graph._node_executors["worker"] # pylint: disable=protected-access context.restart_result = executor( StreamMessage(content="after-restart", metadata={}) ) @then("the executor should complete successfully after restart") def step_assert_restart_success(context): assert isinstance(context.restart_result, StreamMessage) assert context.restart_result.metadata["node"] == "worker" assert context.graph.is_running is True # stop() is handled by context.add_cleanup registered in the @when step. # ── is_running guard test (m4) ─────────────────────────────────────── @given("a langgraph instance that is not running") def step_prepare_not_running_graph(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} config = GraphConfig(name="graph-not-running", nodes=nodes) context.graph = _fresh_graph(config) # Ensure the graph is explicitly not running. context.graph.is_running = False @when("I invoke the node executor directly") def step_invoke_executor_not_running(context): executor = context.graph._node_executors["worker"] # pylint: disable=protected-access context.not_running_error = None try: executor(StreamMessage(content="should-fail", metadata={})) except Exception as exc: # pylint: disable=broad-except context.not_running_error = exc @then("a RuntimeError should be raised indicating the graph is not running") def step_assert_not_running_error(context): assert isinstance(context.not_running_error, RuntimeError) assert "not running" in str(context.not_running_error) assert "worker" in str(context.not_running_error) # ── StateManager.replace_state() direct test (m6) ─────────────────── @given("a state manager with an initial state") def step_prepare_state_manager_for_replace(context): context.state_manager = StateManager(initial_state=GraphState()) context.stream_emissions = [] # list[GraphState] context.state_manager.state_stream.subscribe( on_next=lambda s: context.stream_emissions.append(s) ) @when("I call replace_state with a new state") def step_call_replace_state(context): context.new_state = GraphState(metadata={"replaced": True}, current_node="new-node") context.state_manager.replace_state(context.new_state) @then("get_state should return the new state") def step_assert_replace_state_get(context): retrieved = context.state_manager.get_state() assert retrieved.metadata == {"replaced": True} assert retrieved.current_node == "new-node" @then("the state stream should have received a notification") def step_assert_replace_state_stream(context): # BehaviorSubject emits the initial value on subscribe, plus the # replace_state emission, so we expect at least 2 emissions. assert len(context.stream_emissions) >= 2 @then("the emitted state should be a deep copy not the same object") def step_assert_replace_state_deep_copy(context): # The last emission should be a deep copy, not the same object as # the internal state or the new_state we passed in. emitted = context.stream_emissions[-1] assert emitted is not context.new_state assert emitted.metadata == {"replaced": True} @then("the emitted state should be distinct from the internal state") def step_assert_emitted_distinct_from_internal(context): # After the M1 fix (deep-copy on store), the emitted copy must also # be a distinct object from the internal state held by the manager. emitted = context.stream_emissions[-1] # Direct access intentional: identity check requires the actual # internal object, not a get_state() copy. assert emitted is not context.state_manager.state # ── CancelledError path coverage (M3) ─────────────────────────────── @given("a langgraph instance with a cancellable executor task") def step_prepare_cancellable_executor(context): nodes = { "worker": NodeConfig(name="worker", type=NodeType.FUNCTION), } config = GraphConfig(name="graph-cancel", nodes=nodes) context.graph = _fresh_graph(config) context.graph.is_running = True @when("the executor future is cancelled before completion") def step_cancel_executor_future(context): context.cancel_error = None # Patch the executor pool's submit to return a future that raises # CancelledError on result(). This simulates graph shutdown # (stop() calls shutdown(wait=True, cancel_futures=True)) cancelling # a queued-but-not-yet-started task. cancelled_future: concurrent.futures.Future[StreamMessage] = ( concurrent.futures.Future() ) # cancel() on a fresh Future is sufficient to put it in CANCELLED state. cancelled_future.cancel() executor_fn = context.graph._node_executors["worker"] # pylint: disable=protected-access with patch.object( context.graph._executor_pool, # pylint: disable=protected-access "submit", return_value=cancelled_future, ): try: executor_fn(StreamMessage(content="cancel-me", metadata={})) except RuntimeError as exc: context.cancel_error = exc @then("a RuntimeError should be raised indicating graph stopping") def step_assert_cancelled_error(context): assert context.cancel_error is not None, "Expected RuntimeError was not raised" msg = str(context.cancel_error) assert "cancelled" in msg and "stopping" in msg, f"Unexpected error message: {msg}" # ── __del__ safety net test (m3) ───────────────────────────────────── @given("a langgraph instance that was never stopped") def step_prepare_unstopped_graph(context): config = GraphConfig(name="graph-del-test") context.graph = _fresh_graph(config) # Ensure the graph has a live executor pool (never stopped). assert context.graph._executor_pool is not None # pylint: disable=protected-access @when("__del__ is called on the graph") def step_call_del(context): context.del_error = None try: context.graph.__del__() except Exception as exc: # pylint: disable=broad-except context.del_error = exc # The @then step "no exception should propagate from __del__" is already # defined in bridge_coverage_steps.py and is reused here. It checks # ``context.del_error is None``. # ── execute() is_running guard test (n1) ───────────────────────────── @given("a langgraph instance that is not running for execute") def step_prepare_not_running_for_execute(context): config = GraphConfig(name="graph-exec-not-running") context.graph = _fresh_graph(config) context.graph.is_running = False @when("I call execute on the non-running graph") def step_call_execute_not_running(context): context.execute_not_running_error = None loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) try: loop.run_until_complete(context.graph.execute({"messages": []})) except Exception as exc: # pylint: disable=broad-except context.execute_not_running_error = exc finally: loop.close() replacement_loop = asyncio.new_event_loop() asyncio.set_event_loop(replacement_loop) context.add_cleanup(replacement_loop.close) @then("a RuntimeError should be raised indicating the graph is not running for execute") def step_assert_execute_not_running_error(context): assert isinstance(context.execute_not_running_error, RuntimeError) assert "not running" in str(context.execute_not_running_error) # ── TimeoutError through stream path (M2 cycle 6) ─────────────────── @given("a langgraph instance with an executor that raises TimeoutError") def step_prepare_timeout_executor_for_stream(context): nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} context.graph = _fresh_graph(GraphConfig(name="graph-timeout-stream", nodes=nodes)) context.graph._node_executors["worker"] = MagicMock( # pylint: disable=protected-access side_effect=TimeoutError("timed out") ) context.graph.logger = MagicMock() @when("a message is emitted on the timeout worker stream") def step_emit_timeout_worker_stream(context): worker_stream = context.graph.stream_router.streams[ f"__{context.graph.name}_node_worker__" ] context.stream_error = None try: worker_stream.on_next(StreamMessage(content="trigger-timeout", metadata={})) except Exception as exc: # pylint: disable=broad-except context.stream_error = exc @then("the timeout should be logged at warning level") def step_assert_timeout_warning_logged(context): assert context.stream_error is None, ( f"on_next raised unexpectedly: {context.stream_error}" ) context.graph.logger.warning.assert_called_once() args, _kwargs = context.graph.logger.warning.call_args assert args[0] == "Executor timed out for node %s" assert args[1] == "worker" @then("logger exception should not be called for timeout") def step_assert_no_exception_log_for_timeout(context): context.graph.logger.exception.assert_not_called() # ── CancelledError in run_coroutine_threadsafe path (M1 cycle 6) ──── @given("a langgraph instance with a scheduler loop and a cancellable coroutine future") def step_prepare_rcts_cancellable(context): bg_loop = asyncio.new_event_loop() def run_loop() -> None: asyncio.set_event_loop(bg_loop) bg_loop.run_forever() bg_thread = threading.Thread(target=run_loop, daemon=True) bg_thread.start() context._bg_loop = bg_loop context._bg_thread = bg_thread context.add_cleanup(_cleanup_bg_loop, context) scheduler = AsyncIOScheduler(bg_loop) nodes = {"worker": NodeConfig(name="worker", type=NodeType.FUNCTION)} config = GraphConfig(name="graph-cancel-rcts", nodes=nodes) context.graph = LangGraph(config=config, stream_router=None, scheduler=scheduler) context.graph.is_running = True @when("the coroutine future is cancelled before completion") def step_cancel_coroutine_future(context): context.cancel_rcts_error = None cancelled_future: concurrent.futures.Future[StreamMessage] = ( concurrent.futures.Future() ) # cancel() on a fresh Future is sufficient to put it in CANCELLED state. cancelled_future.cancel() executor_fn = context.graph._node_executors["worker"] # pylint: disable=protected-access with patch( "asyncio.run_coroutine_threadsafe", return_value=cancelled_future, ): try: executor_fn(StreamMessage(content="cancel-rcts", metadata={})) except RuntimeError as exc: context.cancel_rcts_error = exc @then("a RuntimeError should be raised indicating graph stopping from coroutine path") def step_assert_rcts_cancelled_error(context): assert context.cancel_rcts_error is not None, "Expected RuntimeError was not raised" msg = str(context.cancel_rcts_error) assert "cancelled" in msg and "stopping" in msg, f"Unexpected error message: {msg}"