diff --git a/src/claude_agent_sdk/_internal/message_parser.py b/src/claude_agent_sdk/_internal/message_parser.py index 931cc2a63..160bd745e 100644 --- a/src/claude_agent_sdk/_internal/message_parser.py +++ b/src/claude_agent_sdk/_internal/message_parser.py @@ -147,6 +147,11 @@ def parse_message(data: dict[str, Any]) -> Message | None: raise MessageParseError( f"Missing required field in user message: {e}", data ) from e + except (TypeError, AttributeError) as e: + # e.g. data["message"] is not a dict, so indexing into it fails + raise MessageParseError( + f"Malformed user message: {e}", data + ) from e case "assistant": try: @@ -222,6 +227,11 @@ def parse_message(data: dict[str, Any]) -> Message | None: raise MessageParseError( f"Missing required field in assistant message: {e}", data ) from e + except (TypeError, AttributeError) as e: + # e.g. data["message"] is not a dict, so indexing into it fails + raise MessageParseError( + f"Malformed assistant message: {e}", data + ) from e case "system": try: diff --git a/src/claude_agent_sdk/_internal/session_store_validation.py b/src/claude_agent_sdk/_internal/session_store_validation.py index 16addd216..a8ffb5e56 100644 --- a/src/claude_agent_sdk/_internal/session_store_validation.py +++ b/src/claude_agent_sdk/_internal/session_store_validation.py @@ -6,13 +6,22 @@ def _store_implements(store: SessionStore, method: str) -> bool: - """True if ``store`` overrides ``method`` rather than inheriting the - Protocol default that raises :class:`NotImplementedError`.""" + """True if ``store`` provides ``method`` rather than inheriting the + Protocol default that raises :class:`NotImplementedError`. + + Resolved against the instance rather than the class: ``SessionStore`` is a + structural Protocol, so an implementation assigned in ``__init__`` + (delegation, ``functools.partial``, a test double) counts just as much as a + class-level ``def``. + """ impl = getattr(store, method, None) if impl is None: return False default = getattr(SessionStore, method, None) - return getattr(type(store), method, None) is not default + # Compare the underlying function so a bound method is matched against the + # Protocol default; anything that is not a bound method (a plain callable + # assigned on the instance) is compared directly and is never the default. + return getattr(impl, "__func__", impl) is not default def validate_session_store_options(options: ClaudeAgentOptions) -> None: diff --git a/tests/test_message_parser.py b/tests/test_message_parser.py index e55fd1556..0962d3c6e 100644 --- a/tests/test_message_parser.py +++ b/tests/test_message_parser.py @@ -976,6 +976,13 @@ def test_parse_invalid_data_type(self): assert "Invalid message data type" in str(exc_info.value) assert "expected dict, got str" in str(exc_info.value) + def test_parse_non_dict_message_field(self): + """A non-dict 'message' field raises MessageParseError, not a bare TypeError.""" + for message_type in ("user", "assistant"): + with pytest.raises(MessageParseError) as exc_info: + parse_message({"type": message_type, "message": "not a dict"}) + assert f"Malformed {message_type} message" in str(exc_info.value) + def test_parse_missing_type_field(self): """Test that missing 'type' field raises MessageParseError.""" with pytest.raises(MessageParseError) as exc_info: diff --git a/tests/test_session_store_conformance.py b/tests/test_session_store_conformance.py index 4768c4cdc..441ac86b4 100644 --- a/tests/test_session_store_conformance.py +++ b/tests/test_session_store_conformance.py @@ -178,6 +178,31 @@ def test_continue_conversation_ok_when_store_implements_list_sessions( ) ) + def test_continue_conversation_ok_when_list_sessions_set_on_instance( + self, + ) -> None: + """SessionStore is a structural Protocol, so an implementation assigned + on the instance (delegation, functools.partial, a test double) satisfies + it just as a class-level def does. + """ + + class DelegatingStore(SessionStore): + def __init__(self, inner: SessionStore) -> None: + self.list_sessions = inner.list_sessions + + async def append(self, key, entries): + pass + + async def load(self, key): + return None + + validate_session_store_options( + ClaudeAgentOptions( + session_store=DelegatingStore(InMemorySessionStore()), + continue_conversation=True, + ) + ) + def test_continue_with_resume_and_store_lacking_list_sessions(self) -> None: """Parity with TS: when resume is explicitly set, continue=True should not require list_sessions() — list_sessions is provably