Skip to content

Commit 6391975

Browse files
Tighten metadata validation and typed builder for parser review feedback
- Require each models_used entry to be a string at validate_session_dict boundary - Add _entry_timestamp() so invalid top-level timestamps fall back to snapshot - Introduce SessionMetadataBuilderDict for parse-time accumulator typing - Return explicit SessionMetadataDict from finalize (no cast) - Align tool_dispatch file-activity handlers with builder type
1 parent b04ec7c commit 6391975

6 files changed

Lines changed: 143 additions & 35 deletions

File tree

models/session.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,40 @@ class SessionMetadataDict(TypedDict):
136136
)
137137

138138

139+
class SessionMetadataBuilderDict(TypedDict):
140+
"""Mutable metadata accumulator during JSONL parsing; sets are sorted at finalize."""
141+
142+
session_id: str
143+
models_used: set[str]
144+
first_timestamp: str | None
145+
last_timestamp: str | None
146+
total_input_tokens: int
147+
total_output_tokens: int
148+
total_cache_read_tokens: int
149+
total_cache_creation_tokens: int
150+
total_tool_calls: int
151+
tool_call_counts: dict[str, int]
152+
version: str | None
153+
cwd: str | None
154+
git_branch: str | None
155+
permission_mode: str | None
156+
compactions: int
157+
total_ephemeral_5m_tokens: int
158+
total_ephemeral_1h_tokens: int
159+
service_tiers: set[str]
160+
session_wall_time_seconds: float | None
161+
compact_boundaries: list[dict[str, Any]]
162+
api_errors: int
163+
files_read: set[str]
164+
files_written: set[str]
165+
files_created: set[str]
166+
bash_commands: list[Any]
167+
web_fetches: list[Any]
168+
sidechain_messages: int
169+
stop_reasons: dict[str, int]
170+
entry_counts: dict[str, int]
171+
172+
139173
class SessionDict(TypedDict):
140174
session_id: str
141175
title: str

tests/test_jsonl_parser.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -738,6 +738,23 @@ def test_file_history_snapshot_timestamp(self):
738738
finally:
739739
os.unlink(path)
740740

741+
def test_file_history_snapshot_timestamp_falls_back_when_top_level_invalid(self):
742+
path = _write_jsonl(
743+
[
744+
{
745+
"type": "file-history-snapshot",
746+
"timestamp": 1,
747+
"snapshot": {"timestamp": "2026-01-02T12:00:00Z"},
748+
},
749+
]
750+
)
751+
try:
752+
s = parse_session(path)
753+
assert s["metadata"]["first_timestamp"] == "2026-01-02T12:00:00Z"
754+
assert s["metadata"]["last_timestamp"] == "2026-01-02T12:00:00Z"
755+
finally:
756+
os.unlink(path)
757+
741758
def test_summary_entry_type_produces_no_message(self, caplog):
742759
path = _write_jsonl(
743760
[

tests/test_jsonl_validation.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,19 @@ def test_metadata_first_timestamp_null_allowed(self):
8989
)
9090
assert result["metadata"]["first_timestamp"] is None
9191

92+
def test_metadata_models_used_requires_string_elements(self):
93+
with pytest.raises(SessionValidationError) as exc_info:
94+
validate_session_dict(
95+
_valid_payload(
96+
metadata={
97+
"session_id": "abc123",
98+
"models_used": ["claude-sonnet", 42],
99+
"first_timestamp": None,
100+
}
101+
)
102+
)
103+
assert exc_info.value.path == "metadata.models_used[1]"
104+
92105
def test_message_not_dict(self):
93106
with pytest.raises(SessionValidationError) as exc_info:
94107
validate_session_dict(_valid_payload(messages=["not-a-dict"]))

utils/jsonl_parser.py

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010

1111
from models.record_data import RecordDataUnion
1212
from models.session import (
13-
SESSION_METADATA_FIELD_NAMES,
1413
MessageDict,
1514
RoleLiteral,
1615
SessionDict,
16+
SessionMetadataBuilderDict,
1717
SessionMetadataDict,
1818
ToolUseDict,
1919
)
@@ -77,13 +77,7 @@ def _safe_int(val: Any) -> int:
7777
return 0
7878

7979

80-
# Set-backed fields are sorted when finalized; all other builder keys pass through as-is.
81-
_METADATA_SET_FIELDS = frozenset(
82-
{"models_used", "service_tiers", "files_read", "files_written", "files_created"}
83-
)
84-
85-
86-
def _new_session_metadata_builder(session_id: str) -> dict[str, Any]:
80+
def _new_session_metadata_builder(session_id: str) -> SessionMetadataBuilderDict:
8781
"""Fresh mutable metadata accumulator for ``parse_session()``."""
8882
return {
8983
"session_id": session_id,
@@ -118,15 +112,53 @@ def _new_session_metadata_builder(session_id: str) -> dict[str, Any]:
118112
}
119113

120114

121-
def _finalize_session_metadata(raw: dict[str, Any]) -> SessionMetadataDict:
115+
def _finalize_session_metadata(raw: SessionMetadataBuilderDict) -> SessionMetadataDict:
122116
"""Convert the mutable parse-time metadata builder into a SessionMetadataDict."""
123-
finalized: dict[str, Any] = {}
124-
for key in SESSION_METADATA_FIELD_NAMES:
125-
val = raw[key]
126-
if key in _METADATA_SET_FIELDS:
127-
val = sorted(val)
128-
finalized[key] = val
129-
return cast(SessionMetadataDict, finalized)
117+
return {
118+
"session_id": raw["session_id"],
119+
"models_used": sorted(raw["models_used"]),
120+
"first_timestamp": raw["first_timestamp"],
121+
"last_timestamp": raw["last_timestamp"],
122+
"total_input_tokens": raw["total_input_tokens"],
123+
"total_output_tokens": raw["total_output_tokens"],
124+
"total_cache_read_tokens": raw["total_cache_read_tokens"],
125+
"total_cache_creation_tokens": raw["total_cache_creation_tokens"],
126+
"total_tool_calls": raw["total_tool_calls"],
127+
"tool_call_counts": raw["tool_call_counts"],
128+
"version": raw["version"],
129+
"cwd": raw["cwd"],
130+
"git_branch": raw["git_branch"],
131+
"permission_mode": raw["permission_mode"],
132+
"compactions": raw["compactions"],
133+
"total_ephemeral_5m_tokens": raw["total_ephemeral_5m_tokens"],
134+
"total_ephemeral_1h_tokens": raw["total_ephemeral_1h_tokens"],
135+
"service_tiers": sorted(raw["service_tiers"]),
136+
"session_wall_time_seconds": raw["session_wall_time_seconds"],
137+
"compact_boundaries": raw["compact_boundaries"],
138+
"api_errors": raw["api_errors"],
139+
"files_read": sorted(raw["files_read"]),
140+
"files_written": sorted(raw["files_written"]),
141+
"files_created": sorted(raw["files_created"]),
142+
"bash_commands": raw["bash_commands"],
143+
"web_fetches": raw["web_fetches"],
144+
"sidechain_messages": raw["sidechain_messages"],
145+
"stop_reasons": raw["stop_reasons"],
146+
"entry_counts": raw["entry_counts"],
147+
}
148+
149+
150+
def _entry_timestamp(entry: dict[str, Any]) -> str | None:
151+
"""Return a usable ISO timestamp string from an entry, or None."""
152+
ts = entry.get("timestamp")
153+
if isinstance(ts, str) and ts:
154+
return ts
155+
if entry.get("type") == "file-history-snapshot":
156+
snap = entry.get("snapshot")
157+
if isinstance(snap, dict):
158+
snap_ts = snap.get("timestamp")
159+
if isinstance(snap_ts, str) and snap_ts:
160+
return snap_ts
161+
return None
130162

131163

132164
def parse_session(filepath: str) -> SessionDict:
@@ -151,14 +183,9 @@ def parse_session(filepath: str) -> SessionDict:
151183
continue
152184

153185
entry_type = entry.get("type")
154-
ts = entry.get("timestamp")
155-
# file-history-snapshot stores timestamp inside snapshot
156-
if not ts and entry_type == "file-history-snapshot":
157-
snap = entry.get("snapshot")
158-
if isinstance(snap, dict):
159-
ts = snap.get("timestamp")
160-
161-
if isinstance(ts, str) and ts:
186+
ts = _entry_timestamp(entry)
187+
188+
if ts:
162189
if metadata["first_timestamp"] is None:
163190
metadata["first_timestamp"] = ts
164191
metadata["last_timestamp"] = ts
@@ -210,7 +237,7 @@ def parse_session(filepath: str) -> SessionDict:
210237

211238

212239
def _process_user(
213-
entry: dict[str, Any], messages: list[MessageDict], metadata: dict[str, Any]
240+
entry: dict[str, Any], messages: list[MessageDict], metadata: SessionMetadataBuilderDict
214241
) -> None:
215242
"""Pull out text, tool results, and session-level metadata (cwd, version, etc.)
216243
from a user entry."""
@@ -257,7 +284,7 @@ def _process_user(
257284

258285

259286
def _process_assistant(
260-
entry: dict[str, Any], messages: list[MessageDict], metadata: dict[str, Any]
287+
entry: dict[str, Any], messages: list[MessageDict], metadata: SessionMetadataBuilderDict
261288
) -> None:
262289
"""Handle assistant responses -- splits content into text, thinking blocks,
263290
and tool_use calls, and accumulates token/model/tool stats."""
@@ -353,7 +380,7 @@ def _process_assistant(
353380

354381

355382
def _process_system(
356-
entry: dict[str, Any], messages: list[MessageDict], metadata: dict[str, Any]
383+
entry: dict[str, Any], messages: list[MessageDict], metadata: SessionMetadataBuilderDict
357384
) -> None:
358385
"""Handle system entries (mostly compact_boundary markers from context
359386
compaction)."""

utils/tool_dispatch.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
from collections.abc import Callable
3636
from typing import Any, cast
3737

38+
from models.session import SessionMetadataBuilderDict
3839
from models.tool_results import (
3940
ToolResultDict,
4041
ToolResultUnion,
@@ -240,40 +241,42 @@ def _tool_result_build_user_input(tr: ToolResultDict, base: dict[str, object]) -
240241
# ``_FILE_ACTIVITY_HANDLERS`` is the single registry; ``KNOWN_TOOL_TYPES`` is derived.
241242

242243

243-
def _file_activity_read(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
244+
def _file_activity_read(tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict) -> None:
244245
raw_fp = tool_input.get("file_path", "")
245246
fp = raw_fp if isinstance(raw_fp, str) else ""
246247
if fp:
247248
metadata["files_read"].add(fp)
248249

249250

250-
def _file_activity_write(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
251+
def _file_activity_write(tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict) -> None:
251252
raw_fp = tool_input.get("file_path", "")
252253
fp = raw_fp if isinstance(raw_fp, str) else ""
253254
if fp:
254255
metadata["files_created"].add(fp)
255256

256257

257-
def _file_activity_edit(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
258+
def _file_activity_edit(tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict) -> None:
258259
raw_fp = tool_input.get("file_path", "")
259260
fp = raw_fp if isinstance(raw_fp, str) else ""
260261
if fp:
261262
metadata["files_written"].add(fp)
262263

263264

264-
def _file_activity_bash(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
265+
def _file_activity_bash(tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict) -> None:
265266
cmd = tool_input.get("command", "")
266267
if isinstance(cmd, str) and cmd:
267268
metadata["bash_commands"].append(cmd)
268269

269270

270-
def _file_activity_web(tool_input: dict[str, Any], metadata: dict[str, Any]) -> None:
271+
def _file_activity_web(tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict) -> None:
271272
url_or_query = tool_input.get("url") or tool_input.get("query", "")
272273
if isinstance(url_or_query, str) and url_or_query:
273274
metadata["web_fetches"].append(url_or_query)
274275

275276

276-
_FILE_ACTIVITY_HANDLERS: dict[str, Callable[[dict[str, Any], dict[str, Any]], None] | None] = {
277+
_FILE_ACTIVITY_HANDLERS: dict[
278+
str, Callable[[dict[str, Any], SessionMetadataBuilderDict], None] | None
279+
] = {
277280
"AskUserQuestion": None,
278281
"Bash": _file_activity_bash,
279282
"Edit": _file_activity_edit,
@@ -290,7 +293,7 @@ def _file_activity_web(tool_input: dict[str, Any], metadata: dict[str, Any]) ->
290293

291294

292295
def track_tool_file_activity(
293-
tool_name: str, tool_input: dict[str, Any], metadata: dict[str, Any]
296+
tool_name: str, tool_input: dict[str, Any], metadata: SessionMetadataBuilderDict
294297
) -> None:
295298
"""Record file/bash/web side effects for tools listed in ``KNOWN_TOOL_TYPES``."""
296299
if tool_name not in KNOWN_TOOL_TYPES:

utils/validation.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,24 @@ def _require_optional_str(path: str, val: Any) -> str | None:
4545
return val
4646

4747

48+
def _require_str_list(path: str, val: Any) -> list[str]:
49+
if not isinstance(val, list):
50+
raise SessionValidationError(path, f"expected list, got {type(val).__name__}")
51+
for index, item in enumerate(val):
52+
if not isinstance(item, str):
53+
raise SessionValidationError(
54+
f"{path}[{index}]",
55+
f"expected str, got {type(item).__name__}",
56+
)
57+
return val
58+
59+
4860
def _validate_session_metadata(metadata: dict[str, Any]) -> None:
4961
"""Enforce SessionMetadataDict required keys at the runtime boundary."""
5062
_require_field(metadata, "session_id", str, "str", path="metadata.session_id")
51-
_require_field(metadata, "models_used", list, "list", path="metadata.models_used")
63+
if "models_used" not in metadata:
64+
raise SessionValidationError("metadata.models_used", "missing required field")
65+
_require_str_list("metadata.models_used", metadata["models_used"])
5266
if "first_timestamp" not in metadata:
5367
raise SessionValidationError("metadata.first_timestamp", "missing required field")
5468
_require_optional_str("metadata.first_timestamp", metadata["first_timestamp"])

0 commit comments

Comments
 (0)