Skip to content

Commit 7dcbc05

Browse files
feat: narrow MessageDict.role to RoleLiteral (#89)
* feat: narrow MessageDict.role to RoleLiteral * fix: skip metadata-only JSONL entry types in role fallback path * fix: address PR #89 review feedback on role coercion and fallback messages * fix: skip summary metadata entries in JSONL role fallback path
1 parent abf4cc0 commit 7dcbc05

8 files changed

Lines changed: 166 additions & 22 deletions

File tree

models/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from models.session import (
99
MessageDict,
1010
QuickSessionInfoDict,
11+
RoleLiteral,
1112
SessionDict,
1213
SessionMetadataDict,
1314
ToolUseDict,
@@ -23,6 +24,7 @@
2324
"ProjectDict",
2425
"ProjectSessionRowDict",
2526
"QuickSessionInfoDict",
27+
"RoleLiteral",
2628
"SearchHitDict",
2729
"SessionDict",
2830
"SessionListItemDict",

models/search.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,13 @@
22

33
from typing import TypedDict
44

5+
from models.session import RoleLiteral
6+
57

68
class SearchHitDict(TypedDict):
79
project: str
810
session_id: str
911
title: str
10-
role: str
12+
role: RoleLiteral
1113
timestamp: str | None
1214
snippet: str

models/session.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
from models.record_data import RecordDataUnion
66
from models.tool_results import ToolNameLiteral, ToolResultUnion
77

8+
RoleLiteral = Literal["user", "assistant", "system", "result", "progress"]
9+
810

911
class ToolUseDict(TypedDict, total=False):
1012
id: str
@@ -25,7 +27,7 @@ class MessageUsageDict(TypedDict, total=False):
2527

2628

2729
class MessageDict(TypedDict):
28-
role: str
30+
role: RoleLiteral
2931
uuid: NotRequired[str | None]
3032
parent_uuid: NotRequired[str | None]
3133
timestamp: NotRequired[str | None]

tests/test_jsonl_parser.py

Lines changed: 99 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -686,19 +686,6 @@ def test_empty_file_returns_skeleton(self):
686686
finally:
687687
os.unlink(path)
688688

689-
def test_unknown_entry_type_silently_ignored(self):
690-
path = _write_jsonl(
691-
[
692-
{"type": "custom", "timestamp": "2026-01-01T00:00:00Z"},
693-
]
694-
)
695-
try:
696-
s = parse_session(path)
697-
assert s["messages"] == []
698-
assert s["metadata"]["entry_counts"].get("custom") == 1
699-
finally:
700-
os.unlink(path)
701-
702689
def test_is_sidechain_increments_counter(self):
703690
path = _write_jsonl(
704691
[
@@ -729,6 +716,26 @@ def test_file_history_snapshot_timestamp(self):
729716
s = parse_session(path)
730717
assert s["metadata"]["first_timestamp"] == "2026-01-02T12:00:00Z"
731718
assert s["metadata"]["last_timestamp"] == "2026-01-02T12:00:00Z"
719+
assert len(s["messages"]) == 0
720+
finally:
721+
os.unlink(path)
722+
723+
def test_summary_entry_type_produces_no_message(self, caplog):
724+
path = _write_jsonl(
725+
[
726+
{
727+
"type": "summary",
728+
"timestamp": "2026-01-03T08:00:00Z",
729+
"summary": "Session recap metadata",
730+
},
731+
]
732+
)
733+
try:
734+
with caplog.at_level("WARNING", logger="utils.jsonl_parser"):
735+
s = parse_session(path)
736+
assert len(s["messages"]) == 0
737+
assert s["metadata"]["entry_counts"].get("summary") == 1
738+
assert "Unknown message role" not in caplog.text
732739
finally:
733740
os.unlink(path)
734741

@@ -981,3 +988,82 @@ def test_tool_use_result_string_returns_none(self):
981988
]
982989
)
983990
assert s["messages"][0]["tool_result_parsed"] is None
991+
992+
993+
# ---------------------------------------------------------------------------
994+
# Unknown role coercion
995+
# ---------------------------------------------------------------------------
996+
997+
998+
class TestUnknownRoleCoercion:
999+
def test_unknown_entry_type_maps_to_system_with_warning(self, caplog):
1000+
path = _write_jsonl(
1001+
[
1002+
{
1003+
"type": "mystery_future_type",
1004+
"timestamp": "2026-01-01T00:00:00Z",
1005+
"content": "forward-compat payload",
1006+
}
1007+
]
1008+
)
1009+
try:
1010+
with caplog.at_level("WARNING", logger="utils.jsonl_parser"):
1011+
s = parse_session(path)
1012+
assert len(s["messages"]) == 1
1013+
assert s["messages"][0]["role"] == "system"
1014+
assert s["messages"][0]["text"] == "forward-compat payload"
1015+
assert s["messages"][0]["content"] == "forward-compat payload"
1016+
assert "Unknown message role" in caplog.text
1017+
assert "mystery_future_type" in caplog.text
1018+
finally:
1019+
os.unlink(path)
1020+
1021+
def test_fallback_message_extracts_text_from_structured_content(self):
1022+
path = _write_jsonl(
1023+
[
1024+
{
1025+
"type": "result",
1026+
"timestamp": "2026-01-01T00:00:00Z",
1027+
"content": [{"type": "text", "text": "block body"}],
1028+
}
1029+
]
1030+
)
1031+
try:
1032+
s = parse_session(path)
1033+
assert s["messages"][0]["text"] == "block body"
1034+
assert s["messages"][0]["content"] == "block body"
1035+
finally:
1036+
os.unlink(path)
1037+
1038+
def test_valid_unhandled_result_type_emits_result_role(self):
1039+
path = _write_jsonl(
1040+
[
1041+
{
1042+
"type": "result",
1043+
"timestamp": "2026-01-01T00:00:00Z",
1044+
"content": "task outcome",
1045+
}
1046+
]
1047+
)
1048+
try:
1049+
s = parse_session(path)
1050+
assert len(s["messages"]) == 1
1051+
assert s["messages"][0]["role"] == "result"
1052+
finally:
1053+
os.unlink(path)
1054+
1055+
1056+
class TestCoerceRole:
1057+
def test_known_roles_returned_unchanged(self):
1058+
from utils.jsonl_parser import _coerce_role
1059+
1060+
for role in ("user", "assistant", "system", "result", "progress"):
1061+
assert _coerce_role(role) == role
1062+
1063+
def test_unknown_role_maps_to_system_with_warning(self, caplog):
1064+
from utils.jsonl_parser import _coerce_role
1065+
1066+
with caplog.at_level("WARNING", logger="utils.jsonl_parser"):
1067+
result = _coerce_role("totally_unknown")
1068+
assert result == "system"
1069+
assert "totally_unknown" in caplog.text

tests/test_jsonl_validation.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ def test_valid_payload_returns_session_dict(self):
7070
assert result["session_id"] == "abc123"
7171
assert result["messages"][0]["role"] == "user"
7272

73+
def test_invalid_role_in_message(self):
74+
with pytest.raises(SessionValidationError) as exc_info:
75+
validate_session_dict(_valid_payload(messages=[{"role": "custom", "text": "x"}]))
76+
assert exc_info.value.path == "messages[0].role"
77+
assert "custom" in exc_info.value.detail
78+
7379

7480
class TestParseSessionValidationRegression:
7581
def test_session_minimal_fixture_unchanged(self):

tests/test_parser_fuzz.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -240,8 +240,10 @@ def test_unknown_record_type_is_graceful(tmp_path: Path) -> None:
240240
path = _write_jsonl(tmp_path / "unknown.jsonl", lines)
241241
session = parse_session(path)
242242
assert session["metadata"]["entry_counts"].get("totally-new-claude-record") == 1
243-
# Unknown type produces no message; only the valid user line does.
244-
assert len(session["messages"]) == 1
243+
# Unknown type is coerced to a system message; the valid user line follows.
244+
assert len(session["messages"]) == 2
245+
assert session["messages"][0]["role"] == "system"
246+
assert session["messages"][1]["role"] == "user"
245247

246248

247249
def test_non_numeric_usage_tokens_do_not_crash(tmp_path: Path) -> None:

utils/jsonl_parser.py

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@
22
actually work with -- messages, tool calls, token counts, file activity, etc."""
33

44
import json
5+
import logging
56
import math
67
import os
78
from datetime import datetime
8-
from typing import Any
9+
from typing import Any, cast, get_args
910

1011
from models.record_data import RecordDataUnion
11-
from models.session import MessageDict, SessionDict, ToolUseDict
12+
from models.session import MessageDict, RoleLiteral, SessionDict, ToolUseDict
1213
from models.tool_results import ToolResultUnion, is_tool_result_dict
1314
from utils.jsonl_helpers import (
1415
entry_message as _entry_message,
@@ -23,6 +24,38 @@
2324

2425
__all__ = ["parse_session", "quick_session_info"]
2526

27+
# Metadata-only JSONL entry types: contribute timestamps/counts but not messages.
28+
_SKIP_ENTRY_TYPES = frozenset({"file-history-snapshot", "summary"})
29+
_VALID_ROLES = frozenset(get_args(RoleLiteral))
30+
_log = logging.getLogger(__name__)
31+
32+
33+
def _coerce_role(raw: str) -> RoleLiteral:
34+
if raw in _VALID_ROLES:
35+
return cast(RoleLiteral, raw)
36+
_log.warning("Unknown message role %r; mapping to 'system'", raw)
37+
return "system"
38+
39+
40+
def _fallback_message(entry: dict[str, Any], role: RoleLiteral) -> MessageDict:
41+
"""Minimal message for JSONL entry types without a dedicated processor."""
42+
raw_content = entry.get("content", "")
43+
if isinstance(raw_content, str):
44+
text = raw_content
45+
elif raw_content is not None:
46+
text = _extract_text(_normalize_content(raw_content))
47+
else:
48+
text = ""
49+
return {
50+
"role": role,
51+
"uuid": entry.get("uuid"),
52+
"parent_uuid": entry.get("parentUuid"),
53+
"timestamp": entry.get("timestamp"),
54+
"text": text,
55+
"content": text,
56+
"is_sidechain": entry.get("isSidechain", False),
57+
}
58+
2659

2760
def _safe_int(val: Any) -> int:
2861
"""Coerce a value to a non-negative int for token accounting; non-numeric,
@@ -127,6 +160,10 @@ def parse_session(filepath: str) -> SessionDict:
127160
_process_system(entry, messages, metadata)
128161
elif entry_type == "progress":
129162
_process_progress(entry, messages)
163+
elif entry_type:
164+
type_str = entry_type if isinstance(entry_type, str) else str(entry_type)
165+
if type_str not in _SKIP_ENTRY_TYPES:
166+
messages.append(_fallback_message(entry, _coerce_role(type_str)))
130167

131168
metadata["models_used"] = sorted(metadata["models_used"])
132169
metadata["service_tiers"] = sorted(metadata["service_tiers"])

utils/validation.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"""Runtime validation for TypedDict shapes at untrusted-data boundaries."""
22

3-
from typing import Any, cast
3+
from typing import Any, cast, get_args
44

55
from models.errors import SessionValidationError
6-
from models.session import SessionDict
6+
from models.session import RoleLiteral, SessionDict
7+
8+
_VALID_ROLES = frozenset(get_args(RoleLiteral))
79

810
_ROOT_PATH = "(root)"
911

@@ -49,6 +51,11 @@ def validate_session_dict(data: dict[str, Any]) -> SessionDict:
4951
for index, message in enumerate(messages):
5052
path = f"messages[{index}]"
5153
msg_dict = _require_value(path, message, dict, "dict")
52-
_require_field(msg_dict, "role", str, "str", path=f"{path}.role")
54+
role = _require_field(msg_dict, "role", str, "str", path=f"{path}.role")
55+
if role not in _VALID_ROLES:
56+
raise SessionValidationError(
57+
f"{path}.role",
58+
f"expected one of {sorted(_VALID_ROLES)!r}, got {role!r}",
59+
)
5360

5461
return cast(SessionDict, data)

0 commit comments

Comments
 (0)