"""Steps targeting uncovered lines and branches in bridge.py.""" from __future__ import annotations import asyncio from typing import Any from unittest.mock import AsyncMock, MagicMock from behave import given, then, when from behave.runner import Context from cleveragents.langgraph.bridge import RxPyLangGraphBridge from cleveragents.langgraph.state import GraphState from cleveragents.reactive.stream_router import ( ReactiveStreamRouter, StreamMessage, ) # ------ helpers ------ def _make_bridge() -> tuple[RxPyLangGraphBridge, MagicMock]: """Create a bridge backed by a mocked stream router.""" scheduler = MagicMock() router = ReactiveStreamRouter(scheduler=scheduler) router.agents = {"agent": MagicMock()} bridge = RxPyLangGraphBridge(router) return bridge, scheduler def _make_mock_graph( name: str, *, messages: list[dict[str, Any]] | None = None, state_dict: dict[str, Any] | None = None, ) -> MagicMock: """Build a mock LangGraph with a controllable execute coroutine.""" graph = MagicMock() graph.name = name gs = MagicMock(spec=GraphState) gs.messages = messages if messages is not None else [] gs.to_dict = MagicMock(return_value=state_dict or {"messages": gs.messages}) graph.execute = AsyncMock(return_value=gs) graph.get_execution_history = MagicMock(return_value=["step1"]) graph.state_manager = MagicMock() graph.state_manager.checkpoint_dir = None graph.nodes = {} return graph # ------ Scenario: __del__ suppresses AttributeError ------ @given("a bridge with its cleanup_tasks method removed") def step_bridge_without_cleanup(context: Context) -> None: """Create a bridge then sabotage it so __del__ hits AttributeError.""" bridge, _ = _make_bridge() # Delete _active_tasks so cleanup_tasks() raises AttributeError internally; # __del__ wraps cleanup_tasks in contextlib.suppress(AttributeError). del bridge._active_tasks context.bridge = bridge @when("__del__ is invoked on the crippled bridge") def step_invoke_del(context: Context) -> None: """Call __del__ explicitly; the suppress(AttributeError) path should fire.""" context.del_error = None try: context.bridge.__del__() except Exception as exc: context.del_error = exc @then("no exception should propagate from __del__") def step_no_del_exception(context: Context) -> None: """Verify __del__ swallowed the AttributeError.""" assert context.del_error is None, ( f"Expected no error from __del__, got {context.del_error!r}" ) # ------ Scenario: cleanup_tasks_async cancels running tasks ------ @given("a bridge with a long-running async task") def step_bridge_with_long_task(context: Context) -> None: """Create a bridge and schedule an async task that sleeps for a long time.""" bridge, _ = _make_bridge() loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) async def _long_sleep() -> None: await asyncio.sleep(999) task = loop.create_task(_long_sleep()) bridge._active_tasks.add(task) context.bridge = bridge context.task = task context.loop = loop @when("I run cleanup_tasks_async on the bridge") def step_run_cleanup_async(context: Context) -> None: """Await cleanup_tasks_async so it cancels the not-done task.""" context.loop.run_until_complete(context.bridge.cleanup_tasks_async(timeout=0.1)) @then("the running task should be cancelled") def step_assert_task_cancelled(context: Context) -> None: """Confirm the long-running task was cancelled.""" assert context.task.cancelled() or context.task.done(), ( f"Expected task to be cancelled or done, state={context.task._state}" ) context.loop.close() asyncio.set_event_loop(asyncio.new_event_loop()) @then("the bridge active tasks set should be empty") def step_assert_active_tasks_empty(context: Context) -> None: """Confirm _active_tasks was cleared.""" assert len(context.bridge._active_tasks) == 0, ( f"Expected empty _active_tasks, got {len(context.bridge._active_tasks)}" ) # ------ Scenario: Graph executor with string content (lines 201-204) ------ @given("a bridge with a mock graph that returns messages") def step_bridge_with_graph_messages(context: Context) -> None: """Set up a bridge with a mock graph whose execute returns messages.""" bridge, _ = _make_bridge() mock_graph = _make_mock_graph( "test_exec", messages=[{"content": "response-ok"}], state_dict={"messages": [{"content": "response-ok"}]}, ) bridge.graphs["test_exec"] = mock_graph context.bridge = bridge context.mock_graph = mock_graph @when("I run the graph executor with a string content message") def step_exec_string_message(context: Context) -> None: """Push a string StreamMessage through the graph executor and await it.""" bridge = context.bridge executor_op = bridge._create_graph_executor({"graph": "test_exec"}) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) results: list[Any] = [] errors: list[Any] = [] msg = StreamMessage(content="hello world", metadata={"origin": "test"}) import rx # type: ignore rx.just(msg).pipe(executor_op).subscribe( on_next=lambda x: results.append(x), on_error=lambda e: errors.append(e), ) # Drain event loop so the scheduled coroutine completes pending = list(bridge._active_tasks) if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) else: loop.run_until_complete(asyncio.sleep(0.05)) context.exec_results = results context.exec_errors = errors context.loop = loop @then("the executor result should contain the graph output") def step_assert_executor_result(context: Context) -> None: """Verify the executor produced a StreamMessage with expected content.""" assert len(context.exec_errors) == 0, ( f"Executor raised errors: {context.exec_errors}" ) assert len(context.exec_results) > 0, "Executor produced no results" result_msg = context.exec_results[0] assert isinstance(result_msg, StreamMessage), ( f"Expected StreamMessage, got {type(result_msg).__name__}" ) if hasattr(context, "loop") and not context.loop.is_closed(): context.loop.close() asyncio.set_event_loop(asyncio.new_event_loop()) @then("the result metadata should include graph execution history") def step_assert_metadata_history(context: Context) -> None: """Check that the result metadata carries execution_history.""" result_msg = context.exec_results[0] assert "execution_history" in result_msg.metadata, ( f"Missing 'execution_history' in metadata keys: {list(result_msg.metadata)}" ) assert "graph" in result_msg.metadata, ( f"Missing 'graph' in metadata keys: {list(result_msg.metadata)}" ) @when("I run the graph executor with a dict content message") def step_exec_dict_message(context: Context) -> None: """Push a dict StreamMessage through the graph executor and await it.""" bridge = context.bridge executor_op = bridge._create_graph_executor({"graph": "test_exec"}) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) results: list[Any] = [] errors: list[Any] = [] msg = StreamMessage( content={"messages": [{"role": "user", "content": "hi"}]}, metadata={}, ) import rx # type: ignore rx.just(msg).pipe(executor_op).subscribe( on_next=lambda x: results.append(x), on_error=lambda e: errors.append(e), ) pending = list(bridge._active_tasks) if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) else: loop.run_until_complete(asyncio.sleep(0.05)) context.exec_results = results context.exec_errors = errors context.loop = loop @when("I run the graph executor with a numeric content message") def step_exec_numeric_message(context: Context) -> None: """Push a non-str/non-dict StreamMessage through the executor.""" bridge = context.bridge executor_op = bridge._create_graph_executor({"graph": "test_exec"}) loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) results: list[Any] = [] errors: list[Any] = [] msg = StreamMessage(content=42, metadata={}) import rx # type: ignore rx.just(msg).pipe(executor_op).subscribe( on_next=lambda x: results.append(x), on_error=lambda e: errors.append(e), ) pending = list(bridge._active_tasks) if pending: loop.run_until_complete(asyncio.gather(*pending, return_exceptions=True)) else: loop.run_until_complete(asyncio.sleep(0.05)) context.exec_results = results context.exec_errors = errors context.loop = loop # ------ Scenario: Graph executor with empty messages ------ @given("a bridge with a mock graph that returns empty messages") def step_bridge_with_graph_empty_messages(context: Context) -> None: """Set up a bridge with a mock graph whose execute returns no messages.""" bridge, _ = _make_bridge() mock_graph = _make_mock_graph( "test_exec", messages=[], state_dict={"data": "fallback-state"}, ) bridge.graphs["test_exec"] = mock_graph context.bridge = bridge context.mock_graph = mock_graph @then("the executor result should be the full state dict") def step_assert_state_dict_fallback(context: Context) -> None: """Verify the executor returns the full state dict when messages are empty.""" assert len(context.exec_errors) == 0, ( f"Executor raised errors: {context.exec_errors}" ) assert len(context.exec_results) > 0, "Executor produced no results" result_msg = context.exec_results[0] assert isinstance(result_msg, StreamMessage), ( f"Expected StreamMessage, got {type(result_msg).__name__}" ) # Content should be the full state dict since messages was empty assert result_msg.content == {"data": "fallback-state"}, ( f"Expected fallback state dict, got {result_msg.content!r}" ) if hasattr(context, "loop") and not context.loop.is_closed(): context.loop.close() asyncio.set_event_loop(asyncio.new_event_loop()) # ------ Scenario: create_graph_stream with known graph (branch 182→184) ------ @given('a bridge that owns a registered graph named "{name}"') def step_bridge_with_named_graph(context: Context, name: str) -> None: """Create a bridge and insert a mock graph under the given name.""" bridge, _ = _make_bridge() mock_graph = _make_mock_graph(name) bridge.graphs[name] = mock_graph context.bridge = bridge @when('I call create_graph_stream with "{name}"') def step_call_create_graph_stream(context: Context, name: str) -> None: """Invoke create_graph_stream for the given graph name.""" context.stream_config = context.bridge.create_graph_stream(name) @then('the returned StreamConfig name should be "{expected}"') def step_assert_stream_config_name(context: Context, expected: str) -> None: """Verify the StreamConfig has the expected name.""" assert context.stream_config.name == expected, ( f"Expected name '{expected}', got '{context.stream_config.name}'" ) # ------ Scenario: _create_state_checkpointer valid path (branch 260→258) ------ @when('I build a state checkpointer for "{name}"') def step_build_checkpointer(context: Context, name: str) -> None: """Call _create_state_checkpointer with a valid graph name.""" context.checkpointer_error = None try: context.checkpointer_op = context.bridge._create_state_checkpointer( {"graph": name} ) except Exception as exc: context.checkpointer_error = exc context.checkpointer_op = None @then("the checkpointer operator should be returned without error") def step_assert_checkpointer_ok(context: Context) -> None: """Verify the checkpointer operator was created successfully.""" assert context.checkpointer_error is None, ( f"Expected no error, got {context.checkpointer_error!r}" ) assert context.checkpointer_op is not None, "Expected a valid operator, got None"