Skip to content

Commit 61153ab

Browse files
fix: task_message dispatch uses order, not priority boost
1 parent 26baa45 commit 61153ab

5 files changed

Lines changed: 98 additions & 56 deletions

File tree

docs/architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ In `utils/tool_dispatch.py`, tool results are classified through `_parse_tool_re
7575

7676
When adding a new tool renderer:
7777

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).
78+
1. Add a `ToolResultDispatchEntry` to `_TOOL_RESULT_DISPATCH` in `utils/tool_dispatch.py`. Set `priority` higher than any overlapping predicate it must beat without relying on registration order, and add a row to `ORDERING_INVARIANTS` in `tests/test_tool_dispatch_ordering.py` when overlaps exist. Documented exception: `plan` (priority 1) over `file_write` (0). Broad predicates like `task_message` use registration order instead of elevated priority.
7979
2. Add or extend a JSONL fixture under `tests/fixtures/` (especially for overlaps with existing predicates).
8080
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

tests/test_real_session_fixtures.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,10 @@ def test_task_completed_with_message_key_matches_task_message_first() -> None:
145145
"""task_message outranks task_completed when ``message`` key is present.
146146
147147
``is_task_message_tool_result`` matches any dict with a ``message`` or ``task_id``
148-
key and holds priority 1, beating the narrower task predicates at priority 0.
149-
Future tool shapes that add ``message`` for status text (e.g. web-fetch) would
150-
be misclassified as task unless given higher priority — this test locks that
151-
known false-positive surface.
148+
key and is registered before the narrower task predicates, so registration order
149+
picks it on overlap. Future tool shapes that add ``message`` for status text
150+
(e.g. web-fetch) would be misclassified as task unless dispatch is refined —
151+
this test locks that known false-positive surface.
152152
"""
153153
tr = {
154154
"agentId": "agent-sanitized",

tests/test_tool_dispatch_adversarial.py

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
"""Behavioral adversarial fixtures for ``_TOOL_RESULT_DISPATCH`` predicate overlap.
22
3-
Structural priority guards live in ``test_tool_dispatch_ordering.py``.
4-
These tests construct ``toolUseResult`` JSON that satisfies multiple predicates
5-
and assert the classified winner via ``_parse_tool_result`` (highest priority).
3+
Overlap blobs and invariant tables live in ``test_tool_dispatch_ordering.py``.
4+
These tests assert classified output via ``_parse_tool_result``.
65
"""
76

87
from __future__ import annotations
@@ -12,45 +11,32 @@
1211

1312
import pytest
1413

15-
from tests.test_tool_dispatch_ordering import ORDERING_INVARIANT_IDS, ORDERING_INVARIANTS
14+
from models.tool_results import is_file_write_tool_result, is_task_message_tool_result
15+
from tests.test_tool_dispatch_ordering import (
16+
ORDERING_INVARIANT_IDS,
17+
ORDERING_INVARIANTS,
18+
OVERLAP_BLOBS,
19+
)
1620
from utils import tool_dispatch
17-
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, _parse_tool_result
18-
19-
# Overlap blobs: keys chosen so both predicates in each ORDERING_INVARIANTS pair match.
20-
PLAN_FILE_WRITE_OVERLAP: dict[str, object] = {
21-
"plan": {"name": "sprint-plan", "steps": ["index", "search", "render"]},
22-
"filePath": ".cursor/plans/week28.md",
23-
"content": "Plan body that would also satisfy file_write.",
24-
}
25-
26-
TASK_MESSAGE_RETRIEVAL_OVERLAP: dict[str, object] = {
27-
"task_id": "task-overlap-retrieval",
28-
"message": "polling retrieval",
29-
"retrieval_status": "found",
30-
"task": {"task_id": "task-overlap-retrieval", "description": "subagent scan"},
31-
}
32-
33-
TASK_MESSAGE_COMPLETED_OVERLAP: dict[str, object] = {
34-
"agentId": "agent-overlap-completed",
35-
"totalDurationMs": 3200,
36-
"totalTokens": 900,
37-
"status": "completed",
38-
"message": "task finished",
39-
}
21+
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, _parse_tool_result, _winning_dispatch_entry
4022

41-
TASK_MESSAGE_ASYNC_OVERLAP: dict[str, object] = {
42-
"agentId": "agent-overlap-async",
43-
"isAsync": True,
44-
"description": "explore auth handlers",
45-
"message": "task launched",
46-
}
23+
PLAN_FILE_WRITE_OVERLAP = OVERLAP_BLOBS["plan_before_file_write"]
24+
TASK_MESSAGE_RETRIEVAL_OVERLAP = OVERLAP_BLOBS["task_message_before_task_retrieval"]
25+
TASK_MESSAGE_COMPLETED_OVERLAP = OVERLAP_BLOBS["task_message_before_task_completed"]
26+
TASK_MESSAGE_ASYNC_OVERLAP = OVERLAP_BLOBS["task_message_before_task_async"]
4727

4828
# Narrow retrieval shape: only the downstream predicate matches (no task_id/message).
4929
TASK_RETRIEVAL_NARROW: dict[str, object] = {
5030
"retrieval_status": "pending",
5131
"task": {"task_id": "task-narrow", "description": "wait for result"},
5232
}
5333

34+
FILE_WRITE_TASK_MESSAGE_OVERLAP: dict[str, object] = {
35+
"message": "status line on a write result",
36+
"filePath": "/tmp/example.txt",
37+
"content": "file body",
38+
}
39+
5440
AssertWinner = Callable[[dict[str, object]], None]
5541

5642

@@ -119,6 +105,19 @@ def test_task_retrieval_narrow_shape_without_task_message_keys() -> None:
119105
assert result.get("task_id") == "task-narrow"
120106

121107

108+
def test_file_write_beats_task_message_when_both_match() -> None:
109+
"""Regression: broad task_message must not outrank earlier shapes via priority."""
110+
blob = FILE_WRITE_TASK_MESSAGE_OVERLAP
111+
assert is_file_write_tool_result(blob)
112+
assert is_task_message_tool_result(blob)
113+
winner = _winning_dispatch_entry(blob)
114+
assert winner is not None
115+
assert winner.id == "file_write"
116+
result = _parse_tool_result(blob)
117+
assert result is not None
118+
assert result["result_type"] == "file_write"
119+
120+
122121
def test_inverted_plan_file_write_priority_misclassifies_overlap(
123122
monkeypatch: pytest.MonkeyPatch,
124123
) -> None:
@@ -146,3 +145,4 @@ def test_ordering_invariants_have_adversarial_coverage() -> None:
146145
assert len(ORDERING_INVARIANTS) == len(ORDERING_INVARIANT_IDS)
147146
assert len(ORDERING_INVARIANT_IDS) == len(_INVARIANT_BEHAVIOR)
148147
assert set(_INVARIANT_BEHAVIOR.keys()) == set(ORDERING_INVARIANT_IDS)
148+
assert set(OVERLAP_BLOBS.keys()) == set(ORDERING_INVARIANT_IDS)

tests/test_tool_dispatch_ordering.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
"""Structural priority invariants for ``_TOOL_RESULT_DISPATCH``.
1+
"""Structural overlap invariants for ``_TOOL_RESULT_DISPATCH``.
22
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.
3+
When multiple predicates match, the highest ``priority`` wins; equal priority
4+
favors earlier registration. Invariants are declared as ``(before, after, reason)``
5+
triples with a shared overlap blob — add a row to ``ORDERING_INVARIANTS`` when a
6+
new predicate must outrank another on overlap.
67
"""
78

89
from collections.abc import Callable
@@ -17,7 +18,11 @@
1718
is_task_message_tool_result,
1819
is_task_retrieval_tool_result,
1920
)
20-
from utils.tool_dispatch import _TOOL_RESULT_DISPATCH, ToolResultDispatchEntry
21+
from utils.tool_dispatch import (
22+
_TOOL_RESULT_DISPATCH,
23+
ToolResultDispatchEntry,
24+
_winning_dispatch_entry,
25+
)
2126

2227
Predicate = Callable[..., bool]
2328

@@ -51,6 +56,34 @@
5156
"task_message_before_task_async",
5257
]
5358

59+
# Overlap blobs: keys chosen so both predicates in each ORDERING_INVARIANTS pair match.
60+
OVERLAP_BLOBS: dict[str, dict[str, object]] = {
61+
"plan_before_file_write": {
62+
"plan": {"name": "sprint-plan", "steps": ["index", "search", "render"]},
63+
"filePath": ".cursor/plans/week28.md",
64+
"content": "Plan body that would also satisfy file_write.",
65+
},
66+
"task_message_before_task_retrieval": {
67+
"task_id": "task-overlap-retrieval",
68+
"message": "polling retrieval",
69+
"retrieval_status": "found",
70+
"task": {"task_id": "task-overlap-retrieval", "description": "subagent scan"},
71+
},
72+
"task_message_before_task_completed": {
73+
"agentId": "agent-overlap-completed",
74+
"totalDurationMs": 3200,
75+
"totalTokens": 900,
76+
"status": "completed",
77+
"message": "task finished",
78+
},
79+
"task_message_before_task_async": {
80+
"agentId": "agent-overlap-async",
81+
"isAsync": True,
82+
"description": "explore auth handlers",
83+
"message": "task launched",
84+
},
85+
}
86+
5487

5588
def _entry_for(predicate: Predicate) -> ToolResultDispatchEntry:
5689
for entry in _TOOL_RESULT_DISPATCH:
@@ -60,19 +93,27 @@ def _entry_for(predicate: Predicate) -> ToolResultDispatchEntry:
6093

6194

6295
@pytest.mark.parametrize(
63-
"before,after,reason",
64-
ORDERING_INVARIANTS,
96+
"before,after,reason,fixture_id",
97+
[
98+
(*row, invariant_id)
99+
for row, invariant_id in zip(ORDERING_INVARIANTS, ORDERING_INVARIANT_IDS, strict=True)
100+
],
65101
ids=ORDERING_INVARIANT_IDS,
66102
)
67103
def test_tool_dispatch_ordering_invariant(
68104
before: Predicate,
69105
after: Predicate,
70106
reason: str,
107+
fixture_id: str,
71108
) -> None:
109+
blob = OVERLAP_BLOBS[fixture_id]
72110
before_entry = _entry_for(before)
73111
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}"
112+
assert before(blob), f"{fixture_id}: fixture no longer matches {before.__name__}"
113+
assert after(blob), f"{fixture_id}: fixture no longer matches {after.__name__}"
114+
winner = _winning_dispatch_entry(blob)
115+
assert winner is not None
116+
assert winner.id == before_entry.id, (
117+
f"_TOOL_RESULT_DISPATCH overlap violation: expected {before_entry.id!r} "
118+
f"to beat {after_entry.id!r} on {fixture_id}. Reason: {reason}"
78119
)

utils/tool_dispatch.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
33
Dispatch registry: among **all matching predicates**, the entry with the highest
44
``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:
5+
Use ``priority`` when a shape must beat a **later** overlapping entry without
6+
moving registration order. Documented overlap exception:
77
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``).
8+
- ``plan`` (priority 1) over ``file_write`` (0) when both match — plan blobs may
9+
carry ``filePath`` + ``content``.
10+
11+
``task_message`` stays at default priority and relies on registration order
12+
(before ``task_retrieval`` / ``task_completed`` / ``task_async``) because its
13+
predicate is broad (``task_id`` or ``message``) and must not beat earlier shapes.
1214
1315
To add a shape: append a ``ToolResultDispatchEntry`` with predicate, builder,
1416
and priority. If the new predicate overlaps an existing one, set priority higher
@@ -254,7 +256,6 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) -
254256
"task_message",
255257
is_task_message_tool_result,
256258
_tool_result_build_task_message,
257-
priority=_DISPATCH_PRIORITY_OVERLAP,
258259
),
259260
ToolResultDispatchEntry(
260261
"task_retrieval", is_task_retrieval_tool_result, _tool_result_build_task_retrieval

0 commit comments

Comments
 (0)