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