diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a123b2f..5a9dace 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -100,6 +100,7 @@ npm run test:coverage # optional | New `ErrorCode` | Parametrized row in `tests/test_error_codes.py` | | Search / limit validation | `tests/test_search.py` | | New `_parse_tool_result` dispatch entry | Fixture + assertion in `tests/test_jsonl_parser.py` | +| New Claude Code tool use name | See **Adding a new tool type** below | | CLI behavior | `tests/test_cli_e2e.py` (subprocess) or `tests/test_cli_args.py` (parser only) | | Frontend shared module | `static/js/shared/*.test.js` (vitest) | | Error response shape | `tests/test_error_propagation.py` regression | @@ -140,6 +141,17 @@ npm run test:coverage # optional See [`docs/architecture.md`](docs/architecture.md) for data flow, export state machine, and component diagram. +## Adding a new tool type + +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`. + +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`). +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`. +3. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` (sync test parses these branches). +4. **`static/js/render/registry.js`** — add a `TOOL_USE_RENDERERS` entry (and a `tool_use/*.js` renderer module). +5. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module. +6. Run `pytest tests/test_tool_dispatch_sync.py -v` — failure names the site missing the new type. + ## Getting help Open an issue with a clear repro or propose a draft PR early for CI feedback. diff --git a/models/tool_results.py b/models/tool_results.py index ca881b7..7b411ee 100644 --- a/models/tool_results.py +++ b/models/tool_results.py @@ -220,6 +220,7 @@ def is_user_input_tool_result(tr: ToolResultDict) -> TypeGuard[UserInputToolResu # Tool names on assistant tool_use blocks — pairs with slug on user tool_result rows. ToolNameLiteral = Literal[ + "AskUserQuestion", "Bash", "Read", "Write", diff --git a/tests/test_tool_dispatch_sync.py b/tests/test_tool_dispatch_sync.py new file mode 100644 index 0000000..7063bc0 --- /dev/null +++ b/tests/test_tool_dispatch_sync.py @@ -0,0 +1,112 @@ +"""Contract test: ``KNOWN_TOOL_TYPES`` must match all four dispatch sites. + +Sites (each compared to ``KNOWN_TOOL_TYPES`` in ``utils/tool_dispatch.py``): +- ``utils/md_exporter.py`` — ``_render_tool_use`` if/elif branches (parsed) +- ``models/tool_results.py`` — ``ToolNameLiteral`` +- ``static/js/render/registry.js`` — ``TOOL_USE_RENDERERS`` keys (parsed) +""" + +from __future__ import annotations + +import re +from pathlib import Path +from typing import get_args + +import pytest + +from models.tool_results import ToolNameLiteral +from utils.tool_dispatch import KNOWN_TOOL_TYPES + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_FRONTEND_REGISTRY = _REPO_ROOT / "static" / "js" / "render" / "registry.js" +_MD_EXPORTER = _REPO_ROOT / "utils" / "md_exporter.py" + + +def _format_set_diff(expected: frozenset[str], actual: frozenset[str], site: str) -> str: + missing = sorted(expected - actual) + extra = sorted(actual - expected) + parts: list[str] = [] + if missing: + parts.append(f"missing tool type(s) {missing!r} in {site}") + if extra: + parts.append(f"unexpected tool type(s) {extra!r} in {site}") + return "; ".join(parts) + + +def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]: + """Extract ``TOOL_USE_RENDERERS`` keys. + + Assumes values are bare identifiers (``Bash: renderBashUse``). Brace-depth + parsing avoids truncating the object body if a value ever contains ``}``. + """ + text = path.read_text(encoding="utf-8") + marker = "export const TOOL_USE_RENDERERS = {" + start = text.find(marker) + if start == -1: + msg = f"Could not find TOOL_USE_RENDERERS in {path}" + raise ValueError(msg) + i = start + len(marker) + depth = 1 + body_start = i + while i < len(text) and depth > 0: + ch = text[i] + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + i += 1 + if depth != 0: + msg = f"Unbalanced braces in TOOL_USE_RENDERERS in {path}" + raise ValueError(msg) + body = text[body_start : i - 1] + keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE) + return frozenset(keys) + + +def _parse_md_exporter_tool_use_handlers(path: Path) -> frozenset[str]: + """Extract tool names handled by ``_render_tool_use`` if/elif branches.""" + text = path.read_text(encoding="utf-8") + match = re.search( + r"def _render_tool_use\(.*?(?=\ndef _render_tool_result)", + text, + re.DOTALL, + ) + if not match: + msg = f"Could not find _render_tool_use in {path}" + raise ValueError(msg) + body = match.group(0) + names = set(re.findall(r'(?:if|elif) name == "([^"]+)"', body)) + for tuple_match in re.finditer(r"elif name in \(([^)]+)\)", body): + names.update(re.findall(r'"([^"]+)"', tuple_match.group(1))) + return frozenset(names) + + +def test_md_exporter_handlers_match_known_tool_types() -> None: + site = "utils/md_exporter.py (_render_tool_use branches)" + try: + actual = _parse_md_exporter_tool_use_handlers(_MD_EXPORTER) + except ValueError as exc: + pytest.fail(f"{site}: {exc}") + if actual != KNOWN_TOOL_TYPES: + pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site)) + + +def test_tool_name_literal_matches_known_tool_types() -> None: + site = "models/tool_results.py (ToolNameLiteral)" + actual = frozenset(get_args(ToolNameLiteral)) + if actual != KNOWN_TOOL_TYPES: + pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site)) + + +def test_frontend_registry_matches_known_tool_types() -> None: + site = "static/js/render/registry.js (TOOL_USE_RENDERERS)" + try: + actual = _parse_frontend_tool_use_renderers(_FRONTEND_REGISTRY) + except ValueError as exc: + pytest.fail(f"{site}: {exc}") + if actual != KNOWN_TOOL_TYPES: + pytest.fail(_format_set_diff(KNOWN_TOOL_TYPES, actual, site)) + + +def test_known_tool_types_nonempty() -> None: + assert KNOWN_TOOL_TYPES diff --git a/utils/jsonl_parser.py b/utils/jsonl_parser.py index b948d82..04dec48 100644 --- a/utils/jsonl_parser.py +++ b/utils/jsonl_parser.py @@ -19,7 +19,7 @@ normalize_content as _normalize_content, ) from utils.session_peek import quick_session_info -from utils.tool_dispatch import _parse_tool_result +from utils.tool_dispatch import _parse_tool_result, track_tool_file_activity from utils.validation import validate_session_dict __all__ = ["parse_session", "quick_session_info"] @@ -311,7 +311,7 @@ def _process_assistant( if isinstance(tool_id, str): tool_use["id"] = tool_id tool_uses.append(tool_use) - _track_file_activity(tool_name, safe_input, metadata) + track_tool_file_activity(tool_name, safe_input, metadata) messages.append( { @@ -388,26 +388,3 @@ def _process_progress(entry: dict[str, Any], messages: list[MessageDict]) -> Non "is_sidechain": entry.get("isSidechain", False), } ) - - -def _track_file_activity( - tool_name: str, tool_input: dict[str, Any], metadata: dict[str, Any] -) -> None: - """Look at what each tool call did and record which files got touched, - what commands got run, what URLs got fetched.""" - raw_fp = tool_input.get("file_path", "") - fp = raw_fp if isinstance(raw_fp, str) else "" - if tool_name == "Read" and fp: - metadata["files_read"].add(fp) - elif tool_name == "Write" and fp: - metadata["files_created"].add(fp) - elif tool_name == "Edit" and fp: - metadata["files_written"].add(fp) - elif tool_name == "Bash": - cmd = tool_input.get("command", "") - if isinstance(cmd, str) and cmd: - metadata["bash_commands"].append(cmd) - elif tool_name in ("WebFetch", "WebSearch"): - url_or_query = tool_input.get("url") or tool_input.get("query", "") - if isinstance(url_or_query, str) and url_or_query: - metadata["web_fetches"].append(url_or_query) diff --git a/utils/md_exporter.py b/utils/md_exporter.py index b954b74..bc617b1 100644 --- a/utils/md_exporter.py +++ b/utils/md_exporter.py @@ -323,7 +323,7 @@ def _render_tool_use(tool: ToolUseDict) -> str: continue lines.append(f">\n> Q: {q.get('question', '')}") else: - lines.append(f">\n> Input: `{str(inp)}`") + lines.append(f">\n> Input (unknown tool type): `{str(inp)}`") return "\n".join(lines) diff --git a/utils/tool_dispatch.py b/utils/tool_dispatch.py index 5e836c1..7f81a6a 100644 --- a/utils/tool_dispatch.py +++ b/utils/tool_dispatch.py @@ -15,9 +15,25 @@ tuple there when a new predicate must sit above another. Predicates live in ``models.tool_results`` (single source of truth for narrowing). + +Adding a new Claude Code **tool use** name (e.g. ``"Read"``, ``"Bash"``): + +1. Add the name to ``_FILE_ACTIVITY_HANDLERS`` below (``None`` if no file/bash/web + side effects); ``KNOWN_TOOL_TYPES`` is derived from its keys. +2. Add the name to ``ToolNameLiteral`` in ``models/tool_results.py`` and, if the + tool has a distinct ``toolUseResult`` JSON shape, add the TypedDict, predicate, + and ``(predicate, builder)`` pair in ``_TOOL_RESULT_DISPATCH`` (respect ordering + — see notes above and ``tests/test_tool_dispatch_ordering.py``). +3. Add a Markdown branch in ``utils/md_exporter.py`` ``_render_tool_use``. +4. Add ``TOOL_USE_RENDERERS`` entry in ``static/js/render/registry.js``. +5. Run ``pytest tests/test_tool_dispatch_sync.py -v`` — it fails with the + missing site if any step was skipped. + +See ``CONTRIBUTING.md`` § "Adding a new tool type". """ -from typing import cast +from collections.abc import Callable +from typing import Any, cast from models.tool_results import ( ToolResultDict, @@ -219,6 +235,70 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) - (is_user_input_tool_result, _tool_result_build_user_input), ) +# Claude Code assistant tool_use ``name`` values coordinated across parser file +# activity, Markdown export, and the SPA ``TOOL_USE_RENDERERS`` map. +# ``_FILE_ACTIVITY_HANDLERS`` is the single registry; ``KNOWN_TOOL_TYPES`` is derived. + + +def _file_activity_read(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None: + raw_fp = tool_input.get("file_path", "") + fp = raw_fp if isinstance(raw_fp, str) else "" + if fp: + metadata["files_read"].add(fp) + + +def _file_activity_write(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None: + raw_fp = tool_input.get("file_path", "") + fp = raw_fp if isinstance(raw_fp, str) else "" + if fp: + metadata["files_created"].add(fp) + + +def _file_activity_edit(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None: + raw_fp = tool_input.get("file_path", "") + fp = raw_fp if isinstance(raw_fp, str) else "" + if fp: + metadata["files_written"].add(fp) + + +def _file_activity_bash(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None: + cmd = tool_input.get("command", "") + if isinstance(cmd, str) and cmd: + metadata["bash_commands"].append(cmd) + + +def _file_activity_web(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None: + url_or_query = tool_input.get("url") or tool_input.get("query", "") + if isinstance(url_or_query, str) and url_or_query: + metadata["web_fetches"].append(url_or_query) + + +_FILE_ACTIVITY_HANDLERS: dict[str, Callable[[dict[str, Any], dict[str, Any]], None] | None] = { + "AskUserQuestion": None, + "Bash": _file_activity_bash, + "Edit": _file_activity_edit, + "Glob": None, + "Grep": None, + "Read": _file_activity_read, + "Task": None, + "TodoWrite": None, + "WebFetch": _file_activity_web, + "WebSearch": _file_activity_web, + "Write": _file_activity_write, +} +KNOWN_TOOL_TYPES: frozenset[str] = frozenset(_FILE_ACTIVITY_HANDLERS) + + +def track_tool_file_activity( + tool_name: str, tool_input: dict[str, Any], metadata: dict[str, Any] +) -> None: + """Record file/bash/web side effects for tools listed in ``KNOWN_TOOL_TYPES``.""" + if tool_name not in KNOWN_TOOL_TYPES: + return + handler = _FILE_ACTIVITY_HANDLERS[tool_name] + if handler is not None: + handler(tool_input, metadata) + def _parse_tool_result( tool_result: ToolResultUnion | None, slug: str | None = None