|
1 | 1 | """Tool-result classification for Claude Code JSONL toolUseResult blobs. |
2 | 2 |
|
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: |
6 | 7 |
|
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``). |
9 | 12 |
|
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``. |
16 | 17 |
|
17 | 18 | Predicates live in ``models.tool_results`` (single source of truth for narrowing). |
18 | 19 |
|
|
22 | 23 | side effects); ``KNOWN_TOOL_TYPES`` is derived from its keys. |
23 | 24 | 2. Add the name to ``ToolNameLiteral`` in ``models/tool_results.py`` and, if the |
24 | 25 | 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``). |
27 | 29 | 3. Add a Markdown branch in ``utils/md_exporter.py`` ``_render_tool_use``. |
28 | 30 | 4. Add ``TOOL_USE_RENDERERS`` entry in ``static/js/render/registry.js``. |
29 | 31 | 5. Run ``pytest tests/test_tool_dispatch_sync.py -v`` — it fails with the |
|
33 | 35 | """ |
34 | 36 |
|
35 | 37 | from collections.abc import Callable |
36 | | -from typing import Any, cast |
| 38 | +from dataclasses import dataclass |
| 39 | +from typing import Any |
37 | 40 |
|
38 | 41 | from models.session import SessionMetadataBuilderDict |
39 | 42 | from models.tool_results import ( |
|
57 | 60 | is_web_search_tool_result, |
58 | 61 | ) |
59 | 62 |
|
| 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 | + |
60 | 77 |
|
61 | 78 | def _tool_result_build_bash(tr: ToolResultDict, base: dict[str, object]) -> dict[str, object]: |
62 | 79 | result = dict(base) |
@@ -216,26 +233,41 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) - |
216 | 233 | return result |
217 | 234 |
|
218 | 235 |
|
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), |
237 | 267 | ) |
238 | 268 |
|
| 269 | +_DISPATCH_ORDER = {entry.id: index for index, entry in enumerate(_TOOL_RESULT_DISPATCH)} |
| 270 | + |
239 | 271 | # Claude Code assistant tool_use ``name`` values coordinated across parser file |
240 | 272 | # activity, Markdown export, and the SPA ``TOOL_USE_RENDERERS`` map. |
241 | 273 | # ``_FILE_ACTIVITY_HANDLERS`` is the single registry; ``KNOWN_TOOL_TYPES`` is derived. |
@@ -303,27 +335,41 @@ def track_tool_file_activity( |
303 | 335 | handler(tool_input, metadata) |
304 | 336 |
|
305 | 337 |
|
| 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 | + |
306 | 352 | def _parse_tool_result( |
307 | 353 | tool_result: ToolResultUnion | None, slug: str | None = None |
308 | 354 | ) -> dict[str, object] | None: |
309 | 355 | """Figure out what kind of tool result this is (bash, file edit, glob, etc.) |
310 | 356 | by looking at which keys are present, since the JSONL doesn't always tag them. |
311 | 357 |
|
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. |
315 | 362 |
|
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``.""" |
319 | 366 | if not is_tool_result_dict(tool_result): |
320 | 367 | return None |
321 | 368 |
|
322 | 369 | 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) |
327 | 373 |
|
328 | 374 | result = dict(base) |
329 | 375 | result["result_type"] = "unknown" |
|
0 commit comments