Skip to content

Commit d0e6f39

Browse files
fix: tighten tool dispatch sync test per review feedback
1 parent 69bdded commit d0e6f39

4 files changed

Lines changed: 70 additions & 53 deletions

File tree

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Claude Code assistant `tool_use` blocks carry a `name` string (e.g. `"Read"`, `"
147147

148148
1. **`utils/tool_dispatch.py`** — add the name to `_FILE_ACTIVITY_HANDLERS` (`None` if no file/bash/web side effects); `KNOWN_TOOL_TYPES` is derived from its keys. If the tool has a distinct `toolUseResult` JSON shape, add `(predicate, builder)` to `_TOOL_RESULT_DISPATCH` (respect ordering — see module docstring and `tests/test_tool_dispatch_ordering.py`).
149149
2. **`models/tool_results.py`** — add the name to `ToolNameLiteral` and, when the tool has a distinct result payload, add the TypedDict, type guard (`is_*_tool_result`), and union member on `ToolResultUnion`.
150-
3. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` and include the name in `MD_EXPORTER_TOOL_TYPES`.
150+
3. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` (sync test parses these branches).
151151
4. **`static/js/render/registry.js`** — add a `TOOL_USE_RENDERERS` entry (and a `tool_use/*.js` renderer module).
152152
5. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module.
153153
6. Run `pytest tests/test_tool_dispatch_sync.py -v` — failure names the site missing the new type.

tests/test_tool_dispatch_sync.py

Lines changed: 68 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,99 @@
11
"""Contract test: ``KNOWN_TOOL_TYPES`` must match all four dispatch sites.
22
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)
77
"""
88

99
from __future__ import annotations
1010

1111
import re
1212
from pathlib import Path
13+
from typing import get_args
1314

1415
import pytest
1516

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
1819

1920
_REPO_ROOT = Path(__file__).resolve().parents[1]
2021
_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)
2134

2235

2336
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."""
2468
text = path.read_text(encoding="utf-8")
2569
match = re.search(
26-
r"export const TOOL_USE_RENDERERS = \{([^}]+)\}",
70+
r"def _render_tool_use\(.*?(?=\ndef _render_tool_result)",
2771
text,
2872
re.DOTALL,
2973
)
3074
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}"
3276
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)
3682

3783

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))
4792

4893

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))
5797
if actual != KNOWN_TOOL_TYPES:
5898
pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site))
5999

utils/md_exporter.py

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -323,32 +323,11 @@ def _render_tool_use(tool: ToolUseDict) -> str:
323323
continue
324324
lines.append(f">\n> Q: {q.get('question', '')}")
325325
else:
326-
if name in MD_EXPORTER_TOOL_TYPES:
327-
label = "Input (known tool type; no specialized Markdown renderer)"
328-
else:
329-
label = "Input (unknown tool type)"
330-
lines.append(f">\n> {label}: `{str(inp)}`")
326+
lines.append(f">\n> Input (unknown tool type): `{str(inp)}`")
331327

332328
return "\n".join(lines)
333329

334330

335-
MD_EXPORTER_TOOL_TYPES: frozenset[str] = frozenset(
336-
{
337-
"AskUserQuestion",
338-
"Bash",
339-
"Edit",
340-
"Glob",
341-
"Grep",
342-
"Read",
343-
"Task",
344-
"TodoWrite",
345-
"WebFetch",
346-
"WebSearch",
347-
"Write",
348-
}
349-
)
350-
351-
352331
def _render_tool_result(parsed: dict[str, Any]) -> str:
353332
"""Format a tool result nicely instead of dumping raw JSON."""
354333
rt = parsed.get("result_type", "unknown")

utils/tool_dispatch.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,9 +286,7 @@ def _file_activity_web(tool_input: dict[str, Any], metadata: dict[str, Any]) ->
286286
"WebSearch": _file_activity_web,
287287
"Write": _file_activity_write,
288288
}
289-
KNOWN_TOOL_TYPE_NAMES: tuple[str, ...] = tuple(sorted(_FILE_ACTIVITY_HANDLERS))
290289
KNOWN_TOOL_TYPES: frozenset[str] = frozenset(_FILE_ACTIVITY_HANDLERS)
291-
FILE_ACTIVITY_TOOL_TYPES: frozenset[str] = frozenset(_FILE_ACTIVITY_HANDLERS)
292290

293291

294292
def track_tool_file_activity(

0 commit comments

Comments
 (0)