From c969f5c304ee5f46ae504aa4266d9cd2ff12cc18 Mon Sep 17 00:00:00 2001 From: Davis Brief Date: Tue, 16 Jun 2026 16:53:38 -0400 Subject: [PATCH 1/2] Fix uncaught TypeError on string or non-dict message content parse_message handled string content for user messages but not assistant messages, so assistant string content was iterated character by character and raised TypeError instead of the documented MessageParseError. A non-dict content block did the same in both branches. Mirror the user branch for assistant and raise MessageParseError on non-dict blocks. Adds a regression test that fails on main and passes with this change. Co-Authored-By: Claude --- .../_internal/message_parser.py | 34 ++++++- tests/test_message_parser_content_shapes.py | 93 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 tests/test_message_parser_content_shapes.py diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index b8aecfdb9..8806d841b 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -86,6 +86,12 @@ def parse_message(data: dict[str, Any]) -> Message | None: if isinstance(data["message"]["content"], list): user_content_blocks: list[ContentBlock] = [] for block in data["message"]["content"]: + if not isinstance(block, dict): + raise MessageParseError( + f"Invalid content block (expected dict, got " + f"{type(block).__name__})", + data, + ) match block["type"]: case "text": user_content_blocks.append( @@ -126,8 +132,34 @@ def parse_message(data: dict[str, Any]) -> Message | None: case "assistant": try: + # ``content`` may be a plain string rather than a list of + # blocks (the Anthropic message format permits this, and the + # ``user`` branch above already handles it). Treat it as a + # single text block so parsing stays symmetric and never + # iterates a string character-by-character. + raw_content = data["message"]["content"] + if not isinstance(raw_content, list): + return AssistantMessage( + content=[TextBlock(text=raw_content)] + if isinstance(raw_content, str) + else raw_content, + model=data["message"]["model"], + parent_tool_use_id=data.get("parent_tool_use_id"), + error=data.get("error"), + usage=data["message"].get("usage"), + message_id=data["message"].get("id"), + stop_reason=data["message"].get("stop_reason"), + session_id=data.get("session_id"), + uuid=data.get("uuid"), + ) content_blocks: list[ContentBlock] = [] - for block in data["message"]["content"]: + for block in raw_content: + if not isinstance(block, dict): + raise MessageParseError( + f"Invalid content block (expected dict, got " + f"{type(block).__name__})", + data, + ) match block["type"]: case "text": content_blocks.append(TextBlock(text=block["text"])) diff --git a/tests/test_message_parser_content_shapes.py b/tests/test_message_parser_content_shapes.py new file mode 100644 index 000000000..ea2adf78c --- /dev/null +++ b/tests/test_message_parser_content_shapes.py @@ -0,0 +1,93 @@ +"""Regression tests for content-shape handling in parse_message. + +These cover two shapes that previously raised an undocumented ``TypeError`` +out of the parser instead of the documented ``MessageParseError`` (or parsing +cleanly): + +1. an ``assistant`` message whose ``content`` is a plain string, and +2. a ``content`` list that contains a non-dict element. + +Place under ``tests/`` (e.g. ``tests/test_message_parser_content_shapes.py``). +""" + +import pytest + +from claude_agent_sdk._errors import MessageParseError +from claude_agent_sdk._internal.message_parser import parse_message +from claude_agent_sdk.types import AssistantMessage, TextBlock, UserMessage + + +def test_assistant_string_content_parses_like_user() -> None: + """A string ``content`` should parse, matching the existing user branch.""" + msg = parse_message( + {"type": "assistant", "message": {"model": "m", "content": "hello world"}} + ) + assert isinstance(msg, AssistantMessage) + assert len(msg.content) == 1 + assert isinstance(msg.content[0], TextBlock) + assert msg.content[0].text == "hello world" + + +def test_user_string_content_still_parses() -> None: + msg = parse_message({"type": "user", "message": {"content": "hi"}}) + assert isinstance(msg, UserMessage) + + +def test_assistant_string_content_preserves_top_level_fields() -> None: + msg = parse_message( + { + "type": "assistant", + "message": { + "model": "claude", + "content": "plain", + "id": "m1", + "stop_reason": "end_turn", + }, + "session_id": "s1", + } + ) + assert isinstance(msg, AssistantMessage) + assert msg.model == "claude" + assert msg.message_id == "m1" + assert msg.stop_reason == "end_turn" + assert msg.session_id == "s1" + + +@pytest.mark.parametrize("role", ["assistant", "user"]) +def test_non_dict_content_block_raises_documented_error(role: str) -> None: + """A non-dict block raises MessageParseError, never a raw TypeError.""" + message: dict[str, object] = {"content": ["oops"]} + if role == "assistant": + message["model"] = "m" + with pytest.raises(MessageParseError): + parse_message({"type": role, "message": message}) + + +def test_normal_block_lists_unaffected() -> None: + """Sanity: the common structured-content path is unchanged.""" + assistant = parse_message( + { + "type": "assistant", + "message": { + "model": "m", + "content": [ + {"type": "text", "text": "hi"}, + {"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}, + ], + }, + } + ) + assert isinstance(assistant, AssistantMessage) + assert len(assistant.content) == 2 + + user = parse_message( + { + "type": "user", + "message": { + "content": [ + {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} + ] + }, + } + ) + assert isinstance(user, UserMessage) From ed7aa2f0b36136c70310841ba717bcc37d0aa641 Mon Sep 17 00:00:00 2001 From: Davis Brief Date: Thu, 25 Jun 2026 21:02:12 -0400 Subject: [PATCH 2/2] Address review: raise on non-list assistant content; fold tests --- .../_internal/message_parser.py | 21 +---- tests/test_message_parser.py | 16 ++++ tests/test_message_parser_content_shapes.py | 93 ------------------- 3 files changed, 20 insertions(+), 110 deletions(-) delete mode 100644 tests/test_message_parser_content_shapes.py diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 8806d841b..571910004 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -132,25 +132,12 @@ def parse_message(data: dict[str, Any]) -> Message | None: case "assistant": try: - # ``content`` may be a plain string rather than a list of - # blocks (the Anthropic message format permits this, and the - # ``user`` branch above already handles it). Treat it as a - # single text block so parsing stays symmetric and never - # iterates a string character-by-character. raw_content = data["message"]["content"] if not isinstance(raw_content, list): - return AssistantMessage( - content=[TextBlock(text=raw_content)] - if isinstance(raw_content, str) - else raw_content, - model=data["message"]["model"], - parent_tool_use_id=data.get("parent_tool_use_id"), - error=data.get("error"), - usage=data["message"].get("usage"), - message_id=data["message"].get("id"), - stop_reason=data["message"].get("stop_reason"), - session_id=data.get("session_id"), - uuid=data.get("uuid"), + raise MessageParseError( + f"Invalid assistant content (expected list, got " + f"{type(raw_content).__name__})", + data, ) content_blocks: list[ContentBlock] = [] for block in raw_content: diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index 846a8cbdb..e13976314 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -886,6 +886,22 @@ def test_parse_assistant_message_missing_fields(self): parse_message({"type": "assistant"}) assert "Missing required field in assistant message" in str(exc_info.value) + def test_parse_assistant_string_content_raises(self): + """Assistant content as a bare string raises MessageParseError, not a raw TypeError.""" + with pytest.raises(MessageParseError): + parse_message( + {"type": "assistant", "message": {"model": "m", "content": "hi"}} + ) + + @pytest.mark.parametrize("role", ["assistant", "user"]) + def test_non_dict_content_block_raises_documented_error(self, role: str) -> None: + """A non-dict block raises MessageParseError, never a raw TypeError.""" + message: dict[str, object] = {"content": ["oops"]} + if role == "assistant": + message["model"] = "m" + with pytest.raises(MessageParseError): + parse_message({"type": role, "message": message}) + def test_parse_system_message_missing_fields(self): """Test that system message with missing fields raises MessageParseError.""" with pytest.raises(MessageParseError) as exc_info: diff --git a/tests/test_message_parser_content_shapes.py b/tests/test_message_parser_content_shapes.py deleted file mode 100644 index ea2adf78c..000000000 --- a/tests/test_message_parser_content_shapes.py +++ /dev/null @@ -1,93 +0,0 @@ -"""Regression tests for content-shape handling in parse_message. - -These cover two shapes that previously raised an undocumented ``TypeError`` -out of the parser instead of the documented ``MessageParseError`` (or parsing -cleanly): - -1. an ``assistant`` message whose ``content`` is a plain string, and -2. a ``content`` list that contains a non-dict element. - -Place under ``tests/`` (e.g. ``tests/test_message_parser_content_shapes.py``). -""" - -import pytest - -from claude_agent_sdk._errors import MessageParseError -from claude_agent_sdk._internal.message_parser import parse_message -from claude_agent_sdk.types import AssistantMessage, TextBlock, UserMessage - - -def test_assistant_string_content_parses_like_user() -> None: - """A string ``content`` should parse, matching the existing user branch.""" - msg = parse_message( - {"type": "assistant", "message": {"model": "m", "content": "hello world"}} - ) - assert isinstance(msg, AssistantMessage) - assert len(msg.content) == 1 - assert isinstance(msg.content[0], TextBlock) - assert msg.content[0].text == "hello world" - - -def test_user_string_content_still_parses() -> None: - msg = parse_message({"type": "user", "message": {"content": "hi"}}) - assert isinstance(msg, UserMessage) - - -def test_assistant_string_content_preserves_top_level_fields() -> None: - msg = parse_message( - { - "type": "assistant", - "message": { - "model": "claude", - "content": "plain", - "id": "m1", - "stop_reason": "end_turn", - }, - "session_id": "s1", - } - ) - assert isinstance(msg, AssistantMessage) - assert msg.model == "claude" - assert msg.message_id == "m1" - assert msg.stop_reason == "end_turn" - assert msg.session_id == "s1" - - -@pytest.mark.parametrize("role", ["assistant", "user"]) -def test_non_dict_content_block_raises_documented_error(role: str) -> None: - """A non-dict block raises MessageParseError, never a raw TypeError.""" - message: dict[str, object] = {"content": ["oops"]} - if role == "assistant": - message["model"] = "m" - with pytest.raises(MessageParseError): - parse_message({"type": role, "message": message}) - - -def test_normal_block_lists_unaffected() -> None: - """Sanity: the common structured-content path is unchanged.""" - assistant = parse_message( - { - "type": "assistant", - "message": { - "model": "m", - "content": [ - {"type": "text", "text": "hi"}, - {"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}, - ], - }, - } - ) - assert isinstance(assistant, AssistantMessage) - assert len(assistant.content) == 2 - - user = parse_message( - { - "type": "user", - "message": { - "content": [ - {"type": "tool_result", "tool_use_id": "t1", "content": "ok"} - ] - }, - } - ) - assert isinstance(user, UserMessage)