diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 57191000..f46df879 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -199,6 +199,8 @@ def parse_message(data: dict[str, Any]) -> Message | None: stop_reason=data["message"].get("stop_reason"), session_id=data.get("session_id"), uuid=data.get("uuid"), + request_id=data.get("request_id"), + data=data, ) except KeyError as e: raise MessageParseError( @@ -314,6 +316,10 @@ def parse_message(data: dict[str, Any]) -> Message | None: errors=data.get("errors"), api_error_status=data.get("api_error_status"), uuid=data.get("uuid"), + ttft_ms=data.get("ttft_ms"), + terminal_reason=data.get("terminal_reason"), + fast_mode_state=data.get("fast_mode_state"), + data=data, ) except KeyError as e: raise MessageParseError( diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index 70564803..243246f6 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -1034,6 +1034,14 @@ class AssistantMessage: stop_reason: str | None = None session_id: str | None = None uuid: str | None = None + # Top-level frame key (not nested under "message"): the request id of the + # underlying API call, e.g. "req_...". + request_id: str | None = None + # Raw CLI stream-json frame, retained so wire fields not modeled as typed + # attributes (e.g. message.stop_details, message.diagnostics, + # content[].caller) stay reachable without re-parsing. Mirrors + # SystemMessage.data. + data: dict[str, Any] | None = None @dataclass @@ -1220,6 +1228,13 @@ class ResultMessage: # Emitted by the CLI since v2.1.110. Safe to log (no message content). api_error_status: int | None = None uuid: str | None = None + # Time-to-first-token for the turn, in milliseconds. + ttft_ms: int | None = None + terminal_reason: str | None = None + fast_mode_state: str | None = None + # Raw CLI stream-json frame, retained so wire fields not modeled as typed + # attributes stay reachable without re-parsing. Mirrors SystemMessage.data. + data: dict[str, Any] | None = None @dataclass diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index e1397631..3d6dec18 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -834,6 +834,55 @@ def test_parse_result_message_with_null_stop_reason(self): assert isinstance(message, ResultMessage) assert message.stop_reason is None + def test_parse_result_message_models_metrics_and_retains_frame(self): + """ResultMessage models the stable scalar metrics as typed attributes + and retains the raw frame so unmodeled/future fields stay reachable + (issue #1026).""" + data = { + "type": "result", + "subtype": "success", + "duration_ms": 8127, + "duration_api_ms": 7903, + "is_error": False, + "num_turns": 4, + "session_id": "s", + "total_cost_usd": 0.02, + "ttft_ms": 2806, + "terminal_reason": "completed", + "fast_mode_state": "off", + "some_future_field": 42, + } + message = parse_message(data) + assert isinstance(message, ResultMessage) + # Typed metrics. + assert message.ttft_ms == 2806 + assert message.terminal_reason == "completed" + assert message.fast_mode_state == "off" + # Escape hatch: a field the SDK does not model stays reachable. + assert message.data is not None + assert message.data["some_future_field"] == 42 + + def test_parse_assistant_message_models_request_id_and_retains_frame(self): + """AssistantMessage models top-level request_id and retains the raw + frame so unmodeled fields (e.g. message.stop_details) stay reachable + (issue #1026).""" + data = { + "type": "assistant", + "request_id": "req_123", + "message": { + "content": [{"type": "text", "text": "Hi"}], + "model": "claude-opus-4-1-20250805", + "stop_details": {"reason": "end_turn"}, + }, + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + # Typed field. + assert message.request_id == "req_123" + # Escape hatch: an unmodeled nested field stays reachable. + assert message.data is not None + assert message.data["message"]["stop_details"] == {"reason": "end_turn"} + def test_parse_rate_limit_event(self): """Test parsing a rate_limit_event into a typed RateLimitEvent.""" data = {