opencode-config/tests/test_agent_frontmatter.py
Sergey 2473360530
fix(agents): remove invalid doom_loop frontmatter field + simplify reviewer setup (#49)
* fix(agents): remove invalid top-level doom_loop field from frontmatter

* fix(reviewer): simplify setup step 5 and add investigation budget

* test(agents): add frontmatter validation tests

* docs(handoff): add handoff and ADR-020 for doom_loop frontmatter fix

* docs(handoff): set PR number

* docs: update project map

---------

Co-authored-by: opencode-agent <agent@slaid098.dev>
2026-07-24 19:24:36 +03:00

174 lines
6.7 KiB
Python

"""Tests for agent frontmatter validation — top-level vs permission-nested fields.
Covers issue #48 acceptance criteria:
- ``reviewer.md``, ``docs-reviewer.md``, ``memory-syncer.md`` frontmatter does
NOT contain a top-level ``doom_loop`` field (outside the ``permission:`` block).
A top-level ``doom_loop`` is invalid — it is not in the opencode ``AgentV2.Info``
schema and gets forwarded to the provider as a model param, causing
``AI_APICallError: Extra inputs are not permitted`` on strict providers.
- ``doom_loop`` INSIDE the ``permission:`` block IS present in all 3 files
(valid — it is the real permission guard).
- ``steps: 100`` is present and unchanged in all 3 files.
PyYAML is not a project dependency, so frontmatter is parsed manually by
splitting on ``---`` delimiters and tracking indentation (top-level fields have
no indent; ``permission:``-nested fields have 2-space indent). This mirrors the
parsing approach used in ``.opencode/scripts/check-permissions.py``.
"""
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
AGENTS_DIR = REPO_ROOT / ".opencode" / "agents"
AGENT_FILES = [
AGENTS_DIR / "reviewer.md",
AGENTS_DIR / "docs-reviewer.md",
AGENTS_DIR / "memory-syncer.md",
]
def _parse_frontmatter(filepath: Path) -> dict:
"""Parse YAML frontmatter into a nested dict (manual, no pyyaml).
Returns a dict where top-level keys map to either a scalar string or a
nested dict (for indented blocks like ``permission:``).
"""
content = filepath.read_text()
parts = content.split("---", 2)
assert len(parts) >= 3, f"{filepath.name}: no frontmatter block found"
frontmatter = parts[1]
return _parse_yaml_block(frontmatter)
def _parse_yaml_block(text: str) -> dict:
"""Minimal YAML parser for the subset used in agent frontmatter.
Handles: top-level ``key: value``, nested ``key:`` blocks (indented),
and nested ``key: value`` (indented). Values are strings; nested blocks
become dicts. Does NOT handle lists, quoting, or complex YAML — agent
frontmatter only uses the simple key-value subset.
"""
result: dict = {}
current_dict: dict | None = None
for line in text.split("\n"):
if not line.strip() or line.strip().startswith("#"):
continue
stripped = line.lstrip()
indent = len(line) - len(stripped)
if indent == 0:
current_dict = None
if ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if value:
result[key] = value
else:
current_dict = {}
result[key] = current_dict
elif current_dict is not None and indent >= 2:
if ":" in stripped:
key, _, value = stripped.partition(":")
key = key.strip()
value = value.strip()
if value:
current_dict[key] = value
return result
# ── all agent files exist ───────────────────────────────────────────────────
def test_agent_files_exist():
"""All 3 agent files exist in .opencode/agents/."""
for f in AGENT_FILES:
assert f.exists(), f"agent file missing: {f}"
# ── no top-level doom_loop (issue #48 — the invalid field) ──────────────────
def test_no_top_level_doom_loop():
"""Frontmatter of all 3 agents has NO top-level ``doom_loop`` key.
A top-level ``doom_loop`` is invalid — outside ``permission:`` it is not
a recognised agent field and gets forwarded to the provider.
"""
for f in AGENT_FILES:
fm = _parse_frontmatter(f)
assert "doom_loop" not in fm, (
f"{f.name}: top-level 'doom_loop' field must be removed — it is "
"invalid outside the permission: block"
)
def test_no_top_level_doom_loop_raw():
"""Raw text check: no line ``doom_loop:`` at indent 0 in any agent file.
Belt-and-suspenders alongside the parsed check — catches edge cases where
the parser might miss something.
"""
for f in AGENT_FILES:
content = f.read_text()
parts = content.split("---", 2)
assert len(parts) >= 3, f"{f.name}: no frontmatter"
fm = parts[1]
for line in fm.split("\n"):
if line.startswith("doom_loop:"):
raise AssertionError(
f"{f.name}: top-level 'doom_loop:' line found at indent 0 — "
f"must be removed: {line!r}"
)
# ── permission.doom_loop present (the valid guard) ──────────────────────────
def test_permission_doom_loop_present():
"""``doom_loop: deny`` INSIDE ``permission:`` is present in all 3 files.
This is the valid permission guard — it must NOT be removed.
"""
for f in AGENT_FILES:
fm = _parse_frontmatter(f)
assert "permission" in fm, f"{f.name}: no 'permission' block"
permission = fm["permission"]
assert isinstance(permission, dict), f"{f.name}: 'permission' is not a block"
assert "doom_loop" in permission, (
f"{f.name}: 'doom_loop' missing from permission: block — must be kept"
)
assert permission["doom_loop"] == "deny", (
f"{f.name}: permission.doom_loop must be 'deny', got {permission['doom_loop']!r}"
)
# ── steps: 100 present and unchanged ─────────────────────────────────────────
def test_steps_100_present():
"""``steps: 100`` is present in all 3 agent files (must not be changed)."""
for f in AGENT_FILES:
fm = _parse_frontmatter(f)
assert "steps" in fm, f"{f.name}: 'steps' field missing"
assert fm["steps"] == "100", f"{f.name}: steps must be '100', got {fm['steps']!r}"
# ── frontmatter is well-formed (parses without error) ───────────────────────
def test_frontmatter_parseable():
"""All 3 agent files have parseable frontmatter (split by ---)."""
for f in AGENT_FILES:
content = f.read_text()
parts = content.split("---", 2)
assert len(parts) >= 3, f"{f.name}: frontmatter not delimited by ---"
fm = _parse_frontmatter(f)
assert "description" in fm, f"{f.name}: 'description' field missing"
assert "mode" in fm, f"{f.name}: 'mode' field missing"
if __name__ == "__main__":
import pytest
pytest.main([__file__, "-v"])