Skip to content

Commit 57462fd

Browse files
refactor(models): narrow Any-typed parse boundary with TypedDict unions (#72)
* refactor(models): narrow Any-typed parse boundary with TypedDict unions Replace NotRequired[Any] on MessageDict.tool_result and data with per-tool and per-record unions, type guards, and dispatch-aligned narrowing so mypy strict constrains schema drift without weakening tests. * fix(models): sort imports for ruff I001 in models package * style: ruff format jsonl_parser and tool_dispatch * fix(models): make BashProgressDataDict non-total for safe TypeGuard * fix(models): wire tool_dispatch to tool_results TypeGuards (PR #72 review) Remove duplicate _tool_result_pred_* predicates; dispatch registry uses TypeGuards from models/tool_results.py as single source of truth. Drop unused is_bash_progress_data; document ToolNameLiteral | str intent.
1 parent 32e60f4 commit 57462fd

9 files changed

Lines changed: 424 additions & 137 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 `(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. `_tool_result_pred_task_message` is the intentional broad-before-narrow exception (`task_id` or `message` before retrieval/completed/async).
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).
7979
2. Add or extend a JSONL fixture under `tests/fixtures/` (especially for overlaps with existing predicates).
8080
3. Run `pytest tests/test_jsonl_parser.py tests/test_real_session_fixtures.py -v`.
8181

models/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@
33
from models.errors import ErrorResponse
44
from models.export import ExportStateDict
55
from models.project import ProjectDict, ProjectSessionRowDict, SessionListItemDict
6+
from models.record_data import RecordDataUnion
67
from models.search import SearchHitDict
78
from models.session import (
89
MessageDict,
910
QuickSessionInfoDict,
1011
SessionDict,
1112
SessionMetadataDict,
13+
ToolUseDict,
1214
)
1315
from models.stats import FilesTouchedDict, SessionStatsDict
16+
from models.tool_results import ToolResultUnion
1417

1518
__all__ = [
1619
"ErrorResponse",
@@ -25,4 +28,7 @@
2528
"SessionListItemDict",
2629
"SessionMetadataDict",
2730
"SessionStatsDict",
31+
"RecordDataUnion",
32+
"ToolResultUnion",
33+
"ToolUseDict",
2834
]

models/record_data.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""TypedDict shapes for record-level ``data`` payloads on progress messages."""
2+
3+
from typing import Literal, TypedDict
4+
5+
6+
class BashProgressDataDict(TypedDict, total=False):
7+
type: Literal["bash_progress"]
8+
output: str
9+
10+
11+
class HookProgressDataDict(TypedDict, total=False):
12+
type: Literal["hook_progress"]
13+
output: str
14+
15+
16+
class AgentProgressDataDict(TypedDict, total=False):
17+
type: Literal["agent_progress"]
18+
message: str
19+
20+
21+
class SummaryDataDict(TypedDict, total=False):
22+
"""Summary-style progress payloads (when present on progress entries)."""
23+
24+
type: Literal["summary"]
25+
summary: str
26+
27+
28+
class CompactBoundaryDataDict(TypedDict, total=False):
29+
"""Compact-boundary metadata when carried on a data blob."""
30+
31+
type: Literal["compact_boundary"]
32+
trigger: str
33+
pre_tokens: int
34+
35+
36+
RecordDataUnion = (
37+
BashProgressDataDict
38+
| HookProgressDataDict
39+
| AgentProgressDataDict
40+
| SummaryDataDict
41+
| CompactBoundaryDataDict
42+
| dict[str, object]
43+
)

models/session.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,27 @@
11
"""Parsed session shapes from jsonl_parser."""
22

3-
from typing import Any, NotRequired, TypedDict
3+
from typing import Any, Literal, NotRequired, TypedDict
4+
5+
from models.record_data import RecordDataUnion
6+
from models.tool_results import ToolNameLiteral, ToolResultUnion
7+
8+
9+
class ToolUseDict(TypedDict, total=False):
10+
id: str
11+
# Literal | str is just str for mypy — documents known tool names, not exhaustiveness.
12+
name: ToolNameLiteral | str
13+
input: dict[str, object]
14+
15+
16+
class MessageUsageDict(TypedDict, total=False):
17+
input_tokens: int
18+
output_tokens: int
19+
cache_read: int
20+
cache_creation: int
21+
service_tier: str | None
22+
23+
24+
SystemSubtypeLiteral = Literal["compact_boundary", "init"]
425

526

627
class MessageDict(TypedDict):
@@ -12,18 +33,18 @@ class MessageDict(TypedDict):
1233
content: NotRequired[str]
1334
images: NotRequired[list[Any] | None]
1435
is_sidechain: NotRequired[bool]
15-
tool_result: NotRequired[Any]
16-
tool_result_parsed: NotRequired[dict[str, Any] | None]
36+
tool_result: NotRequired[ToolResultUnion | None]
37+
tool_result_parsed: NotRequired[dict[str, object] | None]
1738
slug: NotRequired[str | None]
1839
model: NotRequired[str]
1940
stop_reason: NotRequired[str]
2041
thinking: NotRequired[str | None]
21-
tool_uses: NotRequired[list[dict[str, Any]] | None]
42+
tool_uses: NotRequired[list[ToolUseDict] | None]
2243
is_api_error: NotRequired[bool]
23-
usage: NotRequired[dict[str, Any]]
44+
usage: NotRequired[MessageUsageDict]
2445
subtype: NotRequired[str]
2546
level: NotRequired[str]
26-
data: NotRequired[Any]
47+
data: NotRequired[RecordDataUnion]
2748
progress_type: NotRequired[str]
2849
tool_use_id: NotRequired[str | None]
2950
parent_tool_use_id: NotRequired[str | None]

models/tool_results.py

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
"""TypedDict shapes for Claude Code toolUseResult blobs at the JSONL parse boundary.
2+
3+
Ground truth: tests/test_jsonl_parser.py, tests/test_real_session_fixtures.py,
4+
and utils/tool_dispatch.py predicate order (first match wins).
5+
"""
6+
7+
from typing import Literal, TypedDict, TypeGuard
8+
9+
10+
class BashToolResultDict(TypedDict, total=False):
11+
stdout: str
12+
stderr: str
13+
exitCode: int
14+
interrupted: bool
15+
is_error: bool
16+
returnCodeInterpretation: str
17+
18+
19+
class FileEditToolResultDict(TypedDict, total=False):
20+
structuredPatch: str
21+
filePath: str
22+
newString: str
23+
replaceAll: bool
24+
25+
26+
class PlanToolResultDict(TypedDict, total=False):
27+
plan: list[object]
28+
filePath: str
29+
content: str
30+
31+
32+
class FileWriteToolResultDict(TypedDict, total=False):
33+
filePath: str
34+
content: str
35+
36+
37+
class GlobToolResultDict(TypedDict, total=False):
38+
filenames: list[str]
39+
numFiles: int
40+
truncated: bool
41+
durationMs: int
42+
43+
44+
class GrepToolResultDict(TypedDict, total=False):
45+
mode: str
46+
numFiles: int
47+
numLines: int
48+
content: str
49+
durationMs: int
50+
51+
52+
class ReadFileObjDict(TypedDict, total=False):
53+
filePath: str
54+
numLines: int
55+
content: str
56+
57+
58+
class ReadToolResultDict(TypedDict, total=False):
59+
file: ReadFileObjDict
60+
content: list[object]
61+
62+
63+
class WebSearchToolResultDict(TypedDict, total=False):
64+
query: str
65+
results: list[object] | None
66+
durationSeconds: float
67+
68+
69+
class WebFetchToolResultDict(TypedDict, total=False):
70+
url: str
71+
code: int
72+
durationMs: int
73+
74+
75+
class TaskMessageToolResultDict(TypedDict, total=False):
76+
task_id: str
77+
task_type: str
78+
message: str
79+
agentId: str
80+
81+
82+
class TaskRetrievalToolResultDict(TypedDict, total=False):
83+
retrieval_status: str
84+
task: dict[str, object]
85+
86+
87+
class TaskCompletedToolResultDict(TypedDict, total=False):
88+
agentId: str
89+
totalDurationMs: int
90+
status: str
91+
totalTokens: int
92+
totalToolUseCount: int
93+
94+
95+
class TaskAsyncToolResultDict(TypedDict, total=False):
96+
agentId: str
97+
isAsync: bool
98+
status: str
99+
description: str
100+
101+
102+
class TodoItemDict(TypedDict, total=False):
103+
id: str
104+
content: str
105+
106+
107+
class TodoWriteToolResultDict(TypedDict, total=False):
108+
newTodos: list[TodoItemDict]
109+
oldTodos: list[TodoItemDict]
110+
111+
112+
class UserInputToolResultDict(TypedDict, total=False):
113+
questions: list[dict[str, object]]
114+
answers: dict[str, object]
115+
116+
117+
class ToolResultContentBlockDict(TypedDict, total=False):
118+
type: str
119+
source: dict[str, object]
120+
121+
122+
class ToolResultWithContentDict(TypedDict, total=False):
123+
"""Read-on-image and similar payloads that embed content blocks."""
124+
125+
content: list[ToolResultContentBlockDict]
126+
127+
128+
# Dict passed into dispatch predicates (structural superset of all tool blobs).
129+
ToolResultDict = dict[str, object]
130+
131+
ToolResultUnion = (
132+
str
133+
| BashToolResultDict
134+
| FileEditToolResultDict
135+
| PlanToolResultDict
136+
| FileWriteToolResultDict
137+
| GlobToolResultDict
138+
| GrepToolResultDict
139+
| ReadToolResultDict
140+
| WebSearchToolResultDict
141+
| WebFetchToolResultDict
142+
| TaskMessageToolResultDict
143+
| TaskRetrievalToolResultDict
144+
| TaskCompletedToolResultDict
145+
| TaskAsyncToolResultDict
146+
| TodoWriteToolResultDict
147+
| UserInputToolResultDict
148+
| ToolResultWithContentDict
149+
| dict[str, object]
150+
)
151+
152+
153+
def is_tool_result_dict(tr: ToolResultUnion | None) -> TypeGuard[ToolResultDict]:
154+
return isinstance(tr, dict)
155+
156+
157+
def is_bash_tool_result(tr: ToolResultDict) -> TypeGuard[BashToolResultDict]:
158+
return "stdout" in tr or "stderr" in tr
159+
160+
161+
def is_file_edit_tool_result(tr: ToolResultDict) -> TypeGuard[FileEditToolResultDict]:
162+
return "structuredPatch" in tr or ("filePath" in tr and "newString" in tr)
163+
164+
165+
def is_plan_tool_result(tr: ToolResultDict) -> TypeGuard[PlanToolResultDict]:
166+
return "plan" in tr and "filePath" in tr
167+
168+
169+
def is_file_write_tool_result(tr: ToolResultDict) -> TypeGuard[FileWriteToolResultDict]:
170+
return "filePath" in tr and "content" in tr
171+
172+
173+
def is_glob_tool_result(tr: ToolResultDict) -> TypeGuard[GlobToolResultDict]:
174+
filenames = tr.get("filenames")
175+
return "filenames" in tr and isinstance(filenames, list)
176+
177+
178+
def is_grep_tool_result(tr: ToolResultDict) -> TypeGuard[GrepToolResultDict]:
179+
return "mode" in tr and "numFiles" in tr
180+
181+
182+
def is_read_tool_result(tr: ToolResultDict) -> TypeGuard[ReadToolResultDict]:
183+
file_obj = tr.get("file")
184+
return "file" in tr and isinstance(file_obj, dict)
185+
186+
187+
def is_web_search_tool_result(tr: ToolResultDict) -> TypeGuard[WebSearchToolResultDict]:
188+
return "query" in tr and "results" in tr
189+
190+
191+
def is_web_fetch_tool_result(tr: ToolResultDict) -> TypeGuard[WebFetchToolResultDict]:
192+
return "url" in tr and "code" in tr
193+
194+
195+
def is_task_message_tool_result(tr: ToolResultDict) -> TypeGuard[TaskMessageToolResultDict]:
196+
# Broad: matches ``task_id`` OR ``message``. Runs before retrieval/completed/async
197+
# arms in tool_dispatch — same short-circuit order as the historical if/elif chain.
198+
return "task_id" in tr or "message" in tr
199+
200+
201+
def is_task_retrieval_tool_result(tr: ToolResultDict) -> TypeGuard[TaskRetrievalToolResultDict]:
202+
return "retrieval_status" in tr and "task" in tr
203+
204+
205+
def is_task_completed_tool_result(tr: ToolResultDict) -> TypeGuard[TaskCompletedToolResultDict]:
206+
return "agentId" in tr and "totalDurationMs" in tr
207+
208+
209+
def is_task_async_tool_result(tr: ToolResultDict) -> TypeGuard[TaskAsyncToolResultDict]:
210+
return "agentId" in tr and "isAsync" in tr
211+
212+
213+
def is_todo_write_tool_result(tr: ToolResultDict) -> TypeGuard[TodoWriteToolResultDict]:
214+
return "newTodos" in tr or "oldTodos" in tr
215+
216+
217+
def is_user_input_tool_result(tr: ToolResultDict) -> TypeGuard[UserInputToolResultDict]:
218+
return "questions" in tr and "answers" in tr
219+
220+
221+
# Tool names on assistant tool_use blocks — pairs with slug on user tool_result rows.
222+
ToolNameLiteral = Literal[
223+
"Bash",
224+
"Read",
225+
"Write",
226+
"Edit",
227+
"Glob",
228+
"Grep",
229+
"Task",
230+
"TodoWrite",
231+
"WebFetch",
232+
"WebSearch",
233+
]

tests/test_real_session_fixtures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,7 @@ def test_task_retrieval_not_misclassified_as_task_message() -> None:
151151
def test_task_completed_with_message_key_matches_task_message_first() -> None:
152152
"""Legacy dispatch: broad task_message runs before task_completed when ``message`` present.
153153
154-
``_tool_result_pred_task_message`` matches any dict with a ``message`` or ``task_id``
154+
``is_task_message_tool_result`` matches any dict with a ``message`` or ``task_id``
155155
key. Future tool shapes that add ``message`` for status text (e.g. web-fetch) would
156156
be misclassified as task until dispatch order is refined — this test locks that
157157
known false-positive surface.

0 commit comments

Comments
 (0)