forked from HAL9000/cleveragents-core
bc6a41deb6
## Summary - Fix bug #797: `agents actor list` no longer triggers database writes (`upsert_actor`, `set_default_actor`) by removing `ensure_built_in_actors()` from `ActorRegistry.list()` and `ActorRegistry.list_actors()` - Include TDD regression tests from #841 with `@tdd_expected_fail` removed per Bug Fix Workflow - Update three existing test suites that relied on the old behavior to call `ensure_built_in_actors()` explicitly ## Root Cause `ActorRegistry.list_actors()` and `ActorRegistry.list()` both unconditionally called `self.ensure_built_in_actors()` before delegating to the actor service. `ensure_built_in_actors()` iterates all configured providers and calls `_actor_service.upsert_actor()` for each — a database WRITE operation. It may also call `_actor_service.set_default_actor()` if no default exists — another WRITE. This means every read-only `agents actor list` command triggered database writes and could prompt for pending migrations on fresh checkouts. ## Changes ### Bug Fix - **`src/cleveragents/actor/registry.py`** — Removed `self.ensure_built_in_actors()` from `list()` and `list_actors()`. Both methods now delegate directly to the service layer without triggering writes. All write-heavy methods (`add`, `upsert_actor`, `get`, `get_actor`, `remove`, `remove_actor`, `set_default_actor`, `get_default_actor`) still call `ensure_built_in_actors()`. ### TDD Tests (from #841, `@tdd_expected_fail` removed) - **`features/tdd_actor_list_no_db_update.feature`** — 2 Behave scenarios verifying `upsert_actor` and `set_default_actor` are not called during `actor list` - **`features/steps/tdd_actor_list_no_db_update_steps.py`** — Step definitions - **`robot/tdd_actor_list_no_db_update.robot`** — 2 Robot Framework integration tests - **`robot/helper_tdd_actor_list_no_db_update.py`** — Robot helper script ### Test Adjustments Three existing test suites relied on the old (buggy) behavior where `list_actors()` called `ensure_built_in_actors()`: 1. **`features/consolidated_actor.feature`** — Scenario updated to explicitly call `ensure_built_in_actors()` before `list_actors()` 2. **`features/steps/tdd_actor_list_validation_steps.py`** + **`robot/helper_tdd_actor_list_validation.py`** (bug #592) — Updated to call `ensure_built_in_actors()` explicitly before CLI invocation 3. **`features/steps/actor_list_empty_steps.py`** (bug #592) — Updated with explicit `ensure_built_in_actors()` call and capturing upsert pattern ## Quality Gates | Gate | Result | |------|--------| | `nox -e lint` | ✅ Pass | | `nox -e typecheck` | ✅ Pass (0 errors) | | `nox -e unit_tests` | ✅ Pass (468 features, 12367 scenarios, 0 failures) | | `nox -e integration_tests` | ⚠️ 6 pre-existing failures (timeouts/OOM) | | `nox -e coverage_report` | ✅ 98% (>= 97% threshold) | The 6 integration test failures are pre-existing infrastructure issues (SIGTERM/SIGKILL timeouts) unrelated to this change: Container Resolve Crash (3), M3 E2E Verification (2), Resource CLI (1). Closes #797 Reviewed-on: cleveragents/cleveragents-core#1151 Reviewed-by: Jeffrey Phillips Freeman <jeffrey.freeman@cleverthis.com> Co-authored-by: Brent E. Edwards <brent.edwards@cleverthis.com> Co-committed-by: Brent E. Edwards <brent.edwards@cleverthis.com>
217 lines
7.9 KiB
Python
217 lines
7.9 KiB
Python
"""Step definitions for actor_list_empty.feature (bug #592).
|
|
|
|
Regression tests verifying that ``agents actor list`` handles empty
|
|
provider lists and multi-slash model names without validation errors.
|
|
|
|
Root cause (fixed)
|
|
~~~~~~~~~~~~~~~~~~
|
|
``ActorRegistry._actor_name()`` built names via
|
|
``f"{provider}/{model}"``. For providers whose default model already
|
|
contains a ``/`` (e.g. OpenRouter's ``anthropic/claude-sonnet-4-20250514``),
|
|
this yielded names with 2+ slashes. ``ActorService.upsert_actor()`` then
|
|
called ``_normalize_name()`` which raised ``ValidationError("Actor names
|
|
must include exactly one '/' separator")``.
|
|
|
|
The fix sanitises both provider and model names by replacing ``/`` with
|
|
``-`` and lowercasing the result.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from behave import given, then, when
|
|
from typer.testing import CliRunner
|
|
|
|
from cleveragents.actor.registry import ActorRegistry
|
|
from cleveragents.cli.commands.actor import app as actor_app
|
|
from cleveragents.domain.models.core.actor import Actor
|
|
from features.mocks.fake_provider import FakeProviderInfo, FakeProviderRegistry
|
|
|
|
runner = CliRunner()
|
|
|
|
# Patch targets
|
|
_PATCH_GET_SERVICES = "cleveragents.cli.commands.actor._get_services"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_real_registry(
|
|
providers: list[FakeProviderInfo],
|
|
) -> tuple[MagicMock, Any]:
|
|
"""Build a real ``ActorRegistry`` with mocked service/settings.
|
|
|
|
Actors created by ``upsert_actor`` are captured so that
|
|
``list_actors`` returns them — matching the pattern in
|
|
``features/mocks/fake_provider.make_registry()``.
|
|
"""
|
|
provider_reg = FakeProviderRegistry(providers=providers)
|
|
|
|
captured_actors: list[Actor] = []
|
|
|
|
def _capturing_upsert(**kwargs: Any) -> Actor:
|
|
actor = Actor(
|
|
name=kwargs.get("name", "mock/actor"),
|
|
provider=kwargs.get("provider", "mock"),
|
|
model=kwargs.get("model", "actor"),
|
|
config_blob={},
|
|
config_hash=Actor.compute_hash({}),
|
|
)
|
|
captured_actors.append(actor)
|
|
return actor
|
|
|
|
mock_service = MagicMock()
|
|
mock_service.list_actors.side_effect = lambda: list(captured_actors)
|
|
mock_service.get_default_actor.return_value = None
|
|
mock_service.upsert_actor.side_effect = _capturing_upsert
|
|
|
|
mock_settings = MagicMock()
|
|
mock_settings.resolve_provider_defaults.return_value = MagicMock(
|
|
provider=None, model=None
|
|
)
|
|
|
|
registry = ActorRegistry(
|
|
actor_service=mock_service,
|
|
provider_registry=provider_reg,
|
|
settings=mock_settings,
|
|
)
|
|
return mock_service, registry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("the actor registry has no configured providers for actor-list-empty")
|
|
def step_no_providers(context: Any) -> None:
|
|
"""Set up a real ``ActorRegistry`` with zero configured providers.
|
|
|
|
Uses ``_make_real_registry([])`` so the production code path through
|
|
``ensure_built_in_actors()`` is exercised (returns ``[]`` naturally)
|
|
rather than a fully-mocked registry that bypasses it.
|
|
"""
|
|
service, registry = _make_real_registry([])
|
|
context.actor_empty_service = service
|
|
context.actor_empty_registry = registry
|
|
|
|
|
|
@given('the actor registry has a provider with model "{model}" for actor-list-empty')
|
|
def step_custom_model_provider(context: Any, model: str) -> None:
|
|
"""Set up a real registry with a provider using an arbitrary model name."""
|
|
fake_provider = FakeProviderInfo(
|
|
name="EdgeCaseProvider",
|
|
default_model=model,
|
|
)
|
|
service, registry = _make_real_registry([fake_provider])
|
|
context.actor_empty_service = service
|
|
context.actor_empty_registry = registry
|
|
|
|
|
|
@given(
|
|
"the actor registry has a provider with a multi-slash model for actor-list-empty"
|
|
)
|
|
def step_multi_slash_provider(context: Any) -> None:
|
|
"""Set up a real registry that will attempt to build an actor name
|
|
with multiple slashes (e.g. ``Openrouter/anthropic/claude-...``).
|
|
|
|
We use the real ``ActorRegistry`` to exercise the actual code path.
|
|
The actor service and provider registry are mocked to isolate the
|
|
name-construction logic.
|
|
"""
|
|
fake_provider = FakeProviderInfo(
|
|
name="Openrouter",
|
|
default_model="anthropic/claude-sonnet-4-20250514",
|
|
)
|
|
service, registry = _make_real_registry([fake_provider])
|
|
context.actor_empty_service = service
|
|
context.actor_empty_registry = registry
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I run actor list via the actor-list-empty CLI")
|
|
def step_run_actor_list(context: Any) -> None:
|
|
"""Invoke ``actor list`` with the prepared mocks.
|
|
|
|
Built-in actors are populated via ``ensure_built_in_actors()``
|
|
**before** the list call because ``list_actors()`` is now read-only
|
|
and no longer triggers writes (bug #797 fix).
|
|
"""
|
|
context.actor_empty_registry.ensure_built_in_actors()
|
|
with patch(
|
|
_PATCH_GET_SERVICES,
|
|
return_value=(
|
|
context.actor_empty_service,
|
|
context.actor_empty_registry,
|
|
),
|
|
):
|
|
context.actor_empty_result = runner.invoke(actor_app, ["list"])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then steps
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the actor-list-empty output should contain "{text}"')
|
|
def step_output_contains(context: Any, text: str) -> None:
|
|
result = context.actor_empty_result
|
|
assert result is not None, "actor list was not invoked"
|
|
output = result.output
|
|
assert text.lower() in output.lower(), (
|
|
f"Expected '{text}' in output but got:\n{output}"
|
|
)
|
|
|
|
|
|
@then('the actor-list-empty output should not contain "{text}"')
|
|
def step_output_not_contains(context: Any, text: str) -> None:
|
|
result = context.actor_empty_result
|
|
assert result is not None, "actor list was not invoked"
|
|
output = result.output
|
|
assert text.lower() not in output.lower(), (
|
|
f"Did NOT expect '{text}' in output but found it:\n{output}"
|
|
)
|
|
|
|
|
|
@then("the actor-list-empty exit code should be {code:d}")
|
|
def step_exit_code(context: Any, code: int) -> None:
|
|
result = context.actor_empty_result
|
|
assert result is not None, "actor list was not invoked"
|
|
actual = result.exit_code
|
|
assert actual == code, (
|
|
f"Expected exit code {code}, got {actual}. Output:\n{result.output}"
|
|
)
|
|
|
|
|
|
@then("the upserted actor-list-empty actor name should contain exactly one slash")
|
|
def step_upserted_name_single_slash(context: Any) -> None:
|
|
"""Verify that every name passed to ``upsert_actor`` has exactly one ``/``."""
|
|
service = context.actor_empty_service
|
|
assert service.upsert_actor.call_count > 0, "upsert_actor was never called"
|
|
for call in service.upsert_actor.call_args_list:
|
|
name = call.kwargs.get("name") or call.args[0]
|
|
slash_count = name.count("/")
|
|
assert slash_count == 1, (
|
|
f"Expected exactly 1 slash in actor name '{name}', found {slash_count}"
|
|
)
|
|
|
|
|
|
@then('the upserted actor-list-empty actor name should be "{expected_name}"')
|
|
def step_upserted_name_exact(context: Any, expected_name: str) -> None:
|
|
"""Verify that the first ``upsert_actor`` call used the expected name."""
|
|
service = context.actor_empty_service
|
|
assert service.upsert_actor.call_count > 0, "upsert_actor was never called"
|
|
first_call = service.upsert_actor.call_args_list[0]
|
|
actual = first_call.kwargs.get("name") or first_call.args[0]
|
|
assert actual == expected_name, (
|
|
f"Expected actor name '{expected_name}', got '{actual}'"
|
|
)
|