|
1 | 1 | """Contract test: ``KNOWN_TOOL_TYPES`` must match all four dispatch sites. |
2 | 2 |
|
3 | | -Sites: |
4 | | -- ``utils/tool_dispatch.py`` — ``KNOWN_TOOL_TYPES`` / ``FILE_ACTIVITY_TOOL_TYPES`` |
5 | | -- ``utils/md_exporter.py`` — ``MD_EXPORTER_TOOL_TYPES`` |
6 | | -- ``static/js/render/registry.js`` — ``TOOL_USE_RENDERERS`` keys |
| 3 | +Sites (each compared to ``KNOWN_TOOL_TYPES`` in ``utils/tool_dispatch.py``): |
| 4 | +- ``utils/md_exporter.py`` — ``_render_tool_use`` if/elif branches (parsed) |
| 5 | +- ``models/tool_results.py`` — ``ToolNameLiteral`` |
| 6 | +- ``static/js/render/registry.js`` — ``TOOL_USE_RENDERERS`` keys (parsed) |
7 | 7 | """ |
8 | 8 |
|
9 | 9 | from __future__ import annotations |
10 | 10 |
|
11 | 11 | import re |
12 | 12 | from pathlib import Path |
| 13 | +from typing import get_args |
13 | 14 |
|
14 | 15 | import pytest |
15 | 16 |
|
16 | | -from utils.md_exporter import MD_EXPORTER_TOOL_TYPES |
17 | | -from utils.tool_dispatch import FILE_ACTIVITY_TOOL_TYPES, KNOWN_TOOL_TYPES |
| 17 | +from models.tool_results import ToolNameLiteral |
| 18 | +from utils.tool_dispatch import KNOWN_TOOL_TYPES |
18 | 19 |
|
19 | 20 | _REPO_ROOT = Path(__file__).resolve().parents[1] |
20 | 21 | _FRONTEND_REGISTRY = _REPO_ROOT / "static" / "js" / "render" / "registry.js" |
| 22 | +_MD_EXPORTER = _REPO_ROOT / "utils" / "md_exporter.py" |
| 23 | + |
| 24 | + |
| 25 | +def _format_set_diff(expected: frozenset[str], actual: frozenset[str], site: str) -> str: |
| 26 | + missing = sorted(expected - actual) |
| 27 | + extra = sorted(actual - expected) |
| 28 | + parts: list[str] = [] |
| 29 | + if missing: |
| 30 | + parts.append(f"missing tool type(s) {missing!r} in {site}") |
| 31 | + if extra: |
| 32 | + parts.append(f"unexpected tool type(s) {extra!r} in {site}") |
| 33 | + return "; ".join(parts) |
21 | 34 |
|
22 | 35 |
|
23 | 36 | def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]: |
| 37 | + """Extract ``TOOL_USE_RENDERERS`` keys. |
| 38 | +
|
| 39 | + Assumes values are bare identifiers (``Bash: renderBashUse``). Brace-depth |
| 40 | + parsing avoids truncating the object body if a value ever contains ``}``. |
| 41 | + """ |
| 42 | + text = path.read_text(encoding="utf-8") |
| 43 | + marker = "export const TOOL_USE_RENDERERS = {" |
| 44 | + start = text.find(marker) |
| 45 | + if start == -1: |
| 46 | + msg = f"Could not find TOOL_USE_RENDERERS in {path}" |
| 47 | + raise ValueError(msg) |
| 48 | + i = start + len(marker) |
| 49 | + depth = 1 |
| 50 | + body_start = i |
| 51 | + while i < len(text) and depth > 0: |
| 52 | + ch = text[i] |
| 53 | + if ch == "{": |
| 54 | + depth += 1 |
| 55 | + elif ch == "}": |
| 56 | + depth -= 1 |
| 57 | + i += 1 |
| 58 | + if depth != 0: |
| 59 | + msg = f"Unbalanced braces in TOOL_USE_RENDERERS in {path}" |
| 60 | + raise ValueError(msg) |
| 61 | + body = text[body_start : i - 1] |
| 62 | + keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE) |
| 63 | + return frozenset(keys) |
| 64 | + |
| 65 | + |
| 66 | +def _parse_md_exporter_tool_use_handlers(path: Path) -> frozenset[str]: |
| 67 | + """Extract tool names handled by ``_render_tool_use`` if/elif branches.""" |
24 | 68 | text = path.read_text(encoding="utf-8") |
25 | 69 | match = re.search( |
26 | | - r"export const TOOL_USE_RENDERERS = \{([^}]+)\}", |
| 70 | + r"def _render_tool_use\(.*?(?=\ndef _render_tool_result)", |
27 | 71 | text, |
28 | 72 | re.DOTALL, |
29 | 73 | ) |
30 | 74 | if not match: |
31 | | - msg = f"Could not find TOOL_USE_RENDERERS in {path}" |
| 75 | + msg = f"Could not find _render_tool_use in {path}" |
32 | 76 | raise ValueError(msg) |
33 | | - body = match.group(1) |
34 | | - keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE) |
35 | | - return frozenset(keys) |
| 77 | + body = match.group(0) |
| 78 | + names = set(re.findall(r'(?:if|elif) name == "([^"]+)"', body)) |
| 79 | + for tuple_match in re.finditer(r"elif name in \(([^)]+)\)", body): |
| 80 | + names.update(re.findall(r'"([^"]+)"', tuple_match.group(1))) |
| 81 | + return frozenset(names) |
36 | 82 |
|
37 | 83 |
|
38 | | -def _format_set_diff(expected: frozenset[str], actual: frozenset[str], site: str) -> str: |
39 | | - missing = sorted(expected - actual) |
40 | | - extra = sorted(actual - expected) |
41 | | - parts: list[str] = [] |
42 | | - if missing: |
43 | | - parts.append(f"missing tool type(s) {missing!r} in {site}") |
44 | | - if extra: |
45 | | - parts.append(f"unexpected tool type(s) {extra!r} in {site}") |
46 | | - return "; ".join(parts) |
| 84 | +def test_md_exporter_handlers_match_known_tool_types() -> None: |
| 85 | + site = "utils/md_exporter.py (_render_tool_use branches)" |
| 86 | + try: |
| 87 | + actual = _parse_md_exporter_tool_use_handlers(_MD_EXPORTER) |
| 88 | + except ValueError as exc: |
| 89 | + pytest.fail(f"{site}: {exc}") |
| 90 | + if actual != KNOWN_TOOL_TYPES: |
| 91 | + pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site)) |
47 | 92 |
|
48 | 93 |
|
49 | | -@pytest.mark.parametrize( |
50 | | - ("site", "actual"), |
51 | | - [ |
52 | | - ("utils/tool_dispatch.py (FILE_ACTIVITY_TOOL_TYPES)", FILE_ACTIVITY_TOOL_TYPES), |
53 | | - ("utils/md_exporter.py (MD_EXPORTER_TOOL_TYPES)", MD_EXPORTER_TOOL_TYPES), |
54 | | - ], |
55 | | -) |
56 | | -def test_tool_type_sets_match_known_registry(site: str, actual: frozenset[str]) -> None: |
| 94 | +def test_tool_name_literal_matches_known_tool_types() -> None: |
| 95 | + site = "models/tool_results.py (ToolNameLiteral)" |
| 96 | + actual = frozenset(get_args(ToolNameLiteral)) |
57 | 97 | if actual != KNOWN_TOOL_TYPES: |
58 | 98 | pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site)) |
59 | 99 |
|
|
0 commit comments