Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/claude_agent_sdk/_internal/message_parser.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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"]:
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand All @@ -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(
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/claude_agent_sdk/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -450,6 +451,7 @@ class UserMessage:

content: str | list[ContentBlock]
parent_tool_use_id: str | None = None
timestamp: datetime | None = None


@dataclass
Expand All @@ -459,6 +461,7 @@ class AssistantMessage:
content: list[ContentBlock]
model: str
parent_tool_use_id: str | None = None
timestamp: datetime | None = None


@dataclass
Expand All @@ -467,6 +470,7 @@ class SystemMessage:

subtype: str
data: dict[str, Any]
timestamp: datetime | None = None


@dataclass
Expand All @@ -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
Expand Down
66 changes: 66 additions & 0 deletions tests/test_message_parser.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
"""Tests for message parser error handling."""

from datetime import datetime, timezone

import pytest

from claude_agent_sdk._errors import MessageParseError
Expand Down Expand Up @@ -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
)