4ad51561fc
Cap time.sleep and asyncio.sleep globally at 10ms in before_all to eliminate retry/backoff waits (tenacity wait_fixed, retry_auto_debug exponential backoff). Save originals as time._original_sleep and asyncio._original_sleep for tests that need real wall-clock delays (CircuitBreaker recovery, debounce timers, validation timeouts). Enhance template-DB patch: add "db." prefix to _SCENARIO_DB_PREFIXES and check db_path.stat().st_size > 0 so 0-byte auto-created SQLite files receive the template copy instead of falling through to real migrations. Replace subprocess.run CLI invocations with typer.testing.CliRunner in module_coverage, main_coverage_complete, and coverage_extras step files, eliminating ~6s Python cold-start overhead per call. Switch plan_persistence and action_persistence to in-memory SQLite by default; only cross-restart scenarios use file-based via an explicit Given step. Replace MigrationRunner.run_migrations with Base.metadata.create_all in plan_service_steps for freshly created in-memory engines. Removed leftover debug comment in environment.py. Total tier runtime: 565s -> 21s (96% reduction), 20 features all under 5s behave-internal time. ISSUES CLOSED: #480
361 lines
13 KiB
Python
361 lines
13 KiB
Python
"""Step definitions for features/skill_refresh.feature."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
from behave import given, then, when
|
|
from behave.runner import Context
|
|
|
|
from cleveragents.domain.models.core.skill import Skill
|
|
from cleveragents.mcp.adapter import MCPServerConfig, MCPToolAdapter
|
|
from cleveragents.mcp.refresh_hook import MCPRefreshHook
|
|
from cleveragents.skills.context import SkillExecutionError
|
|
from cleveragents.skills.protocol import SkillDefinition, SkillMetadata
|
|
from cleveragents.skills.refresh import SkillRefreshResult
|
|
from cleveragents.skills.registry import SkillRegistry
|
|
from cleveragents.tool.registry import ToolRegistry
|
|
from features.mocks.mock_mcp_transport import MockMCPTransport
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_skill(
|
|
name: str,
|
|
tool_refs: list[str] | None = None,
|
|
description: str = "Test skill",
|
|
) -> SkillDefinition:
|
|
skill = Skill(
|
|
name=name,
|
|
description=description,
|
|
tool_refs=tool_refs or [],
|
|
)
|
|
metadata = SkillMetadata.from_skill(skill)
|
|
return SkillDefinition(skill=skill, metadata=metadata)
|
|
|
|
|
|
def _register_skill(
|
|
context: Context,
|
|
name: str,
|
|
tool_refs: list[str] | None = None,
|
|
) -> None:
|
|
defn = _make_skill(name, tool_refs=tool_refs)
|
|
context.skill_registry.register(defn)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — SkillRefreshResult construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given(
|
|
"a skill refresh result with {refreshed:d} refreshed, {failed:d} failed, {skipped:d} skipped"
|
|
)
|
|
def step_build_refresh_result(
|
|
context: Context,
|
|
refreshed: int,
|
|
failed: int,
|
|
skipped: int,
|
|
) -> None:
|
|
refreshed_names = [f"local/skill-{i}" for i in range(refreshed)]
|
|
failed_names = {f"local/fail-{i}": "error" for i in range(failed)}
|
|
skipped_names = [f"local/skip-{i}" for i in range(skipped)]
|
|
context.refresh_result = SkillRefreshResult(
|
|
refreshed=refreshed_names,
|
|
failed=failed_names,
|
|
skipped=skipped_names,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — SkillRegistry construction
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a skill registry without a tool registry")
|
|
def step_registry_no_tool_registry(context: Context) -> None:
|
|
context.skill_registry = SkillRegistry()
|
|
context.skill_error = None
|
|
context.refresh_result = None
|
|
context.refresh_count = 0
|
|
|
|
|
|
@given("an empty skill registry without a tool registry")
|
|
def step_empty_registry_no_tool_registry(context: Context) -> None:
|
|
context.skill_registry = SkillRegistry()
|
|
context.skill_error = None
|
|
context.refresh_result = None
|
|
|
|
|
|
@given('a skill registry with a tool registry containing "{tool_name}"')
|
|
def step_registry_with_tool_registry(context: Context, tool_name: str) -> None:
|
|
from cleveragents.tool.runtime import ToolSpec
|
|
|
|
tool_registry = ToolRegistry()
|
|
spec = ToolSpec(
|
|
name=tool_name,
|
|
description=f"Mock tool {tool_name}",
|
|
input_schema={},
|
|
handler=lambda **kwargs: {"result": "ok"},
|
|
source="local",
|
|
)
|
|
tool_registry.register(spec)
|
|
context.skill_registry = SkillRegistry(tool_registry=tool_registry)
|
|
context.skill_error = None
|
|
context.refresh_result = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — Skill registration
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given('a registered skill "{name}" with tool refs "{refs_csv}"')
|
|
def step_registered_skill_with_refs(
|
|
context: Context,
|
|
name: str,
|
|
refs_csv: str,
|
|
) -> None:
|
|
tool_refs = [r.strip() for r in refs_csv.split(",")]
|
|
_register_skill(context, name, tool_refs=tool_refs)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Given — MCP adapter and hook
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@given("a fresh MCP adapter for notification testing")
|
|
def step_mcp_adapter_mock(context: Context) -> None:
|
|
context.mcp_transport = MockMCPTransport()
|
|
config = MCPServerConfig(name="test-server", transport="stdio", command="echo")
|
|
context.mcp_adapter = MCPToolAdapter(config=config, transport=context.mcp_transport)
|
|
context.received_notifications: list[tuple[str, dict[str, Any]]] = []
|
|
if not hasattr(context, "skill_registry"):
|
|
context.skill_registry = SkillRegistry()
|
|
context.refresh_count = 0
|
|
|
|
|
|
@given("a notification listener registered on the adapter")
|
|
def step_notification_listener(context: Context) -> None:
|
|
def _listener(method: str, params: dict[str, Any]) -> None:
|
|
context.received_notifications.append((method, params))
|
|
|
|
context.mcp_adapter.add_notification_listener(_listener)
|
|
|
|
|
|
@given(
|
|
"an MCPRefreshHook connecting the adapter to the skill registry with debounce {debounce:f}"
|
|
)
|
|
def step_mcp_refresh_hook(context: Context, debounce: float) -> None:
|
|
original_refresh_all = context.skill_registry.refresh_all
|
|
refresh_call_count = [0]
|
|
|
|
def _counting_refresh_all() -> SkillRefreshResult:
|
|
refresh_call_count[0] += 1
|
|
return original_refresh_all()
|
|
|
|
context.skill_registry.refresh_all = _counting_refresh_all # type: ignore[method-assign]
|
|
context.refresh_call_count = refresh_call_count
|
|
context.mcp_hook = MCPRefreshHook(
|
|
adapter=context.mcp_adapter,
|
|
skill_registry=context.skill_registry,
|
|
debounce_seconds=debounce,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When — SkillRegistry.refresh(name)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('I refresh skill "{name}"')
|
|
def step_refresh_skill(context: Context, name: str) -> None:
|
|
try:
|
|
context.refresh_result = context.skill_registry.refresh(name)
|
|
context.skill_error = None
|
|
except SkillExecutionError as exc:
|
|
context.skill_error = exc
|
|
context.refresh_result = None
|
|
|
|
|
|
@when('I refresh skill "{name}" and capture log warnings')
|
|
def step_refresh_skill_capture_warnings(context: Context, name: str) -> None:
|
|
captured: list[logging.LogRecord] = []
|
|
|
|
class _CapHandler(logging.Handler):
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
if record.levelno >= logging.WARNING:
|
|
captured.append(record)
|
|
|
|
handler = _CapHandler()
|
|
root_logger = logging.getLogger("cleveragents.skills.registry")
|
|
root_logger.addHandler(handler)
|
|
try:
|
|
context.refresh_result = context.skill_registry.refresh(name)
|
|
context.skill_error = None
|
|
except SkillExecutionError as exc:
|
|
context.skill_error = exc
|
|
finally:
|
|
root_logger.removeHandler(handler)
|
|
context.captured_log_warnings = captured
|
|
|
|
|
|
@when('I try to refresh non-existent skill "{name}"')
|
|
def step_try_refresh_nonexistent(context: Context, name: str) -> None:
|
|
try:
|
|
context.refresh_result = context.skill_registry.refresh(name)
|
|
context.skill_error = None
|
|
except SkillExecutionError as exc:
|
|
context.skill_error = exc
|
|
context.refresh_result = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When — SkillRegistry.refresh_all()
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when("I call refresh_all on the skill registry")
|
|
def step_refresh_all(context: Context) -> None:
|
|
context.refresh_result = context.skill_registry.refresh_all()
|
|
context.skill_error = None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# When — MCP notifications
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@when('the adapter dispatches "{method}" notification')
|
|
def step_dispatch_notification(context: Context, method: str) -> None:
|
|
context.mcp_adapter.dispatch_notification(method, {})
|
|
|
|
|
|
@when("I wait for {seconds:f} seconds for debounce to fire")
|
|
def step_wait_seconds(context: Context, seconds: float) -> None:
|
|
_sleep = getattr(time, "_original_sleep", time.sleep)
|
|
_sleep(seconds)
|
|
|
|
|
|
@when("I cancel the MCPRefreshHook immediately")
|
|
def step_cancel_hook(context: Context) -> None:
|
|
context.mcp_hook.cancel()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — SkillRefreshResult assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then("the refresh result total_refreshed should be {count:d}")
|
|
def step_assert_total_refreshed(context: Context, count: int) -> None:
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "No refresh result available"
|
|
assert result.total_refreshed == count, (
|
|
f"Expected total_refreshed={count}, got {result.total_refreshed}"
|
|
)
|
|
|
|
|
|
@then("the refresh result total_failed should be {count:d}")
|
|
def step_assert_total_failed(context: Context, count: int) -> None:
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "No refresh result available"
|
|
assert result.total_failed == count, (
|
|
f"Expected total_failed={count}, got {result.total_failed}"
|
|
)
|
|
|
|
|
|
@then("the refresh result total_skipped should be {count:d}")
|
|
def step_assert_total_skipped(context: Context, count: int) -> None:
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "No refresh result available"
|
|
assert result.total_skipped == count, (
|
|
f"Expected total_skipped={count}, got {result.total_skipped}"
|
|
)
|
|
|
|
|
|
@then('the refresh result summary should be "{expected}"')
|
|
def step_assert_summary(context: Context, expected: str) -> None:
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "No refresh result available"
|
|
actual = result.to_summary()
|
|
assert actual == expected, f"Expected summary '{expected}', got '{actual}'"
|
|
|
|
|
|
@then('the refresh result failed skill "{name}" error mentions "{fragment}"')
|
|
def step_assert_failed_skill_error(context: Context, name: str, fragment: str) -> None:
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "No refresh result available"
|
|
assert name in result.failed, (
|
|
f"Skill '{name}' not in failed dict. Keys: {list(result.failed.keys())}"
|
|
)
|
|
error_msg = result.failed[name]
|
|
assert fragment in error_msg, f"Expected '{fragment}' in error '{error_msg}'"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Error assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('a SkillExecutionError is raised mentioning "{fragment}"')
|
|
def step_assert_skill_execution_error(context: Context, fragment: str) -> None:
|
|
assert context.skill_error is not None, "Expected SkillExecutionError, got none"
|
|
assert isinstance(context.skill_error, SkillExecutionError), (
|
|
f"Expected SkillExecutionError, got {type(context.skill_error)}"
|
|
)
|
|
assert fragment in str(context.skill_error), (
|
|
f"Expected '{fragment}' in error: {context.skill_error}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — Log warning assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('no "tool registry" warning is logged during refresh without tool registry')
|
|
def step_assert_no_tool_registry_warning(context: Context) -> None:
|
|
# When no tool registry is configured, the refresh still completes (skill
|
|
# is placed in 'refreshed') but no warning should fire for the no-tool-
|
|
# registry-unavailable message because the registry is simply absent —
|
|
# there's nothing to warn about. This step confirms the result is valid.
|
|
result: SkillRefreshResult = context.refresh_result
|
|
assert result is not None, "Expected a refresh result"
|
|
assert result.total_refreshed >= 1 or result.total_skipped >= 1, (
|
|
"Expected skill to be refreshed or skipped, got neither"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Then — MCP notification assertions
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@then('the notification listener should have been called with method "{method}"')
|
|
def step_assert_notification_called(context: Context, method: str) -> None:
|
|
notifications: list[tuple[str, dict[str, Any]]] = context.received_notifications
|
|
methods = [n[0] for n in notifications]
|
|
assert method in methods, (
|
|
f"Expected notification '{method}' to be received. Got: {methods}"
|
|
)
|
|
|
|
|
|
@then("the skill registry refresh_all should have been triggered")
|
|
def step_assert_refresh_all_triggered(context: Context) -> None:
|
|
count = context.refresh_call_count[0]
|
|
assert count >= 1, f"Expected refresh_all to be called at least once, got {count}"
|
|
|
|
|
|
@then("the skill registry refresh count should be {count:d}")
|
|
def step_assert_refresh_count(context: Context, count: int) -> None:
|
|
actual = context.refresh_call_count[0]
|
|
assert actual == count, (
|
|
f"Expected refresh_all to be called {count} time(s), got {actual}"
|
|
)
|