196 lines
7.5 KiB
Python
196 lines
7.5 KiB
Python
|
|
"""Tests for KIN-037: tech_researcher specialist — YAML validation and prompt structure."""
|
||
|
|
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
SPECIALISTS_YAML = Path(__file__).parent.parent / "agents" / "specialists.yaml"
|
||
|
|
PROMPTS_DIR = Path(__file__).parent.parent / "agents" / "prompts"
|
||
|
|
TECH_RESEARCHER_PROMPT = PROMPTS_DIR / "tech_researcher.md"
|
||
|
|
|
||
|
|
REQUIRED_SPECIALIST_FIELDS = {"name", "model", "tools", "description", "permissions"}
|
||
|
|
REQUIRED_OUTPUT_SCHEMA_FIELDS = {
|
||
|
|
"status", "api_overview", "endpoints", "rate_limits", "auth_method",
|
||
|
|
"data_schemas", "limitations", "gotchas", "codebase_diff", "notes",
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def spec():
|
||
|
|
"""Load and parse specialists.yaml once for all tests."""
|
||
|
|
return yaml.safe_load(SPECIALISTS_YAML.read_text())
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def tech_researcher(spec):
|
||
|
|
return spec["specialists"]["tech_researcher"]
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture(scope="module")
|
||
|
|
def prompt_text():
|
||
|
|
return TECH_RESEARCHER_PROMPT.read_text()
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# YAML validity
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestSpecialistsYaml:
|
||
|
|
def test_yaml_parses_without_error(self):
|
||
|
|
content = SPECIALISTS_YAML.read_text()
|
||
|
|
parsed = yaml.safe_load(content)
|
||
|
|
assert parsed is not None
|
||
|
|
|
||
|
|
def test_yaml_has_specialists_key(self, spec):
|
||
|
|
assert "specialists" in spec
|
||
|
|
|
||
|
|
def test_yaml_has_routes_key(self, spec):
|
||
|
|
assert "routes" in spec
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# tech_researcher entry structure
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestTechResearcherEntry:
|
||
|
|
def test_tech_researcher_exists_in_specialists(self, spec):
|
||
|
|
assert "tech_researcher" in spec["specialists"]
|
||
|
|
|
||
|
|
def test_tech_researcher_has_required_fields(self, tech_researcher):
|
||
|
|
missing = REQUIRED_SPECIALIST_FIELDS - set(tech_researcher.keys())
|
||
|
|
assert not missing, f"Missing fields: {missing}"
|
||
|
|
|
||
|
|
def test_tech_researcher_name_is_string(self, tech_researcher):
|
||
|
|
assert isinstance(tech_researcher["name"], str)
|
||
|
|
assert tech_researcher["name"].strip()
|
||
|
|
|
||
|
|
def test_tech_researcher_model_is_sonnet(self, tech_researcher):
|
||
|
|
assert tech_researcher["model"] == "sonnet"
|
||
|
|
|
||
|
|
def test_tech_researcher_tools_is_list(self, tech_researcher):
|
||
|
|
assert isinstance(tech_researcher["tools"], list)
|
||
|
|
assert len(tech_researcher["tools"]) > 0
|
||
|
|
|
||
|
|
def test_tech_researcher_tools_include_webfetch(self, tech_researcher):
|
||
|
|
assert "WebFetch" in tech_researcher["tools"]
|
||
|
|
|
||
|
|
def test_tech_researcher_tools_include_read_grep_glob(self, tech_researcher):
|
||
|
|
for tool in ("Read", "Grep", "Glob"):
|
||
|
|
assert tool in tech_researcher["tools"], f"Missing tool: {tool}"
|
||
|
|
|
||
|
|
def test_tech_researcher_permissions_is_read_only(self, tech_researcher):
|
||
|
|
assert tech_researcher["permissions"] == "read_only"
|
||
|
|
|
||
|
|
def test_tech_researcher_description_is_non_empty_string(self, tech_researcher):
|
||
|
|
assert isinstance(tech_researcher["description"], str)
|
||
|
|
assert len(tech_researcher["description"]) > 10
|
||
|
|
|
||
|
|
def test_tech_researcher_has_output_schema(self, tech_researcher):
|
||
|
|
assert "output_schema" in tech_researcher
|
||
|
|
|
||
|
|
def test_tech_researcher_output_schema_has_required_fields(self, tech_researcher):
|
||
|
|
schema = tech_researcher["output_schema"]
|
||
|
|
missing = REQUIRED_OUTPUT_SCHEMA_FIELDS - set(schema.keys())
|
||
|
|
assert not missing, f"Missing output_schema fields: {missing}"
|
||
|
|
|
||
|
|
def test_tech_researcher_context_rules_decisions_is_list(self, tech_researcher):
|
||
|
|
decisions = tech_researcher.get("context_rules", {}).get("decisions")
|
||
|
|
assert isinstance(decisions, list)
|
||
|
|
|
||
|
|
def test_tech_researcher_context_rules_includes_gotcha(self, tech_researcher):
|
||
|
|
decisions = tech_researcher.get("context_rules", {}).get("decisions", [])
|
||
|
|
assert "gotcha" in decisions
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# api_research route
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestApiResearchRoute:
|
||
|
|
def test_api_research_route_exists(self, spec):
|
||
|
|
assert "api_research" in spec["routes"]
|
||
|
|
|
||
|
|
def test_api_research_route_has_steps(self, spec):
|
||
|
|
route = spec["routes"]["api_research"]
|
||
|
|
assert "steps" in route
|
||
|
|
assert isinstance(route["steps"], list)
|
||
|
|
assert len(route["steps"]) >= 1
|
||
|
|
|
||
|
|
def test_api_research_route_starts_with_tech_researcher(self, spec):
|
||
|
|
steps = spec["routes"]["api_research"]["steps"]
|
||
|
|
assert steps[0] == "tech_researcher"
|
||
|
|
|
||
|
|
def test_api_research_route_includes_architect(self, spec):
|
||
|
|
steps = spec["routes"]["api_research"]["steps"]
|
||
|
|
assert "architect" in steps
|
||
|
|
|
||
|
|
def test_api_research_route_has_description(self, spec):
|
||
|
|
route = spec["routes"]["api_research"]
|
||
|
|
assert "description" in route
|
||
|
|
assert isinstance(route["description"], str)
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Prompt file existence
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestTechResearcherPromptFile:
|
||
|
|
def test_prompt_file_exists(self):
|
||
|
|
assert TECH_RESEARCHER_PROMPT.exists(), (
|
||
|
|
f"Prompt file not found: {TECH_RESEARCHER_PROMPT}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_prompt_file_is_not_empty(self, prompt_text):
|
||
|
|
assert len(prompt_text.strip()) > 100
|
||
|
|
|
||
|
|
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
# Prompt content — structured review instructions
|
||
|
|
# ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
class TestTechResearcherPromptContent:
|
||
|
|
def test_prompt_contains_json_output_instruction(self, prompt_text):
|
||
|
|
assert "JSON" in prompt_text or "json" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_status_field(self, prompt_text):
|
||
|
|
assert '"status"' in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_done_partial_blocked_statuses(self, prompt_text):
|
||
|
|
assert "done" in prompt_text
|
||
|
|
assert "partial" in prompt_text
|
||
|
|
assert "blocked" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_api_overview_field(self, prompt_text):
|
||
|
|
assert "api_overview" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_endpoints_field(self, prompt_text):
|
||
|
|
assert "endpoints" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_rate_limits_field(self, prompt_text):
|
||
|
|
assert "rate_limits" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_codebase_diff_field(self, prompt_text):
|
||
|
|
assert "codebase_diff" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_gotchas_field(self, prompt_text):
|
||
|
|
assert "gotchas" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_contains_webfetch_instruction(self, prompt_text):
|
||
|
|
assert "WebFetch" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_mentions_no_secrets_logging(self, prompt_text):
|
||
|
|
"""Prompt must instruct agent not to log secret values."""
|
||
|
|
lower = prompt_text.lower()
|
||
|
|
assert "secret" in lower or "credential" in lower or "token" in lower
|
||
|
|
|
||
|
|
def test_prompt_specifies_readonly_bash(self, prompt_text):
|
||
|
|
"""Bash must be restricted to read-only operations per rules."""
|
||
|
|
assert "read-only" in prompt_text or "read only" in prompt_text or "GET" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_partial_reason_for_partial_status(self, prompt_text):
|
||
|
|
assert "partial_reason" in prompt_text
|
||
|
|
|
||
|
|
def test_prompt_defines_blocked_reason_for_blocked_status(self, prompt_text):
|
||
|
|
assert "blocked_reason" in prompt_text
|