c9abb45adf
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 15s
CI / build (pull_request) Successful in 16s
CI / quality (pull_request) Successful in 18s
CI / security (pull_request) Successful in 31s
CI / typecheck (pull_request) Successful in 55s
CI / integration_tests (pull_request) Successful in 3m28s
CI / unit_tests (pull_request) Successful in 10m38s
CI / docker (pull_request) Successful in 16s
CI / benchmark-regression (pull_request) Successful in 20m39s
CI / coverage (pull_request) Successful in 41m22s
CI / lint (push) Successful in 13s
CI / quality (push) Successful in 17s
CI / build (push) Successful in 22s
CI / typecheck (push) Successful in 29s
CI / security (push) Successful in 29s
CI / benchmark-regression (push) Has been skipped
CI / integration_tests (push) Successful in 2m40s
CI / unit_tests (push) Successful in 11m4s
CI / docker (push) Successful in 1m5s
CI / benchmark-publish (push) Successful in 12m10s
CI / coverage (push) Successful in 1h7m46s
Added 246 new BDD scenarios across 9 feature files to improve unit test coverage for modules that were either entirely untested or had significant coverage gaps: - lock_service_coverage.feature (27 scenarios): validation branches, TTL boundaries, re-entrant acquisition, rollback on exceptions - plan_apply_service_coverage.feature (54 scenarios): operation labels, diff rendering (plain/rich/json), artifact building, validation gate, changeset resolution and cleanup - plan_executor_coverage.feature (51 scenarios): step parsing, execute actor integration, strategize/execute guards, stub retry/recovery, decision tree construction - skill_cli_coverage_r3.feature (22 scenarios): tools refresh, list/show JSON fallback, capability summary errors, remove confirmation - changeset_repository_coverage.feature (39 scenarios): entry/tool repos validation, database error wrapping, domain conversion, SQLite store CRUD operations - repositories_coverage.feature (20 scenarios): get_by_name/namespace errors, list_available filters, delete with ActionInUseError, plan update with invariants/processing_state/error_details - sandbox_copy_on_write_coverage.feature (12 scenarios): create OSError wrapping, get_path state transitions, commit edge cases, rollback errors, cleanup with missing paths - bridge_coverage.feature (8 scenarios): __del__ suppression, async task cancellation, execute_graph message type handling, stream config, state checkpointer - plan_cli_coverage.feature (13 scenarios): legacy apply/list/cd paths, use-action with estimation/invariant actors, lifecycle-apply guards, status errors, error recovery display All 246 scenarios (1105 steps) pass. Step definitions use unique prefixes to prevent ambiguous step conflicts with existing tests. ISSUES CLOSED: #467
356 lines
12 KiB
Python
356 lines
12 KiB
Python
"""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"
|