Files
agent-desktop/tests/contract/runner/test_control_command_contract.py
T

177 lines
5.8 KiB
Python

# -*- coding: utf-8 -*-
"""Contract tests for :class:`BaseControlCommandHandler` subclasses.
These tests pin down the contract that anything registered into the
control-command dispatch table must satisfy. When a future handler is
added (or the base interface drifts) these tests fail before integration
tests get a chance to surface a more confusing symptom.
Specifically:
- every subclass has a non-empty ``command_name`` starting with ``/``;
- every subclass implements an *async* ``handle`` returning ``str``;
- the global registry includes all six default handlers;
- the registry rejects empty ``command_name`` registrations.
"""
# pylint: disable=protected-access,redefined-outer-name,unused-argument
# pylint: disable=wrong-import-position,no-name-in-module,c-extension-no-member
# flake8: noqa: E402
from __future__ import annotations
import asyncio
import inspect
from typing import get_type_hints
import pytest
from pineagents.runtime.commands import control as control_commands
from pineagents.runtime.commands.control.base import (
BaseControlCommandHandler,
)
_EXPECTED_DEFAULTS = {
"/approval",
"/approve",
"/deny",
"/model",
"/skills",
"/stop",
}
def _all_subclasses(cls: type) -> set[type]:
"""Return *cls* and all transitively-defined subclasses."""
direct = set(cls.__subclasses__())
nested: set[type] = set()
for sub in direct:
nested.update(_all_subclasses(sub))
return direct | nested
# ---------------------------------------------------------------------------
# Discoverable subclasses
# ---------------------------------------------------------------------------
def _concrete_handlers() -> list[type[BaseControlCommandHandler]]:
"""Return all non-abstract subclasses of BaseControlCommandHandler."""
subs = _all_subclasses(BaseControlCommandHandler)
return [cls for cls in subs if not inspect.isabstract(cls)]
def test_at_least_six_concrete_handlers_are_discoverable():
# Sanity guard: if a refactor accidentally drops a default handler,
# this assertion catches it before the runtime dispatch does.
handlers = _concrete_handlers()
assert len(handlers) >= 6, (
"Expected at least 6 default control command handlers; "
f"found {len(handlers)}: "
f"{sorted(c.__name__ for c in handlers)}"
)
@pytest.mark.parametrize(
"handler_cls",
_concrete_handlers(),
ids=lambda c: c.__name__,
)
def test_handler_has_non_empty_slash_command_name(handler_cls):
name = handler_cls.command_name
assert isinstance(name, str), (
f"{handler_cls.__name__}.command_name must be str, "
f"got {type(name).__name__}"
)
assert name, f"{handler_cls.__name__}.command_name is empty"
assert name.startswith("/"), (
f"{handler_cls.__name__}.command_name must start with '/', "
f"got {name!r}"
)
# No whitespace inside the command token.
assert " " not in name.strip(
"/",
), f"{handler_cls.__name__}.command_name has whitespace: {name!r}"
@pytest.mark.parametrize(
"handler_cls",
_concrete_handlers(),
ids=lambda c: c.__name__,
)
def test_handler_handle_is_async_and_takes_context(handler_cls):
handle = getattr(handler_cls, "handle", None)
assert handle is not None, f"{handler_cls.__name__} missing handle()"
assert asyncio.iscoroutinefunction(
handle,
), f"{handler_cls.__name__}.handle must be async"
sig = inspect.signature(handle)
params = list(sig.parameters.values())
# (self, context) -> str
assert len(params) == 2, (
f"{handler_cls.__name__}.handle expected 2 params (self, context), "
f"got {[p.name for p in params]}"
)
# Return annotation should be ``str`` when present.
try:
hints = get_type_hints(handle)
except (NameError, TypeError):
hints = {}
if "return" in hints:
assert hints["return"] is str, (
f"{handler_cls.__name__}.handle should return str, "
f"annotated {hints['return']}"
)
# ---------------------------------------------------------------------------
# Registry expectations
# ---------------------------------------------------------------------------
def test_registry_contains_all_six_default_commands():
registered = set(control_commands._COMMAND_REGISTRY.keys())
missing = _EXPECTED_DEFAULTS - registered
assert not missing, f"Missing default control commands: {missing}"
@pytest.mark.parametrize("command", sorted(_EXPECTED_DEFAULTS))
def test_is_control_command_recognises_each_default(command):
assert control_commands.is_control_command(command) is True
# With trailing args, recognition still holds.
assert control_commands.is_control_command(f"{command} extra args") is True
def test_is_control_command_rejects_empty_and_non_command_input():
assert control_commands.is_control_command(None) is False
assert control_commands.is_control_command("") is False
assert control_commands.is_control_command("hello") is False
assert control_commands.is_control_command("/totally-bogus") is False
# Non-string defensively rejected.
bogus_input: object = 123
assert control_commands.is_control_command(bogus_input) is False
# ---------------------------------------------------------------------------
# register_command guard rails
# ---------------------------------------------------------------------------
def test_register_command_rejects_empty_command_name():
class _Bad(BaseControlCommandHandler):
command_name = "" # invalid
async def handle(self, context): # noqa: D401
return "noop"
with pytest.raises(ValueError):
control_commands.register_command(_Bad())
def test_unregister_unknown_command_returns_false():
assert control_commands.unregister_command("/never-registered") is False