Skip to content

Commit 9104d31

Browse files
refactor: single KNOWN_TOOL_TYPES registry with sync CI test
Adding a Claude Code tool use name required coordinated edits across tool_dispatch, jsonl_parser file activity, md_exporter, and the frontend registry with no test-time guard against drift. Define KNOWN_TOOL_TYPES in utils/tool_dispatch.py, move file-activity tracking to track_tool_file_activity(), wire md_exporter to the registry, and add tests/test_tool_dispatch_sync.py to assert all four sites stay in sync. Document the add-a-tool-type procedure in CONTRIBUTING.md.
1 parent bddbda3 commit 9104d31

6 files changed

Lines changed: 193 additions & 25 deletions

File tree

CONTRIBUTING.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ npm run test:coverage # optional
100100
| New `ErrorCode` | Parametrized row in `tests/test_error_codes.py` |
101101
| Search / limit validation | `tests/test_search.py` |
102102
| New `_parse_tool_result` dispatch entry | Fixture + assertion in `tests/test_jsonl_parser.py` |
103+
| New Claude Code tool use name | See **Adding a new tool type** below |
103104
| CLI behavior | `tests/test_cli_e2e.py` (subprocess) or `tests/test_cli_args.py` (parser only) |
104105
| Frontend shared module | `static/js/shared/*.test.js` (vitest) |
105106
| Error response shape | `tests/test_error_propagation.py` regression |
@@ -140,6 +141,16 @@ npm run test:coverage # optional
140141

141142
See [`docs/architecture.md`](docs/architecture.md) for data flow, export state machine, and component diagram.
142143

144+
## Adding a new tool type
145+
146+
Claude Code assistant `tool_use` blocks carry a `name` string (e.g. `"Read"`, `"Bash"`). The browser coordinates that name across four sites; drift is caught by `tests/test_tool_dispatch_sync.py`.
147+
148+
1. **`utils/tool_dispatch.py`** — add the name to `KNOWN_TOOL_TYPE_NAMES` (keep alphabetical). Set `_FILE_ACTIVITY_HANDLERS[name]` to a tracker function or `None`. 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`).
149+
2. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` and include the name in `MD_EXPORTER_TOOL_TYPES`.
150+
3. **`static/js/render/registry.js`** — add a `TOOL_USE_RENDERERS` entry (and a `tool_use/*.js` renderer module).
151+
4. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module.
152+
5. Run `pytest tests/test_tool_dispatch_sync.py -v` — failure names the site missing the new type.
153+
143154
## Getting help
144155

145156
Open an issue with a clear repro or propose a draft PR early for CI feedback.

models/tool_results.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ def is_user_input_tool_result(tr: ToolResultDict) -> TypeGuard[UserInputToolResu
220220

221221
# Tool names on assistant tool_use blocks — pairs with slug on user tool_result rows.
222222
ToolNameLiteral = Literal[
223+
"AskUserQuestion",
223224
"Bash",
224225
"Read",
225226
"Write",

tests/test_tool_dispatch_sync.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""Contract test: ``KNOWN_TOOL_TYPES`` must match all four dispatch sites.
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
7+
"""
8+
9+
from __future__ import annotations
10+
11+
import re
12+
from pathlib import Path
13+
14+
import pytest
15+
16+
from utils.md_exporter import MD_EXPORTER_TOOL_TYPES
17+
from utils.tool_dispatch import FILE_ACTIVITY_TOOL_TYPES, KNOWN_TOOL_TYPES
18+
19+
_REPO_ROOT = Path(__file__).resolve().parents[1]
20+
_FRONTEND_REGISTRY = _REPO_ROOT / "static" / "js" / "render" / "registry.js"
21+
22+
23+
def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
24+
text = path.read_text(encoding="utf-8")
25+
match = re.search(
26+
r"export const TOOL_USE_RENDERERS = \{([^}]+)\}",
27+
text,
28+
re.DOTALL,
29+
)
30+
if not match:
31+
msg = f"Could not find TOOL_USE_RENDERERS in {path}"
32+
raise ValueError(msg)
33+
body = match.group(1)
34+
keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE)
35+
return frozenset(keys)
36+
37+
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)
47+
48+
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+
"static/js/render/registry.js (TOOL_USE_RENDERERS)",
56+
_parse_frontend_tool_use_renderers(_FRONTEND_REGISTRY),
57+
),
58+
],
59+
)
60+
def test_tool_type_sets_match_known_registry(site: str, actual: frozenset[str]) -> None:
61+
if actual != KNOWN_TOOL_TYPES:
62+
pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site))
63+
64+
65+
def test_known_tool_types_nonempty() -> None:
66+
assert KNOWN_TOOL_TYPES

utils/jsonl_parser.py

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
normalize_content as _normalize_content,
2020
)
2121
from utils.session_peek import quick_session_info
22-
from utils.tool_dispatch import _parse_tool_result
22+
from utils.tool_dispatch import _parse_tool_result, track_tool_file_activity
2323
from utils.validation import validate_session_dict
2424

2525
__all__ = ["parse_session", "quick_session_info"]
@@ -311,7 +311,7 @@ def _process_assistant(
311311
if isinstance(tool_id, str):
312312
tool_use["id"] = tool_id
313313
tool_uses.append(tool_use)
314-
_track_file_activity(tool_name, safe_input, metadata)
314+
track_tool_file_activity(tool_name, safe_input, metadata)
315315

316316
messages.append(
317317
{
@@ -390,24 +390,3 @@ def _process_progress(entry: dict[str, Any], messages: list[MessageDict]) -> Non
390390
)
391391

392392

393-
def _track_file_activity(
394-
tool_name: str, tool_input: dict[str, Any], metadata: dict[str, Any]
395-
) -> None:
396-
"""Look at what each tool call did and record which files got touched,
397-
what commands got run, what URLs got fetched."""
398-
raw_fp = tool_input.get("file_path", "")
399-
fp = raw_fp if isinstance(raw_fp, str) else ""
400-
if tool_name == "Read" and fp:
401-
metadata["files_read"].add(fp)
402-
elif tool_name == "Write" and fp:
403-
metadata["files_created"].add(fp)
404-
elif tool_name == "Edit" and fp:
405-
metadata["files_written"].add(fp)
406-
elif tool_name == "Bash":
407-
cmd = tool_input.get("command", "")
408-
if isinstance(cmd, str) and cmd:
409-
metadata["bash_commands"].append(cmd)
410-
elif tool_name in ("WebFetch", "WebSearch"):
411-
url_or_query = tool_input.get("url") or tool_input.get("query", "")
412-
if isinstance(url_or_query, str) and url_or_query:
413-
metadata["web_fetches"].append(url_or_query)

utils/md_exporter.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from models.stats import SessionStatsDict
99
from utils.jsonl_helpers import strip_system_tags
1010
from utils.session_stats import format_duration
11+
from utils.tool_dispatch import KNOWN_TOOL_TYPES
1112

1213

1314
def session_to_markdown(session: SessionDict, stats: SessionStatsDict | None = None) -> str:
@@ -323,11 +324,29 @@ def _render_tool_use(tool: ToolUseDict) -> str:
323324
continue
324325
lines.append(f">\n> Q: {q.get('question', '')}")
325326
else:
326-
lines.append(f">\n> Input: `{str(inp)}`")
327+
label = "Input" if name in KNOWN_TOOL_TYPES else "Input (unknown tool type)"
328+
lines.append(f">\n> {label}: `{str(inp)}`")
327329

328330
return "\n".join(lines)
329331

330332

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

utils/tool_dispatch.py

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,23 @@
1515
tuple there when a new predicate must sit above another.
1616
1717
Predicates live in ``models.tool_results`` (single source of truth for narrowing).
18+
19+
Adding a new Claude Code **tool use** name (e.g. ``"Read"``, ``"Bash"``):
20+
21+
1. Add the name to ``KNOWN_TOOL_TYPE_NAMES`` below (alphabetical order).
22+
2. Wire ``_FILE_ACTIVITY_HANDLERS`` (``None`` if no file/bash/web side effects).
23+
3. Add a Markdown branch in ``utils/md_exporter.py`` ``_render_tool_use``.
24+
4. Add ``TOOL_USE_RENDERERS`` entry in ``static/js/render/registry.js``.
25+
5. Add ``(predicate, builder)`` to ``_TOOL_RESULT_DISPATCH`` when the tool has a
26+
distinct ``toolUseResult`` shape (see ordering notes above).
27+
6. Run ``pytest tests/test_tool_dispatch_sync.py -v`` — it fails with the
28+
missing site if any step was skipped.
29+
30+
See ``CONTRIBUTING.md`` § "Adding a new tool type".
1831
"""
1932

20-
from typing import cast
33+
from collections.abc import Callable
34+
from typing import Any, cast
2135

2236
from models.tool_results import (
2337
ToolResultDict,
@@ -219,6 +233,84 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) -
219233
(is_user_input_tool_result, _tool_result_build_user_input),
220234
)
221235

236+
# Claude Code assistant tool_use ``name`` values coordinated across parser file
237+
# activity, Markdown export, and the SPA ``TOOL_USE_RENDERERS`` map.
238+
KNOWN_TOOL_TYPE_NAMES: tuple[str, ...] = (
239+
"AskUserQuestion",
240+
"Bash",
241+
"Edit",
242+
"Glob",
243+
"Grep",
244+
"Read",
245+
"Task",
246+
"TodoWrite",
247+
"WebFetch",
248+
"WebSearch",
249+
"Write",
250+
)
251+
KNOWN_TOOL_TYPES: frozenset[str] = frozenset(KNOWN_TOOL_TYPE_NAMES)
252+
253+
254+
def _file_activity_read(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
255+
raw_fp = tool_input.get("file_path", "")
256+
fp = raw_fp if isinstance(raw_fp, str) else ""
257+
if fp:
258+
metadata["files_read"].add(fp)
259+
260+
261+
def _file_activity_write(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
262+
raw_fp = tool_input.get("file_path", "")
263+
fp = raw_fp if isinstance(raw_fp, str) else ""
264+
if fp:
265+
metadata["files_created"].add(fp)
266+
267+
268+
def _file_activity_edit(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
269+
raw_fp = tool_input.get("file_path", "")
270+
fp = raw_fp if isinstance(raw_fp, str) else ""
271+
if fp:
272+
metadata["files_written"].add(fp)
273+
274+
275+
def _file_activity_bash(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
276+
cmd = tool_input.get("command", "")
277+
if isinstance(cmd, str) and cmd:
278+
metadata["bash_commands"].append(cmd)
279+
280+
281+
def _file_activity_web(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
282+
url_or_query = tool_input.get("url") or tool_input.get("query", "")
283+
if isinstance(url_or_query, str) and url_or_query:
284+
metadata["web_fetches"].append(url_or_query)
285+
286+
287+
# Every ``KNOWN_TOOL_TYPES`` entry must appear here (``None`` = no side effects).
288+
_FILE_ACTIVITY_HANDLERS: dict[str, Callable[[dict[str, Any], dict[str, Any]], None] | None] = {
289+
"AskUserQuestion": None,
290+
"Bash": _file_activity_bash,
291+
"Edit": _file_activity_edit,
292+
"Glob": None,
293+
"Grep": None,
294+
"Read": _file_activity_read,
295+
"Task": None,
296+
"TodoWrite": None,
297+
"WebFetch": _file_activity_web,
298+
"WebSearch": _file_activity_web,
299+
"Write": _file_activity_write,
300+
}
301+
FILE_ACTIVITY_TOOL_TYPES: frozenset[str] = frozenset(_FILE_ACTIVITY_HANDLERS)
302+
303+
304+
def track_tool_file_activity(
305+
tool_name: str, tool_input: dict[str, Any], metadata: dict[str, Any]
306+
) -> None:
307+
"""Record file/bash/web side effects for tools listed in ``KNOWN_TOOL_TYPES``."""
308+
if tool_name not in KNOWN_TOOL_TYPES:
309+
return
310+
handler = _FILE_ACTIVITY_HANDLERS[tool_name]
311+
if handler is not None:
312+
handler(tool_input, metadata)
313+
222314

223315
def _parse_tool_result(
224316
tool_result: ToolResultUnion | None, slug: str | None = None

0 commit comments

Comments
 (0)