From 3499639624e53110ee339ffcd0344ac42b928f58 Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Sun, 16 Aug 2026 14:13:32 +0530 Subject: [PATCH 1/2] Raise MessageParseError when the message field is not a dict parse_message wraps malformed input in MessageParseError -- non-dict data, a missing type, missing required fields all get the parser's own error type. But a "message" field that is not a dict escaped as a bare TypeError from indexing into it: parse_message({"type": "user", "message": "hi"}) # TypeError: string indices must be integers, not 'str' Same for the assistant branch. The existing handlers only catch KeyError, so TypeError/AttributeError from indexing a non-dict fell through, and a single malformed line from the CLI stream would surface as an unrelated-looking TypeError instead of the documented parse error. Catch TypeError/AttributeError alongside KeyError in both branches and raise MessageParseError with the offending data attached, like every other malformation. --- src/claude_agent_sdk/_internal/message_parser.py | 10 ++++++++++ tests/test_message_parser.py | 7 +++++++ 2 files changed, 17 insertions(+) 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/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: From bee52ef7058c196bcaba6096e58ecfefd90a28d3 Mon Sep 17 00:00:00 2001 From: shashvat-singham Date: Sun, 16 Aug 2026 14:24:25 +0530 Subject: [PATCH 2/2] Resolve session-store method checks against the instance _store_implements looked the method up on type(store), so it only saw class-level definitions. SessionStore is a structural Protocol, though, so an implementation assigned on the instance satisfies it just as well -- and those stores were rejected before the subprocess even spawned: class DelegatingStore(SessionStore): def __init__(self, inner): self.list_sessions = inner.list_sessions validate_session_store_options( ClaudeAgentOptions(session_store=DelegatingStore(inner), continue_conversation=True) ) # ValueError: continue_conversation with session_store requires the # store to implement list_sessions() even though calling list_sessions() on that store works fine. The same applies to a store whose method is a functools.partial, and to a test double patched with AsyncMock -- arguably the most common way to hit this, since it fails only under continue_conversation. Look the attribute up on the instance and compare the underlying function against the Protocol default, so a bound method is still matched against the default while a plain callable assigned on the instance counts as an implementation. --- .../_internal/session_store_validation.py | 15 ++++++++--- tests/test_session_store_conformance.py | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+), 3 deletions(-) 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_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