Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 11 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,16 @@ 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 `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`).
2. **`utils/md_exporter.py`** — add an `elif name == "…"` branch in `_render_tool_use` and include the name in `MD_EXPORTER_TOOL_TYPES`.
3. **`static/js/render/registry.js`** — add a `TOOL_USE_RENDERERS` entry (and a `tool_use/*.js` renderer module).
4. **Optional result UI** — if the backend emits a new `result_type`, add `TOOL_RESULT_RENDERERS` and a `tool_result/*.js` module.
5. 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
66 changes: 66 additions & 0 deletions tests/test_tool_dispatch_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
"""Contract test: ``KNOWN_TOOL_TYPES`` must match all four dispatch sites.

Sites:
- ``utils/tool_dispatch.py`` — ``KNOWN_TOOL_TYPES`` / ``FILE_ACTIVITY_TOOL_TYPES``
- ``utils/md_exporter.py`` — ``MD_EXPORTER_TOOL_TYPES``
- ``static/js/render/registry.js`` — ``TOOL_USE_RENDERERS`` keys
"""

from __future__ import annotations

import re
from pathlib import Path

import pytest

from utils.md_exporter import MD_EXPORTER_TOOL_TYPES
from utils.tool_dispatch import FILE_ACTIVITY_TOOL_TYPES, KNOWN_TOOL_TYPES

_REPO_ROOT = Path(__file__).resolve().parents[1]
_FRONTEND_REGISTRY = _REPO_ROOT / "static" / "js" / "render" / "registry.js"


def _parse_frontend_tool_use_renderers(path: Path) -> frozenset[str]:
text = path.read_text(encoding="utf-8")
match = re.search(
r"export const TOOL_USE_RENDERERS = \{([^}]+)\}",
Comment thread
clean6378-max-it marked this conversation as resolved.
Outdated
text,
re.DOTALL,
)
if not match:
msg = f"Could not find TOOL_USE_RENDERERS in {path}"
raise ValueError(msg)
body = match.group(1)
keys = re.findall(r"^\s*(\w+)\s*:", body, re.MULTILINE)
return frozenset(keys)


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)


@pytest.mark.parametrize(
("site", "actual"),
[
("utils/tool_dispatch.py (FILE_ACTIVITY_TOOL_TYPES)", FILE_ACTIVITY_TOOL_TYPES),
("utils/md_exporter.py (MD_EXPORTER_TOOL_TYPES)", MD_EXPORTER_TOOL_TYPES),
(
"static/js/render/registry.js (TOOL_USE_RENDERERS)",
_parse_frontend_tool_use_renderers(_FRONTEND_REGISTRY),
),
],
)
def test_tool_type_sets_match_known_registry(site: str, actual: frozenset[str]) -> None:
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
25 changes: 2 additions & 23 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 @@ -390,24 +390,3 @@ def _process_progress(entry: dict[str, Any], messages: list[MessageDict]) -> Non
)


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)
21 changes: 20 additions & 1 deletion utils/md_exporter.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from models.stats import SessionStatsDict
from utils.jsonl_helpers import strip_system_tags
from utils.session_stats import format_duration
from utils.tool_dispatch import KNOWN_TOOL_TYPES


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

return "\n".join(lines)


MD_EXPORTER_TOOL_TYPES: frozenset[str] = frozenset(
{
"AskUserQuestion",
"Bash",
"Edit",
"Glob",
"Grep",
"Read",
"Task",
"TodoWrite",
"WebFetch",
"WebSearch",
"Write",
}
)


def _render_tool_result(parsed: dict[str, Any]) -> str:
"""Format a tool result nicely instead of dumping raw JSON."""
rt = parsed.get("result_type", "unknown")
Expand Down
94 changes: 93 additions & 1 deletion utils/tool_dispatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,23 @@
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 ``KNOWN_TOOL_TYPE_NAMES`` below (alphabetical order).
2. Wire ``_FILE_ACTIVITY_HANDLERS`` (``None`` if no file/bash/web side effects).
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. Add ``(predicate, builder)`` to ``_TOOL_RESULT_DISPATCH`` when the tool has a
distinct ``toolUseResult`` shape (see ordering notes above).
6. 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 +233,84 @@ 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.
KNOWN_TOOL_TYPE_NAMES: tuple[str, ...] = (
"AskUserQuestion",
"Bash",
"Edit",
"Glob",
"Grep",
"Read",
"Task",
"TodoWrite",
"WebFetch",
"WebSearch",
"Write",
)
KNOWN_TOOL_TYPES: frozenset[str] = frozenset(KNOWN_TOOL_TYPE_NAMES)


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)


# Every ``KNOWN_TOOL_TYPES`` entry must appear here (``None`` = no side effects).
_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,
}
FILE_ACTIVITY_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