Skip to content

Commit c698c4a

Browse files
feat: narrow MessageDict.role to RoleLiteral
1 parent abf4cc0 commit c698c4a

6 files changed

Lines changed: 94 additions & 9 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: 51 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,16 +686,19 @@ def test_empty_file_returns_skeleton(self):
686686
finally:
687687
os.unlink(path)
688688

689-
def test_unknown_entry_type_silently_ignored(self):
689+
def test_unknown_entry_type_maps_to_system(self, caplog):
690690
path = _write_jsonl(
691691
[
692692
{"type": "custom", "timestamp": "2026-01-01T00:00:00Z"},
693693
]
694694
)
695695
try:
696-
s = parse_session(path)
697-
assert s["messages"] == []
696+
with caplog.at_level("WARNING"):
697+
s = parse_session(path)
698+
assert len(s["messages"]) == 1
699+
assert s["messages"][0]["role"] == "system"
698700
assert s["metadata"]["entry_counts"].get("custom") == 1
701+
assert "Unknown message role" in caplog.text
699702
finally:
700703
os.unlink(path)
701704

@@ -981,3 +984,48 @@ def test_tool_use_result_string_returns_none(self):
981984
]
982985
)
983986
assert s["messages"][0]["tool_result_parsed"] is None
987+
988+
989+
# ---------------------------------------------------------------------------
990+
# Unknown role coercion
991+
# ---------------------------------------------------------------------------
992+
993+
994+
class TestUnknownRoleCoercion:
995+
def test_unknown_entry_type_maps_to_system_with_warning(self, caplog):
996+
path = _write_jsonl(
997+
[
998+
{
999+
"type": "mystery_future_type",
1000+
"timestamp": "2026-01-01T00:00:00Z",
1001+
"content": "forward-compat payload",
1002+
}
1003+
]
1004+
)
1005+
try:
1006+
with caplog.at_level("WARNING"):
1007+
s = parse_session(path)
1008+
assert len(s["messages"]) == 1
1009+
assert s["messages"][0]["role"] == "system"
1010+
assert s["messages"][0]["content"] == "forward-compat payload"
1011+
assert "Unknown message role" in caplog.text
1012+
assert "mystery_future_type" in caplog.text
1013+
finally:
1014+
os.unlink(path)
1015+
1016+
def test_valid_unhandled_result_type_emits_result_role(self):
1017+
path = _write_jsonl(
1018+
[
1019+
{
1020+
"type": "result",
1021+
"timestamp": "2026-01-01T00:00:00Z",
1022+
"content": "task outcome",
1023+
}
1024+
]
1025+
)
1026+
try:
1027+
s = parse_session(path)
1028+
assert len(s["messages"]) == 1
1029+
assert s["messages"][0]["role"] == "result"
1030+
finally:
1031+
os.unlink(path)

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: 31 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,30 @@
2324

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

27+
_HANDLED_ENTRY_TYPES = frozenset({"user", "assistant", "system", "progress"})
28+
_VALID_ROLES = frozenset(get_args(RoleLiteral))
29+
30+
31+
def _coerce_role(raw: str) -> RoleLiteral:
32+
if raw in _VALID_ROLES:
33+
return cast(RoleLiteral, raw)
34+
logging.warning("Unknown message role %r; mapping to 'system'", raw)
35+
return "system"
36+
37+
38+
def _fallback_message(entry: dict[str, Any], role: RoleLiteral) -> MessageDict:
39+
"""Minimal message for JSONL entry types without a dedicated processor."""
40+
content = entry.get("content", "")
41+
text = content if isinstance(content, str) else (str(content) if content is not None else "")
42+
return {
43+
"role": role,
44+
"uuid": entry.get("uuid"),
45+
"parent_uuid": entry.get("parentUuid"),
46+
"timestamp": entry.get("timestamp"),
47+
"content": text,
48+
"is_sidechain": entry.get("isSidechain", False),
49+
}
50+
2651

2752
def _safe_int(val: Any) -> int:
2853
"""Coerce a value to a non-negative int for token accounting; non-numeric,
@@ -127,6 +152,10 @@ def parse_session(filepath: str) -> SessionDict:
127152
_process_system(entry, messages, metadata)
128153
elif entry_type == "progress":
129154
_process_progress(entry, messages)
155+
elif entry_type:
156+
type_str = entry_type if isinstance(entry_type, str) else str(entry_type)
157+
if type_str not in _HANDLED_ENTRY_TYPES:
158+
messages.append(_fallback_message(entry, _coerce_role(type_str)))
130159

131160
metadata["models_used"] = sorted(metadata["models_used"])
132161
metadata["service_tiers"] = sorted(metadata["service_tiers"])

0 commit comments

Comments
 (0)