Files
cleveragents-core/features/steps/skill_registry_steps.py
T
2026-02-25 10:03:57 +00:00

563 lines
21 KiB
Python

"""Step definitions for Skill Registry persistence coverage.
Covers skills, skill_items tables and the SkillRegistryService
integration layer.
"""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine, event
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.application.services.skill_registry_service import (
SkillRegistryService,
)
from cleveragents.domain.models.core.skill import (
Skill,
SkillAgentSource,
SkillInclude,
SkillInlineTool,
SkillMcpSource,
)
from cleveragents.domain.models.core.tool import ToolSource
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
DuplicateSkillError,
SkillNotFoundError,
SkillRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _get_session_factory(context: Context) -> Callable[[], Session]:
"""Return the session factory stored on *context*.
The factory always returns the same shared session for the current
scenario, mirroring the ``UnitOfWork`` behaviour used in production.
"""
return context.skill_session_factory # type: ignore[no-any-return]
def _commit_pending(context: Context) -> None:
"""Commit the shared scenario session.
Because the repository only *flushes* (never commits), the test
must explicitly commit to make data durable. The session is kept
open so that subsequent steps can continue using it.
"""
session = _get_session_factory(context)()
session.commit()
def _make_skill(
name: str = "local/test-skill",
description: str = "Test skill",
tool_refs: list[str] | None = None,
includes: list[SkillInclude] | None = None,
anonymous_tools: list[SkillInlineTool] | None = None,
mcp_servers: list[SkillMcpSource] | None = None,
agent_skills: list[SkillAgentSource] | None = None,
overrides: dict[str, dict[str, Any]] | None = None,
) -> Skill:
"""Create a minimal valid Skill domain object for testing."""
return Skill(
name=name,
description=description,
tool_refs=tool_refs or [],
includes=includes or [],
anonymous_tools=anonymous_tools or [],
mcp_servers=mcp_servers or [],
agent_skills=agent_skills or [],
overrides=overrides or {},
)
def _enable_fk_pragma(dbapi_conn: Any, _connection_record: Any) -> None:
"""Enable SQLite foreign key enforcement per connection (SEC-3)."""
cursor = dbapi_conn.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# ---------------------------------------------------------------------------
# Background / setup steps
# ---------------------------------------------------------------------------
@given("a clean in-memory database with the skill registry schema")
def step_clean_db(context: Context) -> None:
engine = create_engine("sqlite:///:memory:", echo=False)
# SEC-3: Enable FK enforcement so CASCADE works at the DB level
event.listen(engine, "connect", _enable_fk_pragma)
Base.metadata.create_all(engine)
# Use a single shared session so that flush() data is visible across
# all repository calls within the same scenario. Without this,
# short-lived sessions returned by sessionmaker() may be garbage-
# collected before _commit_pending() runs, causing the StaticPool's
# reset_on_return="rollback" to discard uncommitted INSERTs.
_shared_session: Session = sessionmaker(bind=engine)()
def _session_factory() -> Session:
return _shared_session
context.skill_engine = engine
context.skill_session_factory = _session_factory
context.skill_error = None
context.skill_result = None
context.skill_removal_result = None
@given("a skill registry repository backed by the database")
def step_repo(context: Context) -> None:
factory = _get_session_factory(context)
context.skill_repo = SkillRepository(session_factory=factory)
@given("a skill registry service backed by the repository")
def step_service(context: Context) -> None:
context.skill_service = SkillRegistryService(
skill_repo=context.skill_repo,
)
# ---------------------------------------------------------------------------
# Given: Skill construction
# ---------------------------------------------------------------------------
@given('a valid skill named "{name}" with description "{description}"')
def step_valid_skill(context: Context, name: str, description: str) -> None:
context.current_skill = _make_skill(name=name, description=description)
@given('the skill has tool refs "{refs_csv}"')
def step_skill_tool_refs(context: Context, refs_csv: str) -> None:
refs = [r.strip() for r in refs_csv.split(",")]
skill: Skill = context.current_skill
context.current_skill = skill.model_copy(update={"tool_refs": refs})
@given('the skill includes "{includes_csv}"')
def step_skill_includes(context: Context, includes_csv: str) -> None:
includes = [SkillInclude(name=n.strip()) for n in includes_csv.split(",")]
skill: Skill = context.current_skill
context.current_skill = skill.model_copy(update={"includes": includes})
@given('the skill has an inline tool with description "{desc}"')
def step_skill_inline_tool(context: Context, desc: str) -> None:
tool = SkillInlineTool(description=desc, source=ToolSource.CUSTOM, timeout=300)
skill: Skill = context.current_skill
context.current_skill = skill.model_copy(update={"anonymous_tools": [tool]})
@given('the skill has an MCP source "{server}"')
def step_skill_mcp(context: Context, server: str) -> None:
mcp = SkillMcpSource(server=server)
skill: Skill = context.current_skill
context.current_skill = skill.model_copy(update={"mcp_servers": [mcp]})
@given('the skill has an agent source "{path}"')
def step_skill_agent(context: Context, path: str) -> None:
agent = SkillAgentSource(path=path)
skill: Skill = context.current_skill
context.current_skill = skill.model_copy(update={"agent_skills": [agent]})
@given("the skill has already been registered once in the skill registry")
def step_skill_already_registered(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
svc.add_skill(context.current_skill)
_commit_pending(context)
@given("the following skills are registered:")
def step_register_skills_table(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
for row in context.table:
skill = _make_skill(
name=row["name"],
description=row["description"],
)
svc.add_skill(skill)
_commit_pending(context)
@given('a valid skill named "{name}" with overrides for "{tool_name}"')
def step_skill_with_overrides(context: Context, name: str, tool_name: str) -> None:
context.current_skill = _make_skill(
name=name,
description="Skill with overrides",
tool_refs=[tool_name],
overrides={tool_name: {"timeout": 60, "retries": 3}},
)
@given('a valid skill named "{name}" with version "{version}"')
def step_skill_with_version(context: Context, name: str, version: str) -> None:
# Skill domain model has no version field; store in context for DB check
context.current_skill = _make_skill(name=name, description="Versioned skill")
context.skill_expected_version = version
@given('a valid skill named "{name}" with mixed item types')
def step_skill_mixed_items(context: Context, name: str) -> None:
context.current_skill = _make_skill(
name=name,
description="Mixed items skill",
tool_refs=["local/tool-a", "local/tool-b"],
includes=[SkillInclude(name="local/base-skill")],
agent_skills=[SkillAgentSource(path="/skills/my-agent")],
)
@given('a valid skill named "{name}" with {count:d} tool refs')
def step_skill_many_refs(context: Context, name: str, count: int) -> None:
refs = [f"local/tool-{i:04d}" for i in range(count)]
context.current_skill = _make_skill(
name=name,
description=f"Skill with {count} tool refs",
tool_refs=refs,
)
# ---------------------------------------------------------------------------
# When: Actions
# ---------------------------------------------------------------------------
@when("the skill is registered through the skill registry service")
@given("the skill is registered through the skill registry service")
def step_register_skill(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
try:
context.skill_result = svc.add_skill(context.current_skill)
_commit_pending(context)
context.skill_error = None
except Exception as exc:
context.skill_error = exc
@when('a second skill with the same name "{name}" is registered')
def step_register_duplicate(context: Context, name: str) -> None:
svc: SkillRegistryService = context.skill_service
dup_skill = _make_skill(name=name, description="Duplicate")
try:
svc.add_skill(dup_skill)
_commit_pending(context)
context.skill_error = None
except DuplicateSkillError as exc:
context.skill_error = exc
except Exception as exc:
# tenacity.RetryError may wrap the real exception; unwrap.
cause = exc.__cause__ or exc
context.skill_error = cause
@when('I retrieve skill "{name}" from the skill registry')
@then('I retrieve skill "{name}" from the skill registry')
def step_retrieve_skill(context: Context, name: str) -> None:
svc: SkillRegistryService = context.skill_service
context.skill_result = svc.get_skill(name)
@when("I list all skills from the skill registry")
def step_list_all_skills(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
context.skill_list_result = svc.list_skills()
@when('I list skills from the skill registry with namespace "{ns}"')
def step_list_skills_ns(context: Context, ns: str) -> None:
svc: SkillRegistryService = context.skill_service
context.skill_list_result = svc.list_skills(namespace=ns)
@when('I update skill "{name}" with description "{desc}"')
def step_update_skill(context: Context, name: str, desc: str) -> None:
svc: SkillRegistryService = context.skill_service
updated_skill = _make_skill(name=name, description=desc)
try:
svc.update_skill(updated_skill)
_commit_pending(context)
context.skill_result = svc.get_skill(name)
context.skill_error = None
except Exception as exc:
context.skill_error = exc
@when('I try to update a non-existent skill "{name}"')
def step_update_nonexistent(context: Context, name: str) -> None:
svc: SkillRegistryService = context.skill_service
fake_skill = _make_skill(name=name, description="Ghost")
try:
svc.update_skill(fake_skill)
_commit_pending(context)
context.skill_error = None
except SkillNotFoundError as exc:
context.skill_error = exc
except Exception as exc:
# tenacity.RetryError may wrap the real exception; unwrap.
cause = exc.__cause__ or exc
context.skill_error = cause
@when('I remove skill "{name}" from the skill registry')
def step_remove_skill(context: Context, name: str) -> None:
svc: SkillRegistryService = context.skill_service
try:
context.skill_removal_result = svc.remove_skill(name)
_commit_pending(context)
except Exception as exc:
context.skill_error = exc
@when('I register a skill with invalid name "{name}" via dict bypass')
def step_register_invalid_name(context: Context, name: str) -> None:
"""Attempt to register a skill with an invalid name through from_domain."""
from cleveragents.infrastructure.database.models import SkillModel
try:
SkillModel.from_domain({"name": name, "description": "Invalid"})
context.skill_error = None
except ValueError as exc:
context.skill_error = exc
@when("I register a skill with empty name via dict bypass")
def step_register_empty_name(context: Context) -> None:
"""Attempt to register a skill with an empty name through from_domain."""
from cleveragents.infrastructure.database.models import SkillModel
try:
SkillModel.from_domain({"name": "", "description": "Invalid"})
context.skill_error = None
except ValueError as exc:
context.skill_error = exc
@when("the skill is registered and retrieved with overrides check")
def step_register_and_check_overrides(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
try:
svc.add_skill(context.current_skill)
_commit_pending(context)
context.skill_result = svc.get_skill(context.current_skill.name)
context.skill_error = None
except Exception as exc:
context.skill_error = exc
@when("I retrieve the skill and check item ordering")
def step_check_item_ordering(context: Context) -> None:
svc: SkillRegistryService = context.skill_service
context.skill_result = svc.get_skill(context.current_skill.name)
@when("the skill is registered with a transient DB error then succeeds")
def step_register_with_retry(context: Context) -> None:
"""Simulate a transient DB error on first attempt, success on second."""
from sqlalchemy.exc import OperationalError as SqlaOpError
svc: SkillRegistryService = context.skill_service
repo: SkillRepository = context.skill_repo
call_count = 0
original_session = repo._session
def flaky_session() -> Session:
nonlocal call_count
call_count += 1
session = original_session()
if call_count == 1:
# Simulate a transient DB error on first call
raise SqlaOpError(
"database is locked", params=None, orig=Exception("locked")
)
return session
repo._session = flaky_session # type: ignore[assignment]
try:
context.skill_result = svc.add_skill(context.current_skill)
_commit_pending(context)
context.skill_error = None
except Exception as exc:
context.skill_error = exc
finally:
repo._session = original_session # type: ignore[assignment]
context.retry_call_count = call_count
# ---------------------------------------------------------------------------
# Then: Assertions
# ---------------------------------------------------------------------------
@then("the skill registry service should not raise an error")
def step_no_error(context: Context) -> None:
assert context.skill_error is None, f"Unexpected error: {context.skill_error}"
@then('the persisted skill should have the name "{name}"')
def step_skill_has_name(context: Context, name: str) -> None:
result = context.skill_result
# If result is None, re-fetch from service
if result is None:
svc: SkillRegistryService = context.skill_service
result = svc.get_skill(name)
context.skill_result = result
assert result is not None, f"Skill '{name}' not found"
assert result.name == name, f"Expected name {name}, got {result.name}"
@then('the persisted skill description should be "{desc}"')
def step_skill_description(context: Context, desc: str) -> None:
result = context.skill_result
assert result is not None, "No skill result available"
assert result.description == desc, (
f"Expected description '{desc}', got '{result.description}'"
)
@then('the persisted skill should have {count:d} items of type "{item_type}"')
def step_skill_items_count(context: Context, count: int, item_type: str) -> None:
# Re-fetch to get the full reconstructed Skill
skill = context.current_skill
svc: SkillRegistryService = context.skill_service
result = svc.get_skill(skill.name)
assert result is not None, f"Skill '{skill.name}' not found"
# Map item_type to the corresponding Skill field
type_to_field: dict[str, int] = {
"tool_ref": len(result.tool_refs),
"include": len(result.includes),
"inline_tool": len(result.anonymous_tools),
"mcp_source": len(result.mcp_servers),
"agent_source": len(result.agent_skills),
}
actual = type_to_field.get(item_type, 0)
assert actual == count, (
f"Expected {count} items of type '{item_type}', got {actual}"
)
@then('a DuplicateSkillError should be raised mentioning "{name}"')
def step_duplicate_error(context: Context, name: str) -> None:
assert context.skill_error is not None, "Expected DuplicateSkillError"
assert isinstance(context.skill_error, DuplicateSkillError), (
f"Expected DuplicateSkillError, got {type(context.skill_error)}"
)
assert name in str(context.skill_error), (
f"Expected '{name}' in error message: {context.skill_error}"
)
@then("the skill registry result should be None")
def step_result_none(context: Context) -> None:
assert context.skill_result is None, f"Expected None, got {context.skill_result}"
@then("the skill list should contain {count:d} skills")
def step_list_count(context: Context, count: int) -> None:
result_list: list[Skill] = context.skill_list_result
assert len(result_list) == count, f"Expected {count} skills, got {len(result_list)}"
@then("a SkillNotFoundError should be raised")
def step_not_found_error(context: Context) -> None:
assert context.skill_error is not None, "Expected SkillNotFoundError"
assert isinstance(context.skill_error, SkillNotFoundError), (
f"Expected SkillNotFoundError, got {type(context.skill_error)}"
)
@then("the skill removal should return True")
def step_removal_true(context: Context) -> None:
assert context.skill_removal_result is True, (
f"Expected True, got {context.skill_removal_result}"
)
@then("the skill removal should return False")
def step_removal_false(context: Context) -> None:
assert context.skill_removal_result is False, (
f"Expected False, got {context.skill_removal_result}"
)
@then("a ValueError should be raised about invalid skill name")
def step_invalid_name_error(context: Context) -> None:
assert context.skill_error is not None, "Expected ValueError"
assert isinstance(context.skill_error, ValueError), (
f"Expected ValueError, got {type(context.skill_error)}"
)
assert (
"namespace/short_name" in str(context.skill_error).lower()
or "pattern" in str(context.skill_error).lower()
), f"Error should mention name format: {context.skill_error}"
@then('the retrieved skill should have overrides for "{tool_name}"')
def step_check_overrides(context: Context, tool_name: str) -> None:
result: Skill = context.skill_result
assert result is not None, "No skill result available"
assert tool_name in result.overrides, (
f"Expected overrides for '{tool_name}', got keys: {list(result.overrides.keys())}"
)
assert result.overrides[tool_name]["timeout"] == 60
assert result.overrides[tool_name]["retries"] == 3
@then("the retrieved skill should have items in stable order")
def step_check_item_order(context: Context) -> None:
result: Skill = context.skill_result
assert result is not None, "No skill result available"
# Original was: 2 tool_refs, 1 include, 1 agent_source
assert len(result.tool_refs) == 2, (
f"Expected 2 tool_refs, got {len(result.tool_refs)}"
)
assert result.tool_refs[0] == "local/tool-a"
assert result.tool_refs[1] == "local/tool-b"
assert len(result.includes) == 1, f"Expected 1 include, got {len(result.includes)}"
assert result.includes[0].name == "local/base-skill"
assert len(result.agent_skills) == 1, (
f"Expected 1 agent_skill, got {len(result.agent_skills)}"
)
assert result.agent_skills[0].path == "/skills/my-agent"
@then("the skill list entries should all be Skill domain objects")
def step_list_domain_objects(context: Context) -> None:
result_list: list[Skill] = context.skill_list_result
for item in result_list:
assert isinstance(item, Skill), f"Expected Skill instance, got {type(item)}"
@then("the persisted skill should have {count:d} tool refs")
def step_skill_tool_ref_count(context: Context, count: int) -> None:
skill = context.current_skill
svc: SkillRegistryService = context.skill_service
result = svc.get_skill(skill.name)
assert result is not None, f"Skill '{skill.name}' not found"
assert len(result.tool_refs) == count, (
f"Expected {count} tool refs, got {len(result.tool_refs)}"
)
@then("the retry should have been attempted")
def step_retry_attempted(context: Context) -> None:
# The @database_retry decorator retries on OperationalError,
# so the call count should be > 1 (first attempt raised, second succeeded)
assert context.retry_call_count >= 1, (
f"Expected retry, but call_count was {context.retry_call_count}"
)