Skip to content

Commit cd7d4a0

Browse files
refactor: split MessageDict by role for mypy narrowing (#126)
* refactor: split MessageDict by role for mypy narrowing * test: require typeddict-item on both invalid MessageDict fixture lines * test: harden MessageDict mypy fixtures and restore search parity
1 parent cb505ea commit cd7d4a0

10 files changed

Lines changed: 326 additions & 99 deletions

models/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
from models.record_data import RecordDataUnion
77
from models.search import SearchHitDict
88
from models.session import (
9+
AssistantMessageDict,
910
MessageDict,
11+
ProgressMessageDict,
1012
QuickSessionInfoDict,
13+
ResultMessageDict,
1114
RoleLiteral,
1215
SessionDict,
1316
SessionMetadataDict,
17+
SystemMessageDict,
1418
ToolUseDict,
19+
UserMessageDict,
1520
)
1621
from models.stats import FilesTouchedDict, SessionStatsDict
1722
from models.tool_results import ToolResultUnion
@@ -20,17 +25,22 @@
2025
"ErrorResponse",
2126
"ExportStateDict",
2227
"FilesTouchedDict",
28+
"AssistantMessageDict",
2329
"MessageDict",
30+
"ProgressMessageDict",
2431
"ProjectDict",
2532
"ProjectSessionRowDict",
2633
"QuickSessionInfoDict",
34+
"ResultMessageDict",
2735
"RoleLiteral",
2836
"SearchHitDict",
2937
"SessionDict",
3038
"SessionListItemDict",
3139
"SessionMetadataDict",
3240
"SessionStatsDict",
41+
"SystemMessageDict",
3342
"RecordDataUnion",
3443
"ToolResultUnion",
3544
"ToolUseDict",
45+
"UserMessageDict",
3646
]

models/session.py

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

3-
from typing import Any, Literal, NotRequired, TypedDict
3+
from typing import Any, Literal, NotRequired, TypedDict, Union
44

55
from models.record_data import RecordDataUnion
66
from models.tool_results import ToolNameLiteral, ToolResultUnion
@@ -26,32 +26,66 @@ class MessageUsageDict(TypedDict, total=False):
2626
SystemSubtypeLiteral = Literal["compact_boundary", "init"]
2727

2828

29-
class MessageDict(TypedDict):
30-
role: RoleLiteral
31-
uuid: NotRequired[str | None]
32-
parent_uuid: NotRequired[str | None]
33-
timestamp: NotRequired[str | None]
29+
class BaseMessageDict(TypedDict, total=False):
30+
"""Fields shared across every parsed message role."""
31+
32+
uuid: str | None
33+
parent_uuid: str | None
34+
timestamp: str | None
35+
is_sidechain: bool
36+
37+
38+
class UserMessageDict(BaseMessageDict):
39+
role: Literal["user"]
3440
text: NotRequired[str]
35-
content: NotRequired[str]
3641
images: NotRequired[list[Any] | None]
37-
is_sidechain: NotRequired[bool]
3842
tool_result: NotRequired[ToolResultUnion | None]
3943
tool_result_parsed: NotRequired[dict[str, object] | None]
4044
slug: NotRequired[str | None]
45+
46+
47+
class AssistantMessageDict(BaseMessageDict):
48+
role: Literal["assistant"]
49+
text: NotRequired[str]
4150
model: NotRequired[str]
4251
stop_reason: NotRequired[str]
4352
thinking: NotRequired[str | None]
4453
tool_uses: NotRequired[list[ToolUseDict] | None]
4554
is_api_error: NotRequired[bool]
4655
usage: NotRequired[MessageUsageDict]
56+
57+
58+
class SystemMessageDict(BaseMessageDict):
59+
role: Literal["system"]
60+
text: NotRequired[str]
4761
subtype: NotRequired[str]
62+
content: NotRequired[str]
4863
level: NotRequired[str]
49-
data: NotRequired[RecordDataUnion]
64+
65+
66+
class ResultMessageDict(BaseMessageDict):
67+
role: Literal["result"]
68+
text: NotRequired[str]
69+
content: NotRequired[str]
70+
71+
72+
class ProgressMessageDict(BaseMessageDict):
73+
role: Literal["progress"]
5074
progress_type: NotRequired[str]
75+
data: NotRequired[RecordDataUnion]
5176
tool_use_id: NotRequired[str | None]
5277
parent_tool_use_id: NotRequired[str | None]
5378

5479

80+
MessageDict = Union[
81+
UserMessageDict,
82+
AssistantMessageDict,
83+
SystemMessageDict,
84+
ResultMessageDict,
85+
ProgressMessageDict,
86+
]
87+
88+
5589
class SessionMetadataDict(TypedDict):
5690
"""Metadata accumulated while parsing a Claude Code JSONL session.
5791
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"""Negative fixture: role-inappropriate MessageDict access must fail mypy strict."""
2+
3+
from models.session import MessageDict, UserMessageDict
4+
5+
user_msg: UserMessageDict = {"role": "user", "text": "hello"}
6+
_ = user_msg["thinking"] # expect: typeddict-item thinking
7+
8+
union_msg: MessageDict = {"role": "user", "text": "hello"}
9+
_ = union_msg["thinking"] # expect: typeddict-item thinking
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Positive fixture: discriminant narrowing allows role-specific fields."""
2+
3+
from models.session import AssistantMessageDict, MessageDict, UserMessageDict
4+
5+
messages: list[MessageDict] = [
6+
{"role": "user", "text": "hello"},
7+
{"role": "assistant", "text": "hi", "thinking": "hmm"},
8+
]
9+
for msg in messages:
10+
if msg["role"] == "user":
11+
narrowed: UserMessageDict = msg
12+
_ = narrowed["slug"]
13+
elif msg["role"] == "assistant":
14+
narrowed_asst: AssistantMessageDict = msg
15+
_ = narrowed_asst["thinking"]

tests/test_message_dict_mypy.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Type-level regression: MessageDict union rejects role-inappropriate access."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
10+
import pytest
11+
12+
REPO_ROOT = Path(__file__).resolve().parent.parent
13+
MYPY_TYPES_DIR = REPO_ROOT / "tests" / "mypy_types"
14+
15+
_INVALID_FIXTURE = "message_dict_invalid.py"
16+
_EXPECT_MARKER = "expect: typeddict-item"
17+
18+
19+
def _run_mypy_on(path: Path) -> subprocess.CompletedProcess[str]:
20+
return subprocess.run(
21+
[
22+
sys.executable,
23+
"-m",
24+
"mypy",
25+
"--strict",
26+
str(path),
27+
"--config-file",
28+
str(REPO_ROOT / "pyproject.toml"),
29+
],
30+
cwd=REPO_ROOT,
31+
capture_output=True,
32+
text=True,
33+
check=False,
34+
timeout=60.0,
35+
)
36+
37+
38+
def _typeddict_item_error_lines(output: str, fixture_name: str) -> set[int]:
39+
pattern = rf"{re.escape(fixture_name)}:(\d+): error:.*\[typeddict-item\]"
40+
return {int(match.group(1)) for match in re.finditer(pattern, output)}
41+
42+
43+
def _invalid_fixture_expectations(fixture_path: Path) -> tuple[set[int], list[str]]:
44+
expected_lines: set[int] = set()
45+
expected_tokens: list[str] = []
46+
for lineno, line in enumerate(fixture_path.read_text(encoding="utf-8").splitlines(), start=1):
47+
if _EXPECT_MARKER not in line:
48+
continue
49+
expected_lines.add(lineno)
50+
suffix = line.split(_EXPECT_MARKER, 1)[1].strip()
51+
if suffix:
52+
expected_tokens.append(suffix)
53+
return expected_lines, expected_tokens
54+
55+
56+
@pytest.mark.parametrize(
57+
("fixture_name", "should_pass"),
58+
[
59+
(_INVALID_FIXTURE, False),
60+
("message_dict_valid.py", True),
61+
],
62+
)
63+
def test_message_dict_mypy_fixtures(fixture_name: str, should_pass: bool) -> None:
64+
fixture_path = MYPY_TYPES_DIR / fixture_name
65+
result = _run_mypy_on(fixture_path)
66+
output = result.stdout + result.stderr
67+
if should_pass:
68+
assert result.returncode == 0, output
69+
else:
70+
assert result.returncode != 0
71+
error_lines = _typeddict_item_error_lines(output, fixture_name)
72+
expected_lines, expected_tokens = _invalid_fixture_expectations(fixture_path)
73+
assert error_lines >= expected_lines, (
74+
f"expected typeddict-item on lines {sorted(expected_lines)}, "
75+
f"got {sorted(error_lines)}: {output}"
76+
)
77+
for token in expected_tokens:
78+
assert token in output, f"expected mypy output to mention {token!r}: {output}"

utils/json_exporter.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from datetime import datetime, timezone
66
from typing import Any
77

8-
from models.session import SessionDict, SessionMetadataDict
8+
from models.session import MessageDict, SessionDict, SessionMetadataDict
99
from models.stats import SessionStatsDict
1010

1111

@@ -39,11 +39,11 @@ def _serialize_metadata(meta: SessionMetadataDict) -> dict[str, Any]:
3939
return result
4040

4141

42-
def _serialize_messages(messages: list[Any]) -> list[dict[str, Any]]:
42+
def _serialize_messages(messages: list[MessageDict]) -> list[dict[str, Any]]:
4343
"""Same set-to-list cleanup, but for each message dict."""
44-
out = []
44+
out: list[dict[str, Any]] = []
4545
for msg in messages:
46-
clean = {}
46+
clean: dict[str, Any] = {}
4747
for key, val in msg.items():
4848
if isinstance(val, set):
4949
clean[key] = sorted(val)

0 commit comments

Comments
 (0)