Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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.

Comment thread
coderabbitai[bot] marked this conversation as resolved.
## Getting help

Open an issue with a clear repro or propose a draft PR early for CI feedback.
1 change: 1 addition & 0 deletions models/tool_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Comment thread
clean6378-max-it marked this conversation as resolved.
"Bash",
"Read",
"Write",
Expand Down
112 changes: 112 additions & 0 deletions tests/test_tool_dispatch_sync.py
Original file line number Diff line number Diff line change
@@ -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
27 changes: 2 additions & 25 deletions utils/jsonl_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down Expand Up @@ -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(
{
Expand Down Expand Up @@ -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)
2 changes: 1 addition & 1 deletion utils/md_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
82 changes: 81 additions & 1 deletion utils/tool_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
Loading