8dc55655e9
CI / push-validation (push) Successful in 41s
CI / helm (push) Successful in 42s
CI / benchmark-publish (push) Failing after 56s
CI / build (push) Successful in 1m6s
CI / lint (push) Successful in 1m13s
CI / quality (push) Successful in 1m34s
CI / typecheck (push) Successful in 2m12s
CI / security (push) Successful in 2m13s
CI / e2e_tests (push) Successful in 3m45s
CI / integration_tests (push) Successful in 3m57s
CI / unit_tests (push) Successful in 4m53s
CI / docker (push) Successful in 1m34s
CI / coverage (push) Successful in 11m27s
CI / status-check (push) Successful in 3s
CI / benchmark-publish (pull_request) Has been skipped
CI / helm (pull_request) Successful in 32s
CI / push-validation (pull_request) Successful in 33s
CI / build (pull_request) Successful in 53s
CI / lint (pull_request) Successful in 59s
CI / quality (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 1m29s
CI / typecheck (pull_request) Successful in 1m32s
CI / e2e_tests (pull_request) Successful in 4m1s
CI / integration_tests (pull_request) Successful in 5m4s
CI / unit_tests (pull_request) Successful in 5m51s
CI / docker (pull_request) Successful in 1m27s
CI / coverage (pull_request) Successful in 11m51s
CI / status-check (pull_request) Successful in 3s
Replace the DB-persistence approach for built-in actors with in-memory virtual resolution. Built-in actors (e.g. openai/gpt-4o, anthropic/claude-sonnet) are now resolved on-demand from ProviderRegistry at query time and merged with persisted custom actors — no database writes occur for built-in actors. Key changes: - Add ActorRegistry._resolve_virtual_builtin_actors(): generates virtual Actor objects in-memory from configured providers (is_built_in=True, id=None) - ActorRegistry.list()/list_actors(): merges virtual built-ins with custom DB actors; custom actors win on name collision; result sorted alphabetically - ActorRegistry.get()/get_actor(): DB-first, virtual built-in fallback, NotFoundError if neither - ActorRegistry.remove()/remove_actor(): rejects virtual built-in names with ValidationError - ActorRegistry.set_default_actor(): stores only the actor name string via new actor_preferences singleton table; no actor row created for virtual built-ins - ActorRegistry.get_default_actor(): reads preference name, resolves via DB→virtual chain, returns actor with is_default=True - Remove ensure_built_in_actors() entirely — 20+ call sites cleaned up including plan.py - Remove ActorRepository.upsert_built_in() — no longer needed - Remove is_built_in from ActorModel DB column (kept on Actor domain model for virtual actors) - New Alembic migration m10_001_virtual_builtin_actors: drops is_built_in column, adds actor_preferences singleton table - Add ActorService.set_default_actor_name() and get_default_actor_name() for preference storage without requiring a DB actor row - Update 15+ Behave step files and 5 feature files; add new features/virtual_builtin_actors.feature with 8 scenarios covering list, show, remove, set-default, get-default, no-DB-writes guarantees - Rewrite tests/actor/test_registry_builtin_yaml.py: TestEnsureBuiltInActorsWithYaml → TestResolveVirtualBuiltinActors plus new TestListActors, TestGetActor, TestRemoveActor, TestDefaultActor test classes Quality gates: lint ✓, typecheck ✓, unit_tests ✓ (15674 scenarios), coverage ✓ (97.10%), integration_tests ✓ (1997 tests) ISSUES CLOSED: #10923
349 lines
12 KiB
Python
349 lines
12 KiB
Python
"""Behave steps targeting actor service coverage gaps."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import os
|
|
from collections.abc import Callable
|
|
from datetime import datetime
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.application.services.actor_service import ActorService
|
|
from cleveragents.config.settings import Settings
|
|
from cleveragents.core.exceptions import (
|
|
BusinessRuleViolation,
|
|
NotFoundError,
|
|
ValidationError,
|
|
)
|
|
from cleveragents.domain.models.core.actor import Actor
|
|
|
|
|
|
def _add_cleanup(context: Context, handler: Callable[[], None]) -> None:
|
|
if not hasattr(context, "_cleanup_handlers"):
|
|
context._cleanup_handlers = []
|
|
context._cleanup_handlers.append(handler)
|
|
|
|
|
|
class _StubActorRepository:
|
|
def __init__(self) -> None:
|
|
self._actors: dict[str, Actor] = {}
|
|
self._default_name: str | None = None
|
|
|
|
def list_all(self) -> list[Actor]:
|
|
return [self._actors[name] for name in sorted(self._actors.keys())]
|
|
|
|
def get_by_name(self, name: str) -> Actor | None:
|
|
return self._actors.get(name)
|
|
|
|
def upsert(self, actor: Actor) -> Actor:
|
|
self._actors[actor.name] = actor
|
|
if actor.is_default:
|
|
self.set_default(actor.name)
|
|
return actor
|
|
|
|
def delete(self, name: str) -> None:
|
|
actor = self._actors.get(name)
|
|
if actor is None:
|
|
raise ValueError("Actor does not exist")
|
|
if actor.is_built_in or actor.is_default:
|
|
raise ValueError("Cannot delete built-in or default actor")
|
|
del self._actors[name]
|
|
|
|
def set_default(self, name: str) -> Actor:
|
|
if name not in self._actors:
|
|
raise ValueError("Actor does not exist")
|
|
self._default_name = name
|
|
for actor in self._actors.values():
|
|
actor.is_default = actor.name == name
|
|
return self._actors[name]
|
|
|
|
def get_default(self) -> Actor | None:
|
|
if self._default_name and self._default_name in self._actors:
|
|
return self._actors[self._default_name]
|
|
for actor in self._actors.values():
|
|
if actor.is_default:
|
|
self._default_name = actor.name
|
|
return actor
|
|
return None
|
|
|
|
def clear(self) -> None:
|
|
self._actors.clear()
|
|
self._default_name = None
|
|
|
|
def count(self) -> int:
|
|
return len(self._actors)
|
|
|
|
def get_default_name(self) -> str | None:
|
|
return self._default_name
|
|
|
|
def set_default_name(self, name: str | None) -> None:
|
|
self._default_name = name
|
|
|
|
|
|
class _StubTransaction:
|
|
def __init__(self, repo: _StubActorRepository) -> None:
|
|
self.actors = repo
|
|
|
|
def __enter__(self) -> _StubTransaction:
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
return False
|
|
|
|
|
|
class _StubUnitOfWork:
|
|
def __init__(self, repo: _StubActorRepository) -> None:
|
|
self._repo = repo
|
|
|
|
def transaction(self) -> _StubTransaction:
|
|
return _StubTransaction(self._repo)
|
|
|
|
|
|
def _make_actor(
|
|
name: str, *, is_default: bool = False, is_built_in: bool = False
|
|
) -> Actor:
|
|
now = datetime.now()
|
|
return Actor(
|
|
id=None,
|
|
name=name,
|
|
provider="provider",
|
|
model="model",
|
|
config_blob={},
|
|
config_hash=Actor.compute_hash({}),
|
|
graph_descriptor=None,
|
|
unsafe=False,
|
|
is_built_in=is_built_in,
|
|
is_default=is_default,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
|
|
@given("an actor service with stubbed dependencies")
|
|
def step_actor_service_stubbed(context: Context) -> None:
|
|
repo = _StubActorRepository()
|
|
context.actor_repo = repo
|
|
context.unit_of_work = _StubUnitOfWork(repo)
|
|
context.actor_service = ActorService(Settings(), context.unit_of_work)
|
|
|
|
original_env = os.environ.get("CLEVERAGENTS_TESTING_USE_MOCK_AI")
|
|
|
|
def cleanup_env() -> None:
|
|
if original_env is None:
|
|
os.environ.pop("CLEVERAGENTS_TESTING_USE_MOCK_AI", None)
|
|
else:
|
|
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = original_env
|
|
|
|
_add_cleanup(context, cleanup_env)
|
|
|
|
|
|
@when('I normalize actor name "{name}"')
|
|
def step_normalize_name(context: Context, name: str) -> None:
|
|
context.normalized_name = context.actor_service._normalize_name(name)
|
|
|
|
|
|
@then('the normalized actor name should be "{expected}"')
|
|
def step_normalized_actor_name_should_be(context: Context, expected: str) -> None:
|
|
assert context.normalized_name == expected, context.normalized_name
|
|
|
|
|
|
@when('I try to normalize actor name "{name}" without allowing built-ins')
|
|
def step_try_normalize_invalid(context: Context, name: str) -> None:
|
|
try:
|
|
context.actor_service._normalize_name(name, allow_built_in=False)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I try to normalize actor name "" without allowing built-ins')
|
|
def step_try_normalize_invalid_empty(context: Context) -> None:
|
|
step_try_normalize_invalid(context, "")
|
|
|
|
|
|
@then('a ValidationError should be raised containing "{message}"')
|
|
def step_validation_error_message(context: Context, message: str) -> None:
|
|
assert isinstance(context.error, ValidationError), type(context.error)
|
|
assert message in str(context.error)
|
|
|
|
|
|
@given('existing actors named "{first}" and "{second}"')
|
|
def step_existing_actors(context: Context, first: str, second: str) -> None:
|
|
for name in (first, second):
|
|
context.actor_repo.upsert(_make_actor(name))
|
|
|
|
|
|
@when("I list all actors")
|
|
def step_list_actors(context: Context) -> None:
|
|
context.listed_actors = context.actor_service.list_actors()
|
|
|
|
|
|
@then("I should receive actors {actor_names}")
|
|
def step_should_receive_actors(context: Context, actor_names: str) -> None:
|
|
expected = ast.literal_eval(actor_names)
|
|
assert [actor.name for actor in context.listed_actors] == expected
|
|
|
|
|
|
@when('I attempt to fetch actor "{name}"')
|
|
def step_attempt_fetch_actor(context: Context, name: str) -> None:
|
|
try:
|
|
context.fetched_actor = context.actor_service.get_actor(name)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("a NotFoundError should be raised for the actor")
|
|
def step_not_found_error(context: Context) -> None:
|
|
assert isinstance(context.error, NotFoundError), type(context.error)
|
|
|
|
|
|
@given('a built-in actor named "{name}" already exists')
|
|
def step_builtin_actor_exists(context: Context, name: str) -> None:
|
|
context.actor_repo.upsert(_make_actor(name, is_built_in=True))
|
|
|
|
|
|
@when('I attempt to upsert "{name}" as custom')
|
|
def step_attempt_upsert_custom(context: Context, name: str) -> None:
|
|
try:
|
|
context.actor_service.upsert_actor(name=name, provider="p", model="m")
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then("a BusinessRuleViolation should be raised")
|
|
def step_business_rule_violation(context: Context) -> None:
|
|
assert isinstance(context.error, BusinessRuleViolation), type(context.error)
|
|
|
|
|
|
@then("a ValidationError should be raised for non-local namespace")
|
|
def step_validation_error_non_local_namespace(context: Context) -> None:
|
|
"""After issue #10923, non-local namespace actors raise ValidationError.
|
|
|
|
The service rejects non-local custom actors with a ValidationError
|
|
(previously raised BusinessRuleViolation for built-in overwrite attempt;
|
|
now no built-in actors exist in DB so the check is namespace-based).
|
|
"""
|
|
assert isinstance(context.error, ValidationError), (
|
|
f"Expected ValidationError, got {type(context.error)}: {context.error}"
|
|
)
|
|
|
|
|
|
@given('an actor named "{name}" exists')
|
|
def step_actor_exists(context: Context, name: str) -> None:
|
|
context.actor_repo.upsert(_make_actor(name))
|
|
|
|
|
|
@when('I upsert "{name}" with set_default')
|
|
def step_upsert_with_set_default(context: Context, name: str) -> None:
|
|
context.saved_actor = context.actor_service.upsert_actor(
|
|
name=name, provider="p", model="m", set_default=True
|
|
)
|
|
|
|
|
|
@then('the stored actor "{name}" should be default')
|
|
def step_stored_actor_default(context: Context, name: str) -> None:
|
|
actor = context.actor_repo.get_by_name(name)
|
|
assert actor is not None, "Actor not found"
|
|
assert actor.is_default, "Actor was not marked as default"
|
|
default = context.actor_repo.get_default()
|
|
assert default is not None, "No default actor set in repository"
|
|
assert default.name == actor.name, (
|
|
f"Expected default actor '{actor.name}', got '{default.name}'"
|
|
)
|
|
|
|
|
|
@when('I try to remove actor "{name}"')
|
|
def step_try_remove_actor(context: Context, name: str) -> None:
|
|
try:
|
|
context.actor_service.remove_actor(name)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@when('I set default actor "{name}"')
|
|
def step_set_default_actor(context: Context, name: str) -> None:
|
|
try:
|
|
context.result_actor = context.actor_service.set_default_actor(name)
|
|
context.error = None
|
|
except Exception as exc:
|
|
context.error = exc
|
|
|
|
|
|
@then('the returned actor name should be "{name}"')
|
|
def step_returned_actor_name(context: Context, name: str) -> None:
|
|
assert context.result_actor is not None, "No actor returned"
|
|
assert context.result_actor.name == name
|
|
|
|
|
|
@then("no additional actors should be created")
|
|
def step_no_additional_actors(context: Context) -> None:
|
|
assert context.actor_repo.count() == context.initial_actor_count
|
|
|
|
|
|
@given('an actor named "{name}" exists as default')
|
|
def step_actor_exists_as_default(context: Context, name: str) -> None:
|
|
actor = _make_actor(name, is_default=True)
|
|
context.actor_repo.upsert(actor)
|
|
context.actor_repo.set_default(name)
|
|
|
|
|
|
@given('a mock actor named "{name}" exists without default flag')
|
|
def step_mock_actor_exists_without_default(context: Context, name: str) -> None:
|
|
actor = _make_actor(name, is_built_in=True, is_default=False)
|
|
context.actor_repo.upsert(actor)
|
|
|
|
|
|
@given("any existing actors are cleared")
|
|
def step_clear_existing_actors(context: Context) -> None:
|
|
context.actor_repo.clear()
|
|
|
|
|
|
@when('I ensure the default mock actor with env value "{value}"')
|
|
def step_ensure_default_mock_actor(context: Context, value: str) -> None:
|
|
context.initial_actor_count = context.actor_repo.count()
|
|
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = value
|
|
context.result_actor = context.actor_service.ensure_default_mock_actor()
|
|
|
|
|
|
@then("the result should be None")
|
|
def step_result_none(context: Context) -> None:
|
|
assert context.result_actor is None
|
|
|
|
|
|
def after_scenario(context: Context, scenario) -> None:
|
|
if hasattr(context, "_cleanup_handlers"):
|
|
for handler in context._cleanup_handlers:
|
|
handler()
|
|
|
|
|
|
@when('I ensure the default mock actor with env value "{value}" again')
|
|
def step_ensure_default_mock_actor_again(context: Context, value: str) -> None:
|
|
os.environ["CLEVERAGENTS_TESTING_USE_MOCK_AI"] = value
|
|
context.result_actor = context.actor_service.ensure_default_mock_actor()
|
|
|
|
|
|
@then('only one "{name}" actor should exist')
|
|
def step_only_one_actor_exists(context: Context, name: str) -> None:
|
|
actors = context.actor_repo.list_all()
|
|
count = sum(1 for actor in actors if actor.name == name)
|
|
assert count == 1, f"Expected 1 actor named {name}, but found {count}"
|
|
|
|
|
|
@then("only one default actor should exist")
|
|
def step_only_one_default_actor(context: Context) -> None:
|
|
actors = context.actor_repo.list_all()
|
|
default_count = sum(1 for actor in actors if actor.is_default)
|
|
assert default_count == 1, f"Expected 1 default actor, but found {default_count}"
|
|
|
|
|
|
@then("exactly one actor should exist")
|
|
def step_exactly_one_actor(context: Context) -> None:
|
|
actors = context.actor_repo.list_all()
|
|
count = len(actors)
|
|
assert count == 1, f"Expected 1 actor, but found {count}"
|