Skip to content

Commit 77eed93

Browse files
refactor: priority-based tool result dispatch selection
1 parent 964813f commit 77eed93

7 files changed

Lines changed: 131 additions & 90 deletions

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ See [`docs/architecture.md`](docs/architecture.md) for data flow, export state m
145145

146146
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`.
147147

148-
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`).
148+
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 a `ToolResultDispatchEntry` to `_TOOL_RESULT_DISPATCH` (set `priority` when overlapping another predicate — 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`.
150150
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).

docs/architecture.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,13 @@
7171

7272
## Dispatch table
7373

74-
In `utils/tool_dispatch.py`, tool results are classified through `_parse_tool_result`, a **predicate-ordered dispatch table** (not a simple `if tool_name == ...` chain). **Order is load-bearing**: the first matching predicate wins. Tests in `tests/test_jsonl_parser.py` and `tests/test_real_session_fixtures.py` guard ordering regressions.
74+
In `utils/tool_dispatch.py`, tool results are classified through `_parse_tool_result`, a **priority-based dispatch table**. Every matching predicate is considered; the entry with the highest `priority` wins (ties favor earlier registration). Overlap exceptions are explicit priorities, not tuple position — see `tests/test_tool_dispatch_ordering.py` and `tests/test_tool_dispatch_adversarial.py`.
7575

7676
When adding a new tool renderer:
7777

78-
1. Add a `(predicate, builder)` pair to `_TOOL_RESULT_DISPATCH` in `utils/tool_dispatch.py`, preserving existing predicate order unless you also update fixtures and ordering tests (`tests/test_jsonl_parser.py`, `tests/test_real_session_fixtures.py`). Order is **not** “specific before generic” in general — the first match wins. `is_task_message_tool_result` is the intentional broad-before-narrow exception (`task_id` or `message` before retrieval/completed/async).
78+
1. Add a `ToolResultDispatchEntry` to `_TOOL_RESULT_DISPATCH` in `utils/tool_dispatch.py`. Set `priority` higher than any overlapping predicate it must beat, and add a row to `ORDERING_INVARIANTS` in `tests/test_tool_dispatch_ordering.py` when overlaps exist. Documented exceptions: `plan` (priority 1) over `file_write` (0); `task_message` (1) over `task_retrieval` / `task_completed` / `task_async` (0).
7979
2. Add or extend a JSONL fixture under `tests/fixtures/` (especially for overlaps with existing predicates).
80-
3. Run `pytest tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -v`.
80+
3. Run `pytest tests/test_tool_dispatch_ordering.py tests/test_tool_dispatch_adversarial.py tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -v`.
8181

8282
## Export state machine
8383

tests/test_jsonl_parser.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ def test_plan_result(self):
241241
assert r["result_type"] == "plan"
242242

243243
def test_plan_with_content_not_classified_as_file_write(self):
244-
"""plan is registered before file_write in _TOOL_RESULT_DISPATCH."""
244+
"""plan outranks file_write on overlapping toolUseResult blobs."""
245245
r = _parse_tool_result(
246246
{
247247
"plan": [],

tests/test_real_session_fixtures.py

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414

1515
from utils.jsonl_parser import parse_session
16-
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, _parse_tool_result
16+
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, _parse_tool_result, _winning_dispatch_entry
1717

1818
FIXTURES_DIR = os.path.join(os.path.dirname(__file__), "fixtures")
1919

@@ -89,7 +89,7 @@ def test_real_session_minimal_has_bash_tool_result() -> None:
8989

9090

9191
def test_real_session_all_tool_types_covers_dispatch_predicates() -> None:
92-
hit: set[int] = set()
92+
hit: set[str] = set()
9393
path = _fixture_path("real_session_all_tool_types.jsonl")
9494
with open(path, encoding="utf-8") as f:
9595
for line in f:
@@ -100,14 +100,10 @@ def test_real_session_all_tool_types_covers_dispatch_predicates() -> None:
100100
tr = entry.get("toolUseResult")
101101
if not isinstance(tr, dict):
102102
continue
103-
matched = False
104-
for i, (pred, _) in enumerate(_TOOL_RESULT_DISPATCH):
105-
if pred(tr):
106-
hit.add(i)
107-
matched = True
108-
break
109-
assert matched, f"toolUseResult matched no predicate: {list(tr.keys())}"
110-
assert hit == set(range(len(_TOOL_RESULT_DISPATCH)))
103+
winner = _winning_dispatch_entry(tr)
104+
assert winner is not None, f"toolUseResult matched no predicate: {list(tr.keys())}"
105+
hit.add(winner.id)
106+
assert hit == {entry.id for entry in _TOOL_RESULT_DISPATCH}
111107

112108

113109
def test_real_session_nested_tools_has_sidechain_and_tool_use() -> None:

tests/test_tool_dispatch_adversarial.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
"""Behavioral adversarial fixtures for ``_TOOL_RESULT_DISPATCH`` predicate overlap.
22
3-
Structural tuple-position guards live in ``test_tool_dispatch_ordering.py``.
3+
Structural priority guards live in ``test_tool_dispatch_ordering.py``.
44
These tests construct ``toolUseResult`` JSON that satisfies multiple predicates
5-
and assert the classified winner via ``_parse_tool_result`` (first match wins).
5+
and assert the classified winner via ``_parse_tool_result`` (highest priority).
66
"""
77

88
from __future__ import annotations
99

1010
from collections.abc import Callable
11+
from dataclasses import replace
1112

1213
import pytest
1314

@@ -118,19 +119,19 @@ def test_task_retrieval_narrow_shape_without_task_message_keys() -> None:
118119
assert result.get("task_id") == "task-narrow"
119120

120121

121-
def test_inverted_plan_file_write_dispatch_misclassifies_overlap(
122+
def test_inverted_plan_file_write_priority_misclassifies_overlap(
122123
monkeypatch: pytest.MonkeyPatch,
123124
) -> None:
124-
"""Regression: swapping plan below file_write flips the overlap winner."""
125-
table = list(_TOOL_RESULT_DISPATCH)
126-
plan_idx = next(
127-
i for i, (pred, _) in enumerate(table) if pred.__name__ == "is_plan_tool_result"
125+
"""Regression: giving file_write higher priority than plan flips the overlap winner."""
126+
table = tuple(
127+
replace(entry, priority=1)
128+
if entry.id == "file_write"
129+
else replace(entry, priority=0)
130+
if entry.id == "plan"
131+
else entry
132+
for entry in _TOOL_RESULT_DISPATCH
128133
)
129-
write_idx = next(
130-
i for i, (pred, _) in enumerate(table) if pred.__name__ == "is_file_write_tool_result"
131-
)
132-
table[plan_idx], table[write_idx] = table[write_idx], table[plan_idx]
133-
monkeypatch.setattr(tool_dispatch, "_TOOL_RESULT_DISPATCH", tuple(table))
134+
monkeypatch.setattr(tool_dispatch, "_TOOL_RESULT_DISPATCH", table)
134135

135136
result = _parse_tool_result(PLAN_FILE_WRITE_OVERLAP)
136137
assert result is not None
Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""Structural ordering invariants for ``_TOOL_RESULT_DISPATCH``.
1+
"""Structural priority invariants for ``_TOOL_RESULT_DISPATCH``.
22
3-
First matching predicate wins; misordering silently misclassifies tool results.
4-
Invariants are declared as ``(before, after, reason)`` triples — add a row to
5-
``ORDERING_INVARIANTS`` when inserting a predicate that must sit above another.
3+
When multiple predicates match, the highest ``priority`` wins. Invariants are
4+
declared as ``(before, after, reason)`` triples — add a row to
5+
``ORDERING_INVARIANTS`` when a new predicate must outrank another on overlap.
66
"""
77

88
from collections.abc import Callable
@@ -17,30 +17,30 @@
1717
is_task_message_tool_result,
1818
is_task_retrieval_tool_result,
1919
)
20-
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH
20+
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, ToolResultDispatchEntry
2121

2222
Predicate = Callable[..., bool]
2323

2424
ORDERING_INVARIANTS: list[tuple[Predicate, Predicate, str]] = [
2525
(
2626
is_plan_tool_result,
2727
is_file_write_tool_result,
28-
"plan blobs may carry filePath + content; plan must win before file_write",
28+
"plan blobs may carry filePath + content; plan must outrank file_write",
2929
),
3030
(
3131
is_task_message_tool_result,
3232
is_task_retrieval_tool_result,
33-
"task_message is broad (task_id or message); must precede narrower task_retrieval",
33+
"task_message is broad (task_id or message); must outrank task_retrieval",
3434
),
3535
(
3636
is_task_message_tool_result,
3737
is_task_completed_tool_result,
38-
"task_message is broad (task_id or message); must precede narrower task_completed",
38+
"task_message is broad (task_id or message); must outrank task_completed",
3939
),
4040
(
4141
is_task_message_tool_result,
4242
is_task_async_tool_result,
43-
"task_message is broad (task_id or message); must precede narrower task_async",
43+
"task_message is broad (task_id or message); must outrank task_async",
4444
),
4545
]
4646

@@ -52,12 +52,10 @@
5252
]
5353

5454

55-
def _predicate_index(predicate: Predicate) -> int:
56-
for i, entry in enumerate(_TOOL_RESULT_DISPATCH):
57-
pred = entry[0]
58-
# Identity match: dispatch table must store bare function refs (not wrappers).
59-
if pred is predicate:
60-
return i
55+
def _entry_for(predicate: Predicate) -> ToolResultDispatchEntry:
56+
for entry in _TOOL_RESULT_DISPATCH:
57+
if entry.predicate is predicate:
58+
return entry
6159
raise ValueError(f"predicate {predicate.__name__} not found in _TOOL_RESULT_DISPATCH")
6260

6361

@@ -71,10 +69,10 @@ def test_tool_dispatch_ordering_invariant(
7169
after: Predicate,
7270
reason: str,
7371
) -> None:
74-
before_idx = _predicate_index(before)
75-
after_idx = _predicate_index(after)
76-
assert before_idx < after_idx, (
77-
f"_TOOL_RESULT_DISPATCH ordering violation: "
78-
f"{before.__name__} (index {before_idx}) must precede "
79-
f"{after.__name__} (index {after_idx}). Reason: {reason}"
72+
before_entry = _entry_for(before)
73+
after_entry = _entry_for(after)
74+
assert before_entry.priority > after_entry.priority, (
75+
f"_TOOL_RESULT_DISPATCH priority violation: "
76+
f"{before.__name__} (priority {before_entry.priority}) must outrank "
77+
f"{after.__name__} (priority {after_entry.priority}). Reason: {reason}"
8078
)

utils/tool_dispatch.py

Lines changed: 88 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
11
"""Tool-result classification for Claude Code JSONL toolUseResult blobs.
22
3-
Dispatch registry: **first matching predicate wins** (legacy if/elif parity).
4-
Order is load-bearing — do not sort alphabetically or "more specific first"
5-
without replaying tests and real session fixtures.
3+
Dispatch registry: among **all matching predicates**, the entry with the highest
4+
``priority`` wins (ties favor earlier registration). Default priority is 0.
5+
Documented overlap exceptions use priority 1 so contributors set priority on
6+
new shapes instead of reasoning about full tuple position:
67
7-
Notably ``task_message`` is broad (``task_id`` or ``message``) and sits before
8-
``task_retrieval`` / ``task_completed`` / ``task_async``.
8+
- ``plan`` (1) over ``file_write`` (0) when both match — plan blobs may carry
9+
``filePath`` + ``content``.
10+
- ``task_message`` (1) over ``task_retrieval`` / ``task_completed`` /
11+
``task_async`` (0) — ``task_message`` is broad (``task_id`` or ``message``).
912
10-
To add a shape: append ``(pred, build)`` at the end, or insert only after
11-
verifying predicates above would not steal intended matches.
12-
13-
Ordering invariants are enforced structurally by
14-
``tests/test_tool_dispatch_ordering.py`` — add a ``(before, after, reason)``
15-
tuple there when a new predicate must sit above another.
13+
To add a shape: append a ``ToolResultDispatchEntry`` with predicate, builder,
14+
and priority. If the new predicate overlaps an existing one, set priority higher
15+
than the shapes it must beat and add a row to ``ORDERING_INVARIANTS`` in
16+
``tests/test_tool_dispatch_ordering.py``.
1617
1718
Predicates live in ``models.tool_results`` (single source of truth for narrowing).
1819
@@ -22,8 +23,9 @@
2223
side effects); ``KNOWN_TOOL_TYPES`` is derived from its keys.
2324
2. Add the name to ``ToolNameLiteral`` in ``models/tool_results.py`` and, if the
2425
tool has a distinct ``toolUseResult`` JSON shape, add the TypedDict, predicate,
25-
and ``(predicate, builder)`` pair in ``_TOOL_RESULT_DISPATCH`` (respect ordering
26-
— see notes above and ``tests/test_tool_dispatch_ordering.py``).
26+
and ``ToolResultDispatchEntry`` in ``_TOOL_RESULT_DISPATCH`` (set ``priority``
27+
when overlapping another predicate — see notes above and
28+
``tests/test_tool_dispatch_ordering.py``).
2729
3. Add a Markdown branch in ``utils/md_exporter.py`` ``_render_tool_use``.
2830
4. Add ``TOOL_USE_RENDERERS`` entry in ``static/js/render/registry.js``.
2931
5. Run ``pytest tests/test_tool_dispatch_sync.py -v`` — it fails with the
@@ -33,7 +35,8 @@
3335
"""
3436

3537
from collections.abc import Callable
36-
from typing import Any, cast
38+
from dataclasses import dataclass
39+
from typing import Any
3740

3841
from models.session import SessionMetadataBuilderDict
3942
from models.tool_results import (
@@ -57,6 +60,20 @@
5760
is_web_search_tool_result,
5861
)
5962

63+
_DISPATCH_PRIORITY_DEFAULT = 0
64+
# Overlap winners: broad predicates that must beat narrower shapes on the same blob.
65+
_DISPATCH_PRIORITY_OVERLAP = 1
66+
67+
68+
@dataclass(frozen=True, slots=True)
69+
class ToolResultDispatchEntry:
70+
"""One toolUseResult classifier: predicate, builder, and overlap priority."""
71+
72+
id: str
73+
predicate: Callable[[ToolResultDict], bool]
74+
build: Callable[[ToolResultDict, dict[str, object]], dict[str, object]]
75+
priority: int = _DISPATCH_PRIORITY_DEFAULT
76+
6077

6178
def _tool_result_build_bash(tr: ToolResultDict, base: dict[str, object]) -> dict[str, object]:
6279
result = dict(base)
@@ -216,26 +233,41 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) -
216233
return result
217234

218235

219-
# Registry order is load-bearing (see module docstring).
220-
# ``plan`` before ``file_write``: plan blobs may carry ``filePath`` + ``content``.
221-
_TOOL_RESULT_DISPATCH = (
222-
(is_bash_tool_result, _tool_result_build_bash),
223-
(is_file_edit_tool_result, _tool_result_build_file_edit),
224-
(is_plan_tool_result, _tool_result_build_plan),
225-
(is_file_write_tool_result, _tool_result_build_file_write),
226-
(is_glob_tool_result, _tool_result_build_glob),
227-
(is_grep_tool_result, _tool_result_build_grep),
228-
(is_read_tool_result, _tool_result_build_file_read),
229-
(is_web_search_tool_result, _tool_result_build_web_search),
230-
(is_web_fetch_tool_result, _tool_result_build_web_fetch),
231-
(is_task_message_tool_result, _tool_result_build_task_message),
232-
(is_task_retrieval_tool_result, _tool_result_build_task_retrieval),
233-
(is_task_completed_tool_result, _tool_result_build_task_completed),
234-
(is_task_async_tool_result, _tool_result_build_task_async),
235-
(is_todo_write_tool_result, _tool_result_build_todo_write),
236-
(is_user_input_tool_result, _tool_result_build_user_input),
236+
# Registration order is tie-break only when priorities are equal.
237+
_TOOL_RESULT_DISPATCH: tuple[ToolResultDispatchEntry, ...] = (
238+
ToolResultDispatchEntry("bash", is_bash_tool_result, _tool_result_build_bash),
239+
ToolResultDispatchEntry("file_edit", is_file_edit_tool_result, _tool_result_build_file_edit),
240+
ToolResultDispatchEntry(
241+
"plan",
242+
is_plan_tool_result,
243+
_tool_result_build_plan,
244+
priority=_DISPATCH_PRIORITY_OVERLAP,
245+
),
246+
ToolResultDispatchEntry("file_write", is_file_write_tool_result, _tool_result_build_file_write),
247+
ToolResultDispatchEntry("glob", is_glob_tool_result, _tool_result_build_glob),
248+
ToolResultDispatchEntry("grep", is_grep_tool_result, _tool_result_build_grep),
249+
ToolResultDispatchEntry("read", is_read_tool_result, _tool_result_build_file_read),
250+
ToolResultDispatchEntry("web_search", is_web_search_tool_result, _tool_result_build_web_search),
251+
ToolResultDispatchEntry("web_fetch", is_web_fetch_tool_result, _tool_result_build_web_fetch),
252+
ToolResultDispatchEntry(
253+
"task_message",
254+
is_task_message_tool_result,
255+
_tool_result_build_task_message,
256+
priority=_DISPATCH_PRIORITY_OVERLAP,
257+
),
258+
ToolResultDispatchEntry(
259+
"task_retrieval", is_task_retrieval_tool_result, _tool_result_build_task_retrieval
260+
),
261+
ToolResultDispatchEntry(
262+
"task_completed", is_task_completed_tool_result, _tool_result_build_task_completed
263+
),
264+
ToolResultDispatchEntry("task_async", is_task_async_tool_result, _tool_result_build_task_async),
265+
ToolResultDispatchEntry("todo_write", is_todo_write_tool_result, _tool_result_build_todo_write),
266+
ToolResultDispatchEntry("user_input", is_user_input_tool_result, _tool_result_build_user_input),
237267
)
238268

269+
_DISPATCH_ORDER = {entry.id: index for index, entry in enumerate(_TOOL_RESULT_DISPATCH)}
270+
239271
# Claude Code assistant tool_use ``name`` values coordinated across parser file
240272
# activity, Markdown export, and the SPA ``TOOL_USE_RENDERERS`` map.
241273
# ``_FILE_ACTIVITY_HANDLERS`` is the single registry; ``KNOWN_TOOL_TYPES`` is derived.
@@ -303,27 +335,41 @@ def track_tool_file_activity(
303335
handler(tool_input, metadata)
304336

305337

338+
def _matching_dispatch_entries(tr: ToolResultDict) -> list[ToolResultDispatchEntry]:
339+
return [entry for entry in _TOOL_RESULT_DISPATCH if entry.predicate(tr)]
340+
341+
342+
def _winning_dispatch_entry(tr: ToolResultDict) -> ToolResultDispatchEntry | None:
343+
matches = _matching_dispatch_entries(tr)
344+
if not matches:
345+
return None
346+
return max(
347+
matches,
348+
key=lambda entry: (entry.priority, -_DISPATCH_ORDER[entry.id]),
349+
)
350+
351+
306352
def _parse_tool_result(
307353
tool_result: ToolResultUnion | None, slug: str | None = None
308354
) -> dict[str, object] | None:
309355
"""Figure out what kind of tool result this is (bash, file edit, glob, etc.)
310356
by looking at which keys are present, since the JSONL doesn't always tag them.
311357
312-
Classification uses ``_TOOL_RESULT_DISPATCH``: ordered ``(predicate, builder)``
313-
pairs; the **first** predicate that matches wins (parity with the historical
314-
``if``/``elif`` chain — order is not strictly “specific before generic”).
358+
Classification uses ``_TOOL_RESULT_DISPATCH``: every matching predicate is
359+
considered; the entry with the highest ``priority`` wins (ties favor earlier
360+
registration). Set ``priority`` above overlapping shapes instead of relying on
361+
tuple position — see module docstring for documented overlap exceptions.
315362
316-
Append a new pair at the end to register a shape, or insert mid-table only
317-
after checking interactions with broader predicates above (see notes on the
318-
tuple)."""
363+
Append a new ``ToolResultDispatchEntry`` to register a shape. If it overlaps an
364+
existing predicate, raise its ``priority`` and add a row to
365+
``ORDERING_INVARIANTS`` in ``tests/test_tool_dispatch_ordering.py``."""
319366
if not is_tool_result_dict(tool_result):
320367
return None
321368

322369
base: dict[str, object] = {"slug": slug}
323-
for pred, build in _TOOL_RESULT_DISPATCH:
324-
if pred(tool_result):
325-
# Builders take ToolResultDict; cast after pred (heterogeneous tuple, no union narrow).
326-
return build(cast(ToolResultDict, tool_result), base)
370+
winner = _winning_dispatch_entry(tool_result)
371+
if winner is not None:
372+
return winner.build(tool_result, base)
327373

328374
result = dict(base)
329375
result["result_type"] = "unknown"

0 commit comments

Comments
 (0)