Skip to content

Commit 0269b8c

Browse files
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.
1 parent 32e60f4 commit 0269b8c

7 files changed

Lines changed: 379 additions & 71 deletions

File tree

models/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,15 @@
44
from models.export import ExportStateDict
55
from models.project import ProjectDict, ProjectSessionRowDict, SessionListItemDict
66
from models.search import SearchHitDict
7+
from models.record_data import RecordDataUnion
78
from models.session import (
89
MessageDict,
910
QuickSessionInfoDict,
1011
SessionDict,
1112
SessionMetadataDict,
13+
ToolUseDict,
1214
)
15+
from models.tool_results import ToolResultUnion
1316
from models.stats import FilesTouchedDict, SessionStatsDict
1417

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

models/record_data.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
"""TypedDict shapes for record-level ``data`` payloads on progress messages."""
2+
3+
from typing import Literal, TypeGuard, TypedDict
4+
5+
6+
class BashProgressDataDict(TypedDict):
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+
)
44+
45+
46+
def is_bash_progress_data(data: RecordDataUnion) -> TypeGuard[BashProgressDataDict]:
47+
return isinstance(data, dict) and data.get("type") == "bash_progress"

models/session.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
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+
name: ToolNameLiteral | str
12+
input: dict[str, object]
13+
14+
15+
class MessageUsageDict(TypedDict, total=False):
16+
input_tokens: int
17+
output_tokens: int
18+
cache_read: int
19+
cache_creation: int
20+
service_tier: str | None
21+
22+
23+
SystemSubtypeLiteral = Literal["compact_boundary", "init"]
424

525

626
class MessageDict(TypedDict):
@@ -12,18 +32,18 @@ class MessageDict(TypedDict):
1232
content: NotRequired[str]
1333
images: NotRequired[list[Any] | None]
1434
is_sidechain: NotRequired[bool]
15-
tool_result: NotRequired[Any]
16-
tool_result_parsed: NotRequired[dict[str, Any] | None]
35+
tool_result: NotRequired[ToolResultUnion | None]
36+
tool_result_parsed: NotRequired[dict[str, object] | None]
1737
slug: NotRequired[str | None]
1838
model: NotRequired[str]
1939
stop_reason: NotRequired[str]
2040
thinking: NotRequired[str | None]
21-
tool_uses: NotRequired[list[dict[str, Any]] | None]
41+
tool_uses: NotRequired[list[ToolUseDict] | None]
2242
is_api_error: NotRequired[bool]
23-
usage: NotRequired[dict[str, Any]]
43+
usage: NotRequired[MessageUsageDict]
2444
subtype: NotRequired[str]
2545
level: NotRequired[str]
26-
data: NotRequired[Any]
46+
data: NotRequired[RecordDataUnion]
2747
progress_type: NotRequired[str]
2848
tool_use_id: NotRequired[str | None]
2949
parent_tool_use_id: NotRequired[str | None]

models/tool_results.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
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, TypeGuard, TypedDict
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+
# Tool names on assistant tool_use blocks — pairs with slug on user tool_result rows.
196+
ToolNameLiteral = Literal[
197+
"Bash",
198+
"Read",
199+
"Write",
200+
"Edit",
201+
"Glob",
202+
"Grep",
203+
"Task",
204+
"TodoWrite",
205+
"WebFetch",
206+
"WebSearch",
207+
]

0 commit comments

Comments
 (0)