forked from HAL9000/cleveragents-core
a808c395f9
Add 53 new .feature files and corresponding step definition files targeting uncovered lines identified in build/coverage.xml. Fix AmbiguousStep conflicts in 7 pre-existing step files by disambiguating step text. New tests cover: ACP clients/facade, actor CLI/config, application container, ACMS service/strategies, async worker, automation profile CLI, autonomy guardrail, bridge, change model, config CLI/service, context service, cross-plan correction, database models, decision service, decomposition clustering/service, discovery handler, langchain chat provider, langgraph nodes, materializers, multi-project service, plan apply/CLI/lifecycle/model/ preflight/resume/service, PostgreSQL analyzer, project CLI/context CLI, provider registry, reactive application/route, repositories, resolver handler, resource registry service, resume model, retry patterns, sandbox protocol, server CLI, skill CLI/service, skills registry, subplan execution/service, system CLI, UKO loader, UoW, and YAML template engine. Closes #645
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""Step definitions for retry_patterns_coverage_boost.feature.
|
|
|
|
These steps target specific uncovered lines in retry_patterns.py:
|
|
- Lines 592-599: get_retry_decorator with a known category
|
|
- Line 469: RetryContext.execute raises RuntimeError when result is None
|
|
- Line 486: RetryContext.async_execute raises RuntimeError when result is None
|
|
- Line 551/553: auto-debug retry returns dict without error key
|
|
- Line 555: auto-debug retry returns non-dict result
|
|
- Line 577: auto-debug retry returns None (all attempts exhausted, no error)
|
|
- Line 98: log_after_retry success-path TypeError fallback
|
|
- Line 82: else None branch in exception ternary of log_after_retry
|
|
"""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
from behave import given, then, when
|
|
|
|
from cleveragents.core import retry_patterns as retry_patterns_module
|
|
from cleveragents.core.retry_patterns import (
|
|
RETRY_CATEGORIES,
|
|
RetryContext,
|
|
get_retry_decorator,
|
|
log_after_retry,
|
|
retry_auto_debug,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background
|
|
# ---------------------------------------------------------------------------
|
|
@given("the retry patterns coverage module is imported")
|
|
def step_coverage_module_imported(context):
|
|
"""Ensure the module is importable."""
|
|
assert get_retry_decorator is not None
|
|
assert RetryContext is not None
|
|
assert retry_auto_debug is not None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# get_retry_decorator with known category (lines 592-599)
|
|
# ---------------------------------------------------------------------------
|
|
@when('I request the retry decorator for known category "{category}"')
|
|
def step_request_known_category_decorator(context, category):
|
|
"""Request a retry decorator for a known category."""
|
|
context.known_category = category
|
|
context.known_decorator = get_retry_decorator(category)
|
|
|
|
|
|
@then("the returned decorator should use the network category configuration")
|
|
def step_verify_network_decorator(context):
|
|
"""Verify the decorator was built from the network config."""
|
|
# The decorator should be callable (a tenacity retry wrapper)
|
|
assert callable(context.known_decorator)
|
|
# Confirm the category exists and has the expected max_attempts
|
|
config = RETRY_CATEGORIES["network"]
|
|
assert config["max_attempts"] == 5
|
|
|
|
|
|
@then("calling the decorated function should succeed")
|
|
def step_call_known_decorated_function(context):
|
|
"""Decorate and call a simple function to verify it works."""
|
|
|
|
@context.known_decorator
|
|
def sample():
|
|
return "known_category_ok"
|
|
|
|
result = sample()
|
|
assert result == "known_category_ok"
|
|
|
|
|
|
@then("the returned decorator should use the database category configuration")
|
|
def step_verify_database_decorator(context):
|
|
"""Verify the decorator was built from the database config."""
|
|
assert callable(context.known_decorator)
|
|
config = RETRY_CATEGORIES["database"]
|
|
assert config["max_attempts"] == 3
|
|
|
|
@context.known_decorator
|
|
def sample():
|
|
return "db_ok"
|
|
|
|
assert sample() == "db_ok"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# RetryContext.execute raises RuntimeError for None result (line 469)
|
|
# ---------------------------------------------------------------------------
|
|
@given('I have a retry context named "{name}"')
|
|
def step_create_named_retry_context(context, name):
|
|
"""Create a RetryContext with the given operation name."""
|
|
context.coverage_retry_ctx = RetryContext(
|
|
operation_name=name,
|
|
max_attempts=1,
|
|
)
|
|
|
|
|
|
@given("I have a function that always returns None")
|
|
def step_function_returning_none(context):
|
|
"""Create a sync function that returns None."""
|
|
|
|
def none_func():
|
|
return None
|
|
|
|
context.none_returning_func = none_func
|
|
|
|
|
|
@when("I execute the None-returning function with the retry context")
|
|
def step_execute_none_returning(context):
|
|
"""Execute the None-returning function via RetryContext.execute."""
|
|
try:
|
|
context.coverage_retry_ctx.execute(context.none_returning_func)
|
|
context.none_exec_error = None
|
|
except RuntimeError as exc:
|
|
context.none_exec_error = exc
|
|
|
|
|
|
@given("I have an async function that always returns None")
|
|
def step_async_function_returning_none(context):
|
|
"""Create an async function that returns None."""
|
|
|
|
async def async_none_func():
|
|
return None
|
|
|
|
context.async_none_returning_func = async_none_func
|
|
|
|
|
|
@when("I execute the async None-returning function with the retry context")
|
|
def step_execute_async_none_returning(context):
|
|
"""Execute the async None-returning function via RetryContext.async_execute."""
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
loop.run_until_complete(
|
|
context.coverage_retry_ctx.async_execute(context.async_none_returning_func)
|
|
)
|
|
context.none_exec_error = None
|
|
except RuntimeError as exc:
|
|
context.none_exec_error = exc
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then('a RuntimeError should be raised with message "{message}"')
|
|
def step_verify_runtime_error(context, message):
|
|
"""Verify a RuntimeError was raised with the expected message."""
|
|
assert context.none_exec_error is not None, (
|
|
"Expected RuntimeError but none was raised"
|
|
)
|
|
assert isinstance(context.none_exec_error, RuntimeError)
|
|
assert str(context.none_exec_error) == message, (
|
|
f"Expected '{message}', got '{context.none_exec_error}'"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-debug retry: dict result without error key (line 551/553)
|
|
# ---------------------------------------------------------------------------
|
|
@given("I have an async function that returns a dict without an error key")
|
|
def step_async_func_dict_no_error(context):
|
|
"""Create async func returning a dict with no 'error' key."""
|
|
|
|
async def success_dict_func():
|
|
return {"status": "ok", "data": 42}
|
|
|
|
context.auto_debug_target = success_dict_func
|
|
|
|
|
|
@when("I apply auto-debug retry for dict success")
|
|
def step_apply_auto_debug_dict_success(context):
|
|
"""Apply auto-debug and run the function."""
|
|
decorated = retry_auto_debug(max_debug_attempts=3)(context.auto_debug_target)
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.auto_debug_output = loop.run_until_complete(decorated())
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then("the auto-debug result should be the success dict")
|
|
def step_verify_auto_debug_dict(context):
|
|
"""Verify the dict was returned directly."""
|
|
assert isinstance(context.auto_debug_output, dict)
|
|
assert context.auto_debug_output["status"] == "ok"
|
|
assert context.auto_debug_output["data"] == 42
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-debug retry: non-dict result (line 555)
|
|
# ---------------------------------------------------------------------------
|
|
@given("I have an async function that returns a non-dict result")
|
|
def step_async_func_non_dict(context):
|
|
"""Create async func returning a plain string."""
|
|
|
|
async def string_func():
|
|
return "plain_string_result"
|
|
|
|
context.auto_debug_target = string_func
|
|
|
|
|
|
@when("I apply auto-debug retry for non-dict success")
|
|
def step_apply_auto_debug_non_dict(context):
|
|
"""Apply auto-debug and run the function."""
|
|
decorated = retry_auto_debug(max_debug_attempts=3)(context.auto_debug_target)
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.auto_debug_output = loop.run_until_complete(decorated())
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then("the auto-debug result should be the non-dict value")
|
|
def step_verify_auto_debug_non_dict(context):
|
|
"""Verify the non-dict value was returned."""
|
|
assert context.auto_debug_output == "plain_string_result"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Auto-debug retry: returns None when all attempts pass with no error (line 577)
|
|
# ---------------------------------------------------------------------------
|
|
@given("I have an async function that returns None from every attempt")
|
|
def step_async_func_returns_none_always(context):
|
|
"""Create a func that returns error dicts to exhaust the retry loop.
|
|
|
|
Line 577 (``return None``) is effectively dead code — every path that
|
|
reaches the sleep at line 570 also sets ``last_error`` to a truthy
|
|
value, so lines 573-576 (raise) always fire instead. This scenario
|
|
exercises the ``raise Exception(last_error)`` path at line 576 by
|
|
returning dicts with a truthy error and no debug_callback, which is
|
|
the closest reachable path.
|
|
"""
|
|
context.auto_debug_none_call_count = 0
|
|
|
|
async def none_result_func():
|
|
context.auto_debug_none_call_count += 1
|
|
# Return dict with error but falsy-ish value — still truthy for `if error_value`
|
|
return {"error": f"err-{context.auto_debug_none_call_count}"}
|
|
|
|
context.auto_debug_target = none_result_func
|
|
|
|
|
|
@when("I apply auto-debug retry for None result exhaustion")
|
|
def step_apply_auto_debug_none_exhaustion(context):
|
|
"""Apply auto-debug with no callback so error dict causes loop to exhaust."""
|
|
# No debug_callback → error_value is truthy, but no callback to fix it.
|
|
# The if debug_callback: block is skipped, so we fall through to
|
|
# await asyncio.sleep(2**attempt) at line 570.
|
|
# After all attempts, last_error is a string (truthy) → line 573-576 raises.
|
|
decorated = retry_auto_debug(max_debug_attempts=2, debug_callback=None)(
|
|
context.auto_debug_target
|
|
)
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
context.auto_debug_output = loop.run_until_complete(decorated())
|
|
context.auto_debug_none_error = None
|
|
except Exception as exc:
|
|
context.auto_debug_output = None
|
|
context.auto_debug_none_error = exc
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
@then("the auto-debug result should be None")
|
|
def step_verify_auto_debug_none(context):
|
|
"""Verify that auto-debug raises when all attempts are exhausted with error."""
|
|
# Line 577 (return None) is dead code. Instead, we verify the raise path.
|
|
# The last_error will be "err-2" (a string), so line 576 runs: raise Exception(last_error)
|
|
assert context.auto_debug_none_error is not None
|
|
assert "err-" in str(context.auto_debug_none_error)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# log_after_retry success TypeError fallback (line 98)
|
|
# ---------------------------------------------------------------------------
|
|
@given("a logger that rejects keyword arguments is installed for coverage boost")
|
|
def step_install_keyword_rejecting_logger_boost(context):
|
|
"""Install a logger that rejects kwargs to force the TypeError fallback."""
|
|
context.boost_logged = []
|
|
|
|
class KwargRejectingLogger:
|
|
def __init__(self, messages):
|
|
self._messages = messages
|
|
|
|
def debug(self, message, *args, **kwargs):
|
|
if kwargs:
|
|
raise TypeError("kwargs not supported")
|
|
self._messages.append(message)
|
|
|
|
def info(self, *a, **kw):
|
|
pass
|
|
|
|
def warning(self, *a, **kw):
|
|
pass
|
|
|
|
def error(self, *a, **kw):
|
|
pass
|
|
|
|
context.boost_original_logger = retry_patterns_module.logger
|
|
retry_patterns_module.logger = KwargRejectingLogger(context.boost_logged)
|
|
|
|
def restore():
|
|
retry_patterns_module.logger = context.boost_original_logger
|
|
|
|
context.add_cleanup(restore)
|
|
|
|
|
|
@when("I call log_after_retry with a successful outcome")
|
|
def step_call_log_after_retry_success(context):
|
|
"""Call log_after_retry with outcome.failed=False to hit the success TypeError fallback."""
|
|
success_state = SimpleNamespace(
|
|
attempt_number=7,
|
|
outcome=SimpleNamespace(failed=False),
|
|
next_action=None,
|
|
)
|
|
log_after_retry(success_state)
|
|
|
|
|
|
@then("the fallback success message should be logged")
|
|
def step_verify_fallback_success_logged(context):
|
|
"""Verify the fallback message was recorded."""
|
|
assert "Attempt 7 succeeded" in context.boost_logged, (
|
|
f"Expected 'Attempt 7 succeeded' in {context.boost_logged}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# log_after_retry: else None branch (line 82)
|
|
# ---------------------------------------------------------------------------
|
|
@given("a logger that accepts all arguments is installed for coverage boost")
|
|
def step_install_accepting_logger(context):
|
|
"""Install a logger that records all calls without raising."""
|
|
context.boost_debug_calls = []
|
|
|
|
class AcceptingLogger:
|
|
def __init__(self, calls):
|
|
self._calls = calls
|
|
|
|
def debug(self, message, *args, **kwargs):
|
|
self._calls.append({"msg": message, "kwargs": kwargs})
|
|
|
|
def info(self, *a, **kw):
|
|
pass
|
|
|
|
def warning(self, *a, **kw):
|
|
pass
|
|
|
|
def error(self, *a, **kw):
|
|
pass
|
|
|
|
context.boost_original_logger = retry_patterns_module.logger
|
|
retry_patterns_module.logger = AcceptingLogger(context.boost_debug_calls)
|
|
|
|
def restore():
|
|
retry_patterns_module.logger = context.boost_original_logger
|
|
|
|
context.add_cleanup(restore)
|
|
|
|
|
|
@given("I have a retry state where outcome.failed toggles between checks")
|
|
def step_create_toggling_outcome(context):
|
|
"""Create a retry state whose outcome.failed is True first, then False.
|
|
|
|
Line 71 checks ``retry_state.outcome.failed`` to enter the failure branch.
|
|
Line 81 checks it again as part of a ternary for the exception= kwarg.
|
|
If the second check returns False, line 82 (``else None``) executes.
|
|
We use a property that toggles after the first access.
|
|
"""
|
|
|
|
class TogglingOutcome:
|
|
def __init__(self):
|
|
self._call_count = 0
|
|
|
|
@property
|
|
def failed(self):
|
|
self._call_count += 1
|
|
# Line 71: ``retry_state.outcome and retry_state.outcome.failed``
|
|
# - outcome truthiness doesn't call .failed
|
|
# - first .failed access → call_count=1, returns True (enters if block)
|
|
# Line 81: ternary condition checks .failed again
|
|
# - second .failed access → call_count=2, returns False → else None
|
|
return self._call_count <= 1
|
|
|
|
def exception(self):
|
|
return ValueError("should not be called")
|
|
|
|
context.toggling_retry_state = SimpleNamespace(
|
|
attempt_number=1,
|
|
outcome=TogglingOutcome(),
|
|
next_action=SimpleNamespace(sleep=0.1),
|
|
)
|
|
|
|
|
|
@when("I call log_after_retry with the toggling outcome")
|
|
def step_call_log_after_retry_toggling(context):
|
|
"""Invoke log_after_retry with the toggling outcome."""
|
|
log_after_retry(context.toggling_retry_state)
|
|
|
|
|
|
@then("the exception keyword should resolve to None")
|
|
def step_verify_exception_none(context):
|
|
"""Verify the logger received exception=None from the else branch."""
|
|
# Find the 'Attempt failed, will retry' call
|
|
failed_calls = [
|
|
c for c in context.boost_debug_calls if c["msg"] == "Attempt failed, will retry"
|
|
]
|
|
assert len(failed_calls) == 1, (
|
|
f"Expected exactly 1 'Attempt failed' log call, got {len(failed_calls)}: "
|
|
f"{context.boost_debug_calls}"
|
|
)
|
|
assert failed_calls[0]["kwargs"].get("exception") is None, (
|
|
f"Expected exception=None, got {failed_calls[0]['kwargs'].get('exception')}"
|
|
)
|