Files
placeholder/features/steps/actor_list_empty_steps.py
brent.edwards bc6a41deb6 tdd(cli): prevent actor list from triggering database updates (#1151)
## 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>
2026-03-28 05:27:14 +00:00

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}'"
)