Files
cleveragents-core/features/steps/tool_registry_steps.py
CoreRasurae 798db9088e
CI / lint (pull_request) Successful in 15s
CI / typecheck (pull_request) Successful in 36s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 19s
CI / build (pull_request) Successful in 23s
CI / integration_tests (pull_request) Successful in 6m49s
CI / coverage (pull_request) Successful in 12m1s
CI / unit_tests (pull_request) Successful in 16m28s
CI / docker (pull_request) Successful in 51s
feat(tool): add tool registry persistence
2026-02-17 20:28:20 +00:00

598 lines
20 KiB
Python

"""Step definitions for Tool Registry persistence coverage.
Covers tools, tool_resource_bindings, validation_attachments tables
and the ToolRegistryService integration layer.
"""
from __future__ import annotations
from datetime import datetime
from behave import given, then, when
from behave.runner import Context
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from cleveragents.application.services.tool_registry_service import (
ToolRegistryService,
)
from cleveragents.infrastructure.database.models import Base
from cleveragents.infrastructure.database.repositories import (
DuplicateToolError,
ToolInUseError,
ToolRegistryRepository,
ValidationAttachmentRepository,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_tool_dict(
name: str = "core/test-tool",
source: str = "builtin",
tool_type: str = "tool",
description: str | None = None,
code: str | None = None,
mcp_server: str | None = None,
mcp_tool_name: str | None = None,
mode: str | None = None,
resource_bindings: list[dict[str, object]] | None = None,
) -> dict[str, object]:
"""Create a minimal valid tool dict for testing."""
now_iso = datetime.now().isoformat()
result: dict[str, object] = {
"name": name,
"description": description or f"Test tool {name}",
"tool_type": tool_type,
"source": source,
"timeout": 300,
"created_at": now_iso,
"updated_at": now_iso,
"resource_bindings": resource_bindings or [],
}
if code is not None:
result["code"] = code
if mcp_server is not None:
result["mcp_server"] = mcp_server
if mcp_tool_name is not None:
result["mcp_tool_name"] = mcp_tool_name
if mode is not None:
result["mode"] = mode
return result
def _make_binding(slot_name: str = "repo") -> dict[str, object]:
"""Create a resource binding dict."""
return {
"slot_name": slot_name,
"resource_type": "git-checkout",
"access_mode": "read_only",
"binding_mode": "contextual",
"required": True,
}
def _get_session_factory(context: Context) -> sessionmaker[Session]:
return context.tool_session_factory # type: ignore[no-any-return]
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("a clean in-memory database with the tool registry schema")
def step_clean_tool_db(context: Context) -> None:
"""Set up an in-memory SQLite database with all tables."""
engine = create_engine("sqlite:///:memory:", echo=False)
Base.metadata.create_all(engine)
factory: sessionmaker[Session] = sessionmaker(bind=engine, expire_on_commit=False)
context.tool_engine = engine
context.tool_session_factory = factory
# Keep a persistent session for attachment repo (needs same connection)
context.tool_session = factory()
@given("a tool registry repository backed by the database")
def step_tool_repo(context: Context) -> None:
"""Create a ToolRegistryRepository."""
context.tool_repo = ToolRegistryRepository(
session_factory=lambda: context.tool_session,
)
@given("a validation attachment repository backed by the database")
def step_attachment_repo(context: Context) -> None:
"""Create a ValidationAttachmentRepository."""
context.attachment_repo = ValidationAttachmentRepository(
session_factory=lambda: context.tool_session,
)
# ---------------------------------------------------------------------------
# Given: tool dicts
# ---------------------------------------------------------------------------
@given('a valid tool dict named "{name}" with source "{source}"')
def step_valid_tool_dict(context: Context, name: str, source: str) -> None:
"""Build a tool dict."""
context.tool_dict = _make_tool_dict(name=name, source=source)
@given('the tool dict includes inline code "{code}"')
def step_tool_dict_code(context: Context, code: str) -> None:
context.tool_dict["code"] = code
@given('the tool dict includes mcp_server "{server}" and mcp_tool_name "{tool_name}"')
def step_tool_dict_mcp(context: Context, server: str, tool_name: str) -> None:
context.tool_dict["mcp_server"] = server
context.tool_dict["mcp_tool_name"] = tool_name
@given('the tool dict has tool_type "{tool_type}" and mode "{mode}"')
def step_tool_dict_type_mode(context: Context, tool_type: str, mode: str) -> None:
context.tool_dict["tool_type"] = tool_type
context.tool_dict["mode"] = mode
@given('the tool dict includes a resource binding for slot "{slot_name}"')
def step_tool_dict_binding(context: Context, slot_name: str) -> None:
bindings = list(context.tool_dict.get("resource_bindings", [])) # type: ignore[union-attr]
bindings.append(_make_binding(slot_name))
context.tool_dict["resource_bindings"] = bindings
@given("the tool has already been registered once in the tool registry")
def step_tool_already_registered(context: Context) -> None:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
@given("the tool has been registered through the tool registry repository")
def step_tool_registered(context: Context) -> None:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
@given("the following tools have been registered:")
def step_register_multiple_tools(context: Context) -> None:
for row in context.table:
tool_dict = _make_tool_dict(
name=row["name"],
source=row["source"],
tool_type=row["tool_type"],
)
context.tool_repo.create(tool_dict)
context.tool_session.commit()
@given('an unscoped validation attachment for "{val_name}" on resource "{res_id}"')
def step_attachment_exists(context: Context, val_name: str, res_id: str) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
context.last_attachment = att
@given(
'a project-scoped validation attachment for "{val_name}" on resource "{res_id}" in project "{project}"'
)
def step_attachment_in_project(
context: Context, val_name: str, res_id: str, project: str
) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
project_name=project,
)
context.tool_session.commit()
context.last_attachment = att
@given(
'a plan-scoped validation attachment for "{val_name}" on resource "{res_id}" in plan "{plan_id}"'
)
def step_attachment_with_plan(
context: Context, val_name: str, res_id: str, plan_id: str
) -> None:
att = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
plan_id=plan_id,
)
context.tool_session.commit()
context.last_attachment = att
# ---------------------------------------------------------------------------
# When: registration
# ---------------------------------------------------------------------------
@when("the tool is registered through the tool registry repository")
def step_register_tool(context: Context) -> None:
context.last_error = None
try:
context.tool_repo.create(context.tool_dict)
context.tool_session.commit()
except Exception as exc:
context.last_error = exc
@when('a second tool with the same registry name "{name}" is registered')
def step_register_duplicate_tool(context: Context, name: str) -> None:
dup = _make_tool_dict(name=name, source="builtin")
context.last_error = None
try:
context.tool_repo.create(dup)
context.tool_session.commit()
except Exception as exc:
context.last_error = exc
# ---------------------------------------------------------------------------
# When: retrieval
# ---------------------------------------------------------------------------
@when('the tool is looked up by registry name "{name}"')
def step_lookup_tool(context: Context, name: str) -> None:
context.returned_tool = context.tool_repo.get_by_name(name)
# ---------------------------------------------------------------------------
# When: listing
# ---------------------------------------------------------------------------
@when("all tools are listed from the tool registry")
def step_list_all_tools(context: Context) -> None:
context.returned_tools = context.tool_repo.list_all()
@when('tools in the "{ns}" namespace are listed from the registry')
def step_list_tools_ns(context: Context, ns: str) -> None:
context.returned_tools = context.tool_repo.list_all(namespace=ns)
@when('tools of type "{tool_type}" are listed from the registry')
def step_list_tools_type(context: Context, tool_type: str) -> None:
context.returned_tools = context.tool_repo.list_all(tool_type=tool_type)
@when('tools with source "{source}" are listed from the registry')
def step_list_tools_source(context: Context, source: str) -> None:
context.returned_tools = context.tool_repo.list_all(source=source)
# ---------------------------------------------------------------------------
# When: update
# ---------------------------------------------------------------------------
@when('the tool description is updated to "{desc}"')
def step_update_tool_desc(context: Context, desc: str) -> None:
context.tool_dict["description"] = desc
context.tool_repo.update(context.tool_dict)
context.tool_session.commit()
@when('the tool is updated with a new resource binding for slot "{slot_name}"')
def step_update_tool_binding(context: Context, slot_name: str) -> None:
context.tool_dict["resource_bindings"] = [_make_binding(slot_name)]
context.tool_repo.update(context.tool_dict)
context.tool_session.commit()
# ---------------------------------------------------------------------------
# When: deletion
# ---------------------------------------------------------------------------
@when('the tool "{name}" is removed from the registry')
def step_remove_tool(context: Context, name: str) -> None:
context.last_error = None
try:
context.removal_result = context.tool_repo.delete(name)
except Exception as exc:
context.last_error = exc
# ---------------------------------------------------------------------------
# When: attachments
# ---------------------------------------------------------------------------
@when('the validation "{val_name}" is attached to resource "{res_id}"')
def step_attach_validation(context: Context, val_name: str, res_id: str) -> None:
context.last_attachment = context.attachment_repo.attach(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when("the validation attachment is detached by its identifier")
def step_detach_by_id(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
context.detach_result = context.attachment_repo.detach(att_id)
context.tool_session.commit()
@when('the validation attachment "{att_id}" is detached')
def step_detach_nonexistent(context: Context, att_id: str) -> None:
context.detach_result = context.attachment_repo.detach(att_id)
@when('all attachments for resource "{res_id}" are listed')
def step_list_attachments(context: Context, res_id: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id
)
@when(
'project-filtered attachments for resource "{res_id}" in project "{project}" are listed'
)
def step_list_attachments_project(context: Context, res_id: str, project: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id,
project_name=project,
)
@when(
'plan-filtered attachments for resource "{res_id}" with plan "{plan_id}" are listed'
)
def step_list_attachments_plan(context: Context, res_id: str, plan_id: str) -> None:
context.returned_attachments = context.attachment_repo.list_for_resource(
resource_id=res_id,
plan_id=plan_id,
)
@when("the attachment is retrieved by its stored identifier")
def step_get_attachment_by_id(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
context.returned_attachment = context.attachment_repo.get_by_id(att_id)
# ---------------------------------------------------------------------------
# When: service layer
# ---------------------------------------------------------------------------
@given("a tool registry service backed by the repositories")
def step_tool_service(context: Context) -> None:
context.tool_service = ToolRegistryService(
tool_repo=context.tool_repo,
attachment_repo=context.attachment_repo,
)
@when("the tool is registered through the service")
def step_service_register(context: Context) -> None:
context.tool_service.register_tool(context.tool_dict)
context.tool_session.commit()
@given("the tool has been registered through the service")
def step_service_given_registered(context: Context) -> None:
context.tool_service.register_tool(context.tool_dict)
context.tool_session.commit()
@when('the tool is retrieved from the service by name "{name}"')
def step_service_get(context: Context, name: str) -> None:
context.service_returned_tool = context.tool_service.get_tool(name)
@when('the service attaches validation "{val_name}" to resource "{res_id}"')
def step_service_attach(context: Context, val_name: str, res_id: str) -> None:
context.service_attachment = context.tool_service.attach_validation(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when("the service detaches the validation attachment")
def step_service_detach(context: Context) -> None:
att_id = context.service_attachment["attachment_id"]
context.service_detach_result = context.tool_service.detach_validation(att_id)
context.tool_session.commit()
@given('the service has attached validation "{val_name}" to resource "{res_id}"')
def step_service_given_attached(context: Context, val_name: str, res_id: str) -> None:
context.service_attachment = context.tool_service.attach_validation(
validation_name=val_name,
resource_id=res_id,
mode="required",
)
context.tool_session.commit()
@when('the service lists validations for resource "{res_id}"')
def step_service_list_validations(context: Context, res_id: str) -> None:
context.service_returned_attachments = (
context.tool_service.list_validations_for_resource(
resource_id=res_id,
)
)
# ---------------------------------------------------------------------------
# Then assertions
# ---------------------------------------------------------------------------
@then("the tool registry repository should not raise an error")
def step_no_tool_error(context: Context) -> None:
assert context.last_error is None, f"Unexpected error: {context.last_error}"
@then('the persisted tool should have the registry name "{name}"')
def step_tool_name(context: Context, name: str) -> None:
tool = context.tool_repo.get_by_name(name)
assert tool is not None, f"Tool {name} not found"
assert tool["name"] == name
@then('the persisted tool should have source "{source}"')
def step_tool_source(context: Context, source: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["source"] == source
@then('the persisted tool should have tool_type "{tool_type}"')
def step_tool_type(context: Context, tool_type: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["tool_type"] == tool_type
@then("the persisted tool should have {count:d} resource binding")
def step_tool_binding_count(context: Context, count: int) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert len(tool.get("resource_bindings", [])) == count
@then('a DuplicateToolError should be raised mentioning "{name}"')
def step_dup_tool_error(context: Context, name: str) -> None:
assert isinstance(context.last_error, DuplicateToolError), (
f"Expected DuplicateToolError, got {type(context.last_error)}"
)
assert name in str(context.last_error)
@then('the returned tool should have the registry name "{name}"')
def step_returned_tool_name(context: Context, name: str) -> None:
assert context.returned_tool is not None
assert context.returned_tool["name"] == name
@then("no tool should be returned from the registry")
def step_no_tool_returned(context: Context) -> None:
assert context.returned_tool is None
@then("{count:d} tools should be returned from the registry")
def step_tool_count(context: Context, count: int) -> None:
assert len(context.returned_tools) == count
@then('the returned tool names from the registry should include "{name}"')
def step_tool_names_include(context: Context, name: str) -> None:
names = [t["name"] for t in context.returned_tools]
assert name in names
@then('the persisted tool should have description "{desc}"')
def step_tool_desc(context: Context, desc: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
assert tool["description"] == desc
@then('the persisted tool binding slot should be "{slot_name}"')
def step_tool_binding_slot(context: Context, slot_name: str) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is not None
bindings = tool.get("resource_bindings", [])
assert len(bindings) > 0
assert bindings[0]["slot_name"] == slot_name
@then("the removal should return true from the registry")
def step_removal_true(context: Context) -> None:
assert context.last_error is None
assert context.removal_result is True
@then("the removal should return false from the registry")
def step_removal_false(context: Context) -> None:
assert context.last_error is None
assert context.removal_result is False
@then("the tool should no longer exist in the registry")
def step_tool_not_found(context: Context) -> None:
name = context.tool_dict["name"]
tool = context.tool_repo.get_by_name(name)
assert tool is None
@then('a ToolInUseError should be raised mentioning "{name}"')
def step_tool_in_use_error(context: Context, name: str) -> None:
assert isinstance(context.last_error, ToolInUseError), (
f"Expected ToolInUseError, got {type(context.last_error)}"
)
assert name in str(context.last_error)
@then("the attachment should have a valid ULID identifier")
def step_attachment_ulid(context: Context) -> None:
att_id = context.last_attachment["attachment_id"]
assert len(att_id) == 26
@then('the attachment should reference resource "{res_id}"')
def step_attachment_resource(context: Context, res_id: str) -> None:
assert context.last_attachment["resource_id"] == res_id
@then("the detachment should return true")
def step_detach_true(context: Context) -> None:
assert context.detach_result is True
@then("the non-existent detachment should return false")
def step_detach_false(context: Context) -> None:
assert context.detach_result is False
@then("{count:d} attachment should be returned")
def step_attachment_count(context: Context, count: int) -> None:
assert len(context.returned_attachments) == count
@then('the retrieved attachment should reference validation "{val_name}"')
def step_retrieved_att_val(context: Context, val_name: str) -> None:
assert context.returned_attachment is not None
assert context.returned_attachment["validation_name"] == val_name
@then('the service-returned tool should have the registry name "{name}"')
def step_service_tool_name(context: Context, name: str) -> None:
assert context.service_returned_tool is not None
assert context.service_returned_tool["name"] == name
@then("the service detachment should return true")
def step_service_detach_result(context: Context) -> None:
assert context.service_detach_result is True
@then("the service should return {count:d} validation attachment")
def step_service_attachment_count(context: Context, count: int) -> None:
assert len(context.service_returned_attachments) == count