diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 6532a204..ef821326 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -1,6 +1,7 @@ """Message parser for Claude Code SDK responses.""" import logging +from datetime import datetime from typing import Any from .._errors import MessageParseError @@ -21,6 +22,20 @@ logger = logging.getLogger(__name__) +def _parse_timestamp(timestamp_str: str | None) -> datetime | None: + """Parse ISO 8601 timestamp string to datetime object. + + Args: + timestamp_str: ISO 8601 timestamp string (e.g., "2025-10-09T19:00:40.452Z") + + Returns: + datetime object or None if timestamp_str is None + """ + if timestamp_str is None: + return None + return datetime.fromisoformat(timestamp_str.replace("Z", "+00:00")) + + def parse_message(data: dict[str, Any]) -> Message: """ Parse message from CLI output into typed Message objects. @@ -48,6 +63,7 @@ def parse_message(data: dict[str, Any]) -> Message: case "user": try: parent_tool_use_id = data.get("parent_tool_use_id") + timestamp = _parse_timestamp(data.get("timestamp")) if isinstance(data["message"]["content"], list): user_content_blocks: list[ContentBlock] = [] for block in data["message"]["content"]: @@ -75,10 +91,12 @@ def parse_message(data: dict[str, Any]) -> Message: return UserMessage( content=user_content_blocks, parent_tool_use_id=parent_tool_use_id, + timestamp=timestamp, ) return UserMessage( content=data["message"]["content"], parent_tool_use_id=parent_tool_use_id, + timestamp=timestamp, ) except KeyError as e: raise MessageParseError( @@ -120,6 +138,7 @@ def parse_message(data: dict[str, Any]) -> Message: content=content_blocks, model=data["message"]["model"], parent_tool_use_id=data.get("parent_tool_use_id"), + timestamp=_parse_timestamp(data.get("timestamp")), ) except KeyError as e: raise MessageParseError( @@ -131,6 +150,7 @@ def parse_message(data: dict[str, Any]) -> Message: return SystemMessage( subtype=data["subtype"], data=data, + timestamp=_parse_timestamp(data.get("timestamp")), ) except KeyError as e: raise MessageParseError( @@ -149,6 +169,7 @@ def parse_message(data: dict[str, Any]) -> Message: total_cost_usd=data.get("total_cost_usd"), usage=data.get("usage"), result=data.get("result"), + timestamp=_parse_timestamp(data.get("timestamp")), ) except KeyError as e: raise MessageParseError( diff --git a/src/claude_agent_sdk/types.py b/src/claude_agent_sdk/types.py index be1cb996..a44b5319 100644 --- a/src/claude_agent_sdk/types.py +++ b/src/claude_agent_sdk/types.py @@ -3,6 +3,7 @@ import sys from collections.abc import Awaitable, Callable from dataclasses import dataclass, field +from datetime import datetime from pathlib import Path from typing import TYPE_CHECKING, Any, Literal, TypedDict @@ -450,6 +451,7 @@ class UserMessage: content: str | list[ContentBlock] parent_tool_use_id: str | None = None + timestamp: datetime | None = None @dataclass @@ -459,6 +461,7 @@ class AssistantMessage: content: list[ContentBlock] model: str parent_tool_use_id: str | None = None + timestamp: datetime | None = None @dataclass @@ -467,6 +470,7 @@ class SystemMessage: subtype: str data: dict[str, Any] + timestamp: datetime | None = None @dataclass @@ -482,6 +486,7 @@ class ResultMessage: total_cost_usd: float | None = None usage: dict[str, Any] | None = None result: str | None = None + timestamp: datetime | None = None @dataclass diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 60bcc53a..1255687d 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -1,5 +1,7 @@ """Tests for message parser error handling.""" +from datetime import datetime, timezone + import pytest from claude_agent_sdk._errors import MessageParseError @@ -282,3 +284,67 @@ def test_message_parse_error_contains_data(self): with pytest.raises(MessageParseError) as exc_info: parse_message(data) assert exc_info.value.data == data + + def test_parse_user_message_with_timestamp(self): + """Test parsing a user message with timestamp.""" + data = { + "type": "user", + "message": {"content": [{"type": "text", "text": "Hello"}]}, + "timestamp": "2025-10-09T19:00:40.452Z", + } + message = parse_message(data) + assert isinstance(message, UserMessage) + assert message.timestamp is not None + assert message.timestamp == datetime( + 2025, 10, 9, 19, 0, 40, 452000, tzinfo=timezone.utc + ) + + def test_parse_assistant_message_with_timestamp(self): + """Test parsing an assistant message with timestamp.""" + data = { + "type": "assistant", + "message": { + "content": [{"type": "text", "text": "Hello"}], + "model": "claude-opus-4-1-20250805", + }, + "timestamp": "2025-10-09T19:00:40.452Z", + } + message = parse_message(data) + assert isinstance(message, AssistantMessage) + assert message.timestamp is not None + assert message.timestamp == datetime( + 2025, 10, 9, 19, 0, 40, 452000, tzinfo=timezone.utc + ) + + def test_parse_system_message_with_timestamp(self): + """Test parsing a system message with timestamp.""" + data = { + "type": "system", + "subtype": "start", + "timestamp": "2025-10-09T19:00:40.452Z", + } + message = parse_message(data) + assert isinstance(message, SystemMessage) + assert message.timestamp is not None + assert message.timestamp == datetime( + 2025, 10, 9, 19, 0, 40, 452000, tzinfo=timezone.utc + ) + + def test_parse_result_message_with_timestamp(self): + """Test parsing a result message with timestamp.""" + data = { + "type": "result", + "subtype": "success", + "duration_ms": 1000, + "duration_api_ms": 500, + "is_error": False, + "num_turns": 2, + "session_id": "session_123", + "timestamp": "2025-10-09T19:00:40.452Z", + } + message = parse_message(data) + assert isinstance(message, ResultMessage) + assert message.timestamp is not None + assert message.timestamp == datetime( + 2025, 10, 9, 19, 0, 40, 452000, tzinfo=timezone.utc + )