Files
cleveragents-core/features/steps/skill_schema_steps.py
T
aditya 6d7b759f25
CI / coverage (pull_request) Has been skipped
CI / docker (pull_request) Has been skipped
CI / lint (pull_request) Failing after 11s
CI / typecheck (pull_request) Successful in 28s
CI / security (pull_request) Successful in 25s
CI / quality (pull_request) Successful in 17s
CI / integration_tests (pull_request) Successful in 5m29s
CI / build (pull_request) Successful in 16s
CI / unit_tests (pull_request) Successful in 16m9s
style(skill): apply ruff auto-formatting to skill schema files
2026-02-17 10:13:19 +00:00

654 lines
23 KiB
Python

"""Step definitions for skill YAML schema validation.
Tests for features/skill_schema.feature — validates the SkillConfigSchema
Pydantic model, YAML loading, key normalization, environment variable
interpolation, and error messages.
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
from behave import given, then, when # type: ignore[import-untyped]
from behave.runner import Context # type: ignore[import-untyped]
from pydantic import ValidationError
from cleveragents.skills.schema import SkillConfigSchema
# ────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────
_NAME_ONLY_YAML = """\
name: local/empty-skill
"""
_TOOLS_YAML = """\
name: local/file-reader
description: "Basic file reading operations"
tools:
- name: builtin/read_file
- name: builtin/list_directory
- name: builtin/search_files
"""
_FULL_YAML = """\
name: local/full-skill
description: "A fully populated skill"
tools:
- name: builtin/read_file
- name: builtin/write_file
writes: true
inline_tools:
- name: helper_tool
description: "Inline helper"
source: custom
code: |
def run(input_data):
return {"ok": True}
includes:
- name: local/file-reader
mcp_servers:
- name: github
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
agent_skill_folders:
- path: ./skills/deploy
"""
_TOOL_OVERRIDES_YAML = """\
name: local/tool-overrides
tools:
- name: builtin/shell_execute
description: "Execute commands in sandbox"
writes: true
checkpointable: false
"""
_FULL_INLINE_TOOL_YAML = """\
name: local/inline-full
inline_tools:
- name: run_migrations
description: "Run database migrations"
source: custom
code: |
def run(input_data):
return {"ok": True}
input_schema:
type: object
properties:
direction:
type: string
writes: true
checkpointable: true
side_effects: ["schema_mutation"]
"""
_STDIO_MCP_YAML = """\
name: local/mcp-stdio
mcp_servers:
- name: github
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-github"]
"""
_SSE_MCP_YAML = """\
name: local/mcp-sse
mcp_servers:
- name: remote-server
transport: sse
url: "https://mcp.example.com/sse"
headers:
Authorization: "Bearer token"
"""
_MCP_FILTER_YAML = """\
name: local/mcp-filter
mcp_servers:
- name: filtered
transport: stdio
command: npx
tool_filter:
include:
- tool_a
- tool_b
exclude:
- tool_c
"""
_AGENT_FOLDERS_YAML = """\
name: local/agent-folders
agent_skill_folders:
- path: ./skills/deploy
- path: ./skills/code-review-bundle
name: code-review
"""
_CAMEL_CASE_YAML = """\
name: local/camel-skill
inlineTools:
- name: my_tool
source: custom
code: |
def run(input_data):
return {}
mcpServers:
- name: srv
transport: stdio
command: echo
"""
# ────────────────────────────────────────────────────────────
# Given steps
# ────────────────────────────────────────────────────────────
@given("a skill YAML string with only the name field")
def step_given_name_only_yaml(context: Context) -> None:
"""Provide a minimal skill YAML with only name."""
context.skill_yaml_string = _NAME_ONLY_YAML
@given("a skill YAML string with tool references")
def step_given_tools_yaml(context: Context) -> None:
"""Provide a skill YAML with tool references."""
context.skill_yaml_string = _TOOLS_YAML
@given("a skill YAML string with all sections populated")
def step_given_full_yaml(context: Context) -> None:
"""Provide a fully populated skill YAML."""
context.skill_yaml_string = _FULL_YAML
@given('the skill YAML file "{rel_path}"')
def step_given_skill_yaml_file(context: Context, rel_path: str) -> None:
"""Set the path to a skill YAML file relative to project root."""
project_root = Path(__file__).resolve().parents[2]
context.skill_yaml_file = str(project_root / rel_path)
@given("a skill YAML string with tool overrides")
def step_given_tool_overrides_yaml(context: Context) -> None:
"""Provide a skill YAML with tool reference overrides."""
context.skill_yaml_string = _TOOL_OVERRIDES_YAML
@given("a skill YAML string with a full inline tool")
def step_given_full_inline_tool_yaml(context: Context) -> None:
"""Provide a skill YAML with a fully populated inline tool."""
context.skill_yaml_string = _FULL_INLINE_TOOL_YAML
@given("a skill YAML string with a stdio MCP server")
def step_given_stdio_mcp_yaml(context: Context) -> None:
"""Provide a skill YAML with a stdio MCP server."""
context.skill_yaml_string = _STDIO_MCP_YAML
@given("a skill YAML string with an SSE MCP server")
def step_given_sse_mcp_yaml(context: Context) -> None:
"""Provide a skill YAML with an SSE MCP server."""
context.skill_yaml_string = _SSE_MCP_YAML
@given("a skill YAML string with MCP tool filter")
def step_given_mcp_filter_yaml(context: Context) -> None:
"""Provide a skill YAML with MCP tool filter."""
context.skill_yaml_string = _MCP_FILTER_YAML
@given("a skill YAML string with agent skill folders")
def step_given_agent_folders_yaml(context: Context) -> None:
"""Provide a skill YAML with agent skill folders."""
context.skill_yaml_string = _AGENT_FOLDERS_YAML
@given("a skill YAML string missing the name field")
def step_given_missing_name_yaml(context: Context) -> None:
"""Provide a skill YAML without the name field."""
context.skill_yaml_string = "description: Missing the name\n"
@given('a skill YAML string with name "{name}"')
def step_given_yaml_with_name(context: Context, name: str) -> None:
"""Provide a skill YAML with a custom name value."""
context.skill_yaml_string = f"name: {name}\n"
@given('a skill YAML string with an invalid tool ref name "{name}"')
def step_given_invalid_tool_ref(context: Context, name: str) -> None:
"""Provide a skill YAML with an invalid tool reference name."""
context.skill_yaml_string = f"""\
name: local/bad-ref
tools:
- name: {name}
"""
@given("a skill YAML string with an inline tool missing source")
def step_given_inline_missing_source(context: Context) -> None:
"""Provide a skill YAML with an inline tool missing the source field."""
context.skill_yaml_string = """\
name: local/bad-inline
inline_tools:
- name: my_tool
code: |
def run(input_data):
return {}
"""
@given("a skill YAML string with an inline tool missing code")
def step_given_inline_missing_code(context: Context) -> None:
"""Provide a skill YAML with an inline tool missing the code field."""
context.skill_yaml_string = """\
name: local/bad-inline
inline_tools:
- name: my_tool
source: custom
"""
@given('a skill YAML string with an inline tool source "{source}"')
def step_given_inline_invalid_source(context: Context, source: str) -> None:
"""Provide a skill YAML with an invalid inline tool source."""
context.skill_yaml_string = f"""\
name: local/bad-source
inline_tools:
- name: my_tool
source: {source}
code: |
def run(input_data):
return {{}}
"""
@given('a skill YAML string with an invalid include name "{name}"')
def step_given_invalid_include(context: Context, name: str) -> None:
"""Provide a skill YAML with an invalid include name."""
context.skill_yaml_string = f"""\
name: local/bad-include
includes:
- name: {name}
"""
@given('a skill YAML string with MCP transport "{transport}"')
def step_given_invalid_mcp_transport(context: Context, transport: str) -> None:
"""Provide a skill YAML with an invalid MCP transport."""
context.skill_yaml_string = f"""\
name: local/bad-mcp
mcp_servers:
- name: srv
transport: {transport}
"""
@given("a skill YAML string with MCP server missing transport")
def step_given_mcp_missing_transport(context: Context) -> None:
"""Provide a skill YAML with an MCP server missing transport."""
context.skill_yaml_string = """\
name: local/bad-mcp
mcp_servers:
- name: srv
"""
@given("a skill YAML string with agent folder missing path")
def step_given_agent_folder_missing_path(context: Context) -> None:
"""Provide a skill YAML with an agent folder missing path."""
context.skill_yaml_string = """\
name: local/bad-folder
agent_skill_folders:
- name: missing-path-folder
"""
@given("a skill YAML string with an unknown top-level field")
def step_given_unknown_field(context: Context) -> None:
"""Provide a skill YAML with an extra unknown field."""
context.skill_yaml_string = """\
name: local/extra-field
unknown_field: should fail
"""
@given("a None skill YAML string")
def step_given_none_yaml(context: Context) -> None:
"""Set YAML string to None."""
context.skill_yaml_string = None # type: ignore[assignment]
@given("an empty skill YAML string")
def step_given_empty_yaml(context: Context) -> None:
"""Set YAML string to empty."""
context.skill_yaml_string = ""
@given("a skill YAML string that is a list")
def step_given_yaml_list(context: Context) -> None:
"""Set YAML string to a list instead of a mapping."""
context.skill_yaml_string = "- item1\n- item2\n"
@given("a None skill YAML file path")
def step_given_none_file_path(context: Context) -> None:
"""Set file path to None."""
context.skill_yaml_file = None # type: ignore[assignment]
@given('the skill YAML directory path "{dir_path}"')
def step_given_yaml_directory_path(context: Context, dir_path: str) -> None:
"""Set file path to a directory."""
project_root = Path(__file__).resolve().parents[2]
context.skill_yaml_file = str(project_root / dir_path)
@given("a skill YAML string with camelCase keys")
def step_given_camel_case_yaml(context: Context) -> None:
"""Provide a skill YAML with camelCase keys."""
context.skill_yaml_string = _CAMEL_CASE_YAML
@given('a skill YAML string using env var "${{{var}}}" for mcp command')
def step_given_yaml_with_env_var(context: Context, var: str) -> None:
"""Provide a skill YAML with an environment variable reference in MCP."""
context.skill_yaml_string = f"""\
name: local/env-skill
mcp_servers:
- name: srv
transport: stdio
command: ${{{var}}}
"""
# ────────────────────────────────────────────────────────────
# When steps
# ────────────────────────────────────────────────────────────
@when("I validate the skill schema")
def step_when_validate_skill_schema(context: Context) -> None:
"""Validate the skill YAML string via SkillConfigSchema."""
try:
context.skill_config = SkillConfigSchema.from_yaml(context.skill_yaml_string)
context.skill_schema_error = None
except (ValidationError, ValueError) as exc:
context.skill_config = None
context.skill_schema_error = str(exc)
@when("I validate the skill schema from file")
def step_when_validate_from_file(context: Context) -> None:
"""Validate a skill YAML file via SkillConfigSchema."""
try:
context.skill_config = SkillConfigSchema.from_yaml_file(context.skill_yaml_file)
context.skill_schema_error = None
except (ValidationError, ValueError, FileNotFoundError) as exc:
context.skill_config = None
context.skill_schema_error = str(exc)
@when("I validate the skill schema expecting failure")
def step_when_validate_expecting_failure(context: Context) -> None:
"""Validate the skill YAML, expecting it to fail."""
try:
context.skill_config = SkillConfigSchema.from_yaml(context.skill_yaml_string)
context.skill_schema_error = None
except (ValidationError, ValueError) as exc:
context.skill_config = None
context.skill_schema_error = str(exc)
@when("I validate the skill schema from file expecting failure")
def step_when_validate_file_expecting_failure(context: Context) -> None:
"""Validate a skill YAML file, expecting it to fail."""
try:
context.skill_config = SkillConfigSchema.from_yaml_file(context.skill_yaml_file)
context.skill_schema_error = None
except (ValidationError, ValueError, FileNotFoundError, TypeError) as exc:
context.skill_config = None
context.skill_schema_error = str(exc)
# ────────────────────────────────────────────────────────────
# Then steps
# ────────────────────────────────────────────────────────────
@then("the skill schema validation should succeed")
def step_then_validation_succeeds(context: Context) -> None:
"""Assert that schema validation succeeded."""
assert context.skill_config is not None, (
f"Expected validation to succeed but got error: {context.skill_schema_error}"
)
@then("the skill schema validation should fail")
def step_then_validation_fails(context: Context) -> None:
"""Assert that schema validation failed."""
assert context.skill_config is None, "Expected validation to fail but it succeeded"
assert context.skill_schema_error is not None
@then('the skill schema error should mention "{text}"')
def step_then_error_mentions(context: Context, text: str) -> None:
"""Assert the error message contains expected text."""
assert context.skill_schema_error is not None, "No error was captured"
assert text.lower() in context.skill_schema_error.lower(), (
f"Expected error to mention '{text}', got: {context.skill_schema_error}"
)
@then('the skill config name should be "{expected}"')
def step_then_config_name(context: Context, expected: str) -> None:
"""Assert the parsed config name."""
assert context.skill_config is not None
assert context.skill_config.name == expected
@then('the skill config description should be "{expected}"')
def step_then_config_description(context: Context, expected: str) -> None:
"""Assert the parsed config description."""
assert context.skill_config is not None
assert context.skill_config.description == expected
@then("the skill config should have {count:d} tools")
def step_then_tool_count(context: Context, count: int) -> None:
"""Assert the number of tool references."""
assert context.skill_config is not None
assert len(context.skill_config.tools) == count
@then("the skill config should have {count:d} inline tools")
def step_then_inline_tool_count(context: Context, count: int) -> None:
"""Assert the number of inline tools."""
assert context.skill_config is not None
assert len(context.skill_config.inline_tools) == count
@then("the skill config should have {count:d} includes")
def step_then_include_count(context: Context, count: int) -> None:
"""Assert the number of includes."""
assert context.skill_config is not None
assert len(context.skill_config.includes) == count
@then("the skill config should have {count:d} mcp servers")
def step_then_mcp_count(context: Context, count: int) -> None:
"""Assert the number of MCP servers."""
assert context.skill_config is not None
assert len(context.skill_config.mcp_servers) == count
@then("the skill config should have {count:d} agent skill folders")
def step_then_agent_folder_count(context: Context, count: int) -> None:
"""Assert the number of agent skill folders."""
assert context.skill_config is not None
assert len(context.skill_config.agent_skill_folders) == count
@then("the skill config tools list should be empty")
def step_then_tools_empty(context: Context) -> None:
"""Assert tools list is empty."""
assert context.skill_config is not None
assert context.skill_config.tools == []
@then("the skill config inline_tools list should be empty")
def step_then_inline_tools_empty(context: Context) -> None:
"""Assert inline_tools list is empty."""
assert context.skill_config is not None
assert context.skill_config.inline_tools == []
@then("the skill config includes list should be empty")
def step_then_includes_empty(context: Context) -> None:
"""Assert includes list is empty."""
assert context.skill_config is not None
assert context.skill_config.includes == []
@then("the skill config mcp_servers list should be empty")
def step_then_mcp_servers_empty(context: Context) -> None:
"""Assert mcp_servers list is empty."""
assert context.skill_config is not None
assert context.skill_config.mcp_servers == []
@then("the skill config agent_skill_folders list should be empty")
def step_then_agent_folders_empty(context: Context) -> None:
"""Assert agent_skill_folders list is empty."""
assert context.skill_config is not None
assert context.skill_config.agent_skill_folders == []
@then('skill tool {idx:d} name should be "{expected}"')
def step_then_tool_name(context: Context, idx: int, expected: str) -> None:
"""Assert a tool reference name by index."""
assert context.skill_config is not None
assert context.skill_config.tools[idx].name == expected
@then('skill tool {idx:d} description should be "{expected}"')
def step_then_tool_description(context: Context, idx: int, expected: str) -> None:
"""Assert a tool reference description by index."""
assert context.skill_config is not None
assert context.skill_config.tools[idx].description == expected
@then("skill tool {idx:d} writes should be true")
def step_then_tool_writes_true(context: Context, idx: int) -> None:
"""Assert a tool reference writes flag is True."""
assert context.skill_config is not None
assert context.skill_config.tools[idx].writes is True
@then("skill tool {idx:d} checkpointable should be false")
def step_then_tool_checkpointable_false(context: Context, idx: int) -> None:
"""Assert a tool reference checkpointable flag is False."""
assert context.skill_config is not None
assert context.skill_config.tools[idx].checkpointable is False
@then('skill inline tool {idx:d} name should be "{expected}"')
def step_then_inline_name(context: Context, idx: int, expected: str) -> None:
"""Assert an inline tool name by index."""
assert context.skill_config is not None
assert context.skill_config.inline_tools[idx].name == expected
@then('skill inline tool {idx:d} source should be "{expected}"')
def step_then_inline_source(context: Context, idx: int, expected: str) -> None:
"""Assert an inline tool source by index."""
assert context.skill_config is not None
assert context.skill_config.inline_tools[idx].source == expected
@then("skill inline tool {idx:d} writes should be true")
def step_then_inline_writes_true(context: Context, idx: int) -> None:
"""Assert an inline tool writes flag is True."""
assert context.skill_config is not None
assert context.skill_config.inline_tools[idx].writes is True
@then("skill inline tool {idx:d} checkpointable should be true")
def step_then_inline_checkpointable_true(context: Context, idx: int) -> None:
"""Assert an inline tool checkpointable flag is True."""
assert context.skill_config is not None
assert context.skill_config.inline_tools[idx].checkpointable is True
@then('skill mcp server {idx:d} name should be "{expected}"')
def step_then_mcp_name(context: Context, idx: int, expected: str) -> None:
"""Assert an MCP server name by index."""
assert context.skill_config is not None
assert context.skill_config.mcp_servers[idx].name == expected
@then('skill mcp server {idx:d} transport should be "{expected}"')
def step_then_mcp_transport(context: Context, idx: int, expected: str) -> None:
"""Assert an MCP server transport by index."""
assert context.skill_config is not None
assert context.skill_config.mcp_servers[idx].transport == expected
@then('skill mcp server {idx:d} command should be "{expected}"')
def step_then_mcp_command(context: Context, idx: int, expected: str) -> None:
"""Assert an MCP server command by index."""
assert context.skill_config is not None
assert context.skill_config.mcp_servers[idx].command == expected
@then('skill mcp server {idx:d} url should be "{expected}"')
def step_then_mcp_url(context: Context, idx: int, expected: str) -> None:
"""Assert an MCP server url by index."""
assert context.skill_config is not None
assert context.skill_config.mcp_servers[idx].url == expected
@then("skill mcp server {idx:d} tool filter include should have {count:d} items")
def step_then_mcp_filter_include_count(context: Context, idx: int, count: int) -> None:
"""Assert the number of tool filter include entries."""
assert context.skill_config is not None
mcp = context.skill_config.mcp_servers[idx]
assert mcp.tool_filter is not None
assert len(mcp.tool_filter.include) == count
@then("skill mcp server {idx:d} tool filter exclude should have {count:d} items")
def step_then_mcp_filter_exclude_count(context: Context, idx: int, count: int) -> None:
"""Assert the number of tool filter exclude entries."""
assert context.skill_config is not None
mcp = context.skill_config.mcp_servers[idx]
assert mcp.tool_filter is not None
assert len(mcp.tool_filter.exclude) == count
@then('skill agent folder {idx:d} path should be "{expected}"')
def step_then_agent_folder_path(context: Context, idx: int, expected: str) -> None:
"""Assert an agent skill folder path by index."""
assert context.skill_config is not None
assert context.skill_config.agent_skill_folders[idx].path == expected
@then('skill agent folder {idx:d} name should be "{expected}"')
def step_then_agent_folder_name(context: Context, idx: int, expected: str) -> None:
"""Assert an agent skill folder name by index."""
assert context.skill_config is not None
assert context.skill_config.agent_skill_folders[idx].name == expected
@then('the skill config model_dump should contain key "{key}"')
def step_then_model_dump_contains_key(context: Context, key: str) -> None:
"""Assert the model_dump dict contains a key."""
assert context.skill_config is not None
data: dict[str, Any] = context.skill_config.model_dump()
assert key in data, f"Key '{key}' not found in model_dump: {list(data.keys())}"