From 3dd7d10a8b8bcc17cb9d3859b01415b98c61963d Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Wed, 23 Sep 2026 21:08:41 +0530 Subject: [PATCH 1/3] Python: fix(core): treat a missing text delta as empty when adding text content `text` is optional on a text content, but `_add_text_content` concatenated `self.text + other.text` directly, so a part with no text raised `TypeError: can only concatenate str (not "NoneType") to str`. This is reachable from the public API: `_coalesce_text_content` merges consecutive text contents with `+`, so one such part aborts the whole response. ChatResponse.from_updates([ ChatResponseUpdate(role="assistant", contents=[Content("text", text="Hello ")]), ChatResponseUpdate(role="assistant", contents=[Content("text")]), ChatResponseUpdate(role="assistant", contents=[Content("text", text="world")]), ]) # TypeError The same stream built from `text_reasoning` parts already worked, because `_add_text_reasoning_content` does handle it. This makes the two agree: a missing delta counts as empty, and `None` survives only when neither side has text, so it stays distinguishable from a real empty string. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WyCUJcDPnCnQMmuDwsnDmD --- .../packages/core/agent_framework/_types.py | 11 ++++- python/packages/core/tests/core/test_types.py | 46 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 17dba475e3e..0cb5ce06d8a 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -1581,9 +1581,18 @@ def __add__(self, other: Content) -> Content: def _add_text_content(self, other: Content) -> Content: """Add two TextContent instances.""" + # `text` is optional on a text content, and a provider can stream one that + # carries only annotations or metadata. Concatenating the raw attributes + # raised TypeError and took the whole response down during coalescing, so + # treat a missing delta as empty. `None` is preserved when neither side + # has text, the same way `_add_text_reasoning_content` does it, so an + # absent value stays distinguishable from a real empty string. + self_text = self.text or "" + other_text = other.text or "" + combined_text = None if self.text is None and other.text is None else self_text + other_text return Content( "text", - text=self.text + other.text, # type: ignore[attr-defined, operator] + text=combined_text, annotations=_combine_annotations(self.annotations, other.annotations), additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties), raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation), diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index a6797b30449..b6b22ecab6e 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2235,6 +2235,52 @@ def test_text_content_iadd_coverage(): assert t1.additional_properties == {"key1": "val1", "key2": "val2"} +def test_text_content_add_handles_missing_text() -> None: + """A text content may carry no text at all; adding one must not raise. + + `text` is optional on a text content -- `Content.from_dict({"type": "text"})` + produces one, and a provider can stream a text part that carries only + annotations or metadata. Concatenating the raw attributes raised + `TypeError: can only concatenate str (not "NoneType") to str`. + """ + with_text = Content("text", text="Hello") + without_text = Content("text") + + assert (with_text + without_text).text == "Hello" + assert (without_text + with_text).text == "Hello" + + +def test_text_content_add_preserves_none_and_empty_string() -> None: + """`None` means no text, `""` means an empty one, and adding keeps them apart. + + Same rule `_add_text_reasoning_content` follows, so the two content types do + not disagree about what an absent value means. + """ + assert (Content("text") + Content("text")).text is None + assert (Content("text", text="") + Content("text")).text == "" + assert (Content("text") + Content("text", text="")).text == "" + + +def test_chat_response_from_updates_coalesces_text_update_without_text() -> None: + """The reachable path: coalescing a stream that contains a text part with no delta. + + `_coalesce_text_content` merges consecutive text contents with `+`, so one + such part used to abort the whole response. The identical stream built from + `text_reasoning` parts already worked, which is the asymmetry being fixed. + """ + + def updates(content_type: str) -> list[ChatResponseUpdate]: + return [ + ChatResponseUpdate(role="assistant", contents=[Content(content_type, text="Hello ")]), + ChatResponseUpdate(role="assistant", contents=[Content(content_type)]), + ChatResponseUpdate(role="assistant", contents=[Content(content_type, text="world")]), + ] + + for content_type in ("text", "text_reasoning"): + response = ChatResponse.from_updates(updates(content_type)) + assert [content.text for content in response.messages[0].contents] == ["Hello world"] + + def test_text_reasoning_content_add_coverage(): """Test TextReasoningContent __add__ method for better coverage.""" From 4b9259a9e633287717e9efe135398d9e04fb6255 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Thu, 24 Sep 2026 17:28:51 +0530 Subject: [PATCH 2/3] Type the content type in the coalescing test as a Literal --- python/packages/core/tests/core/test_types.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index b6b22ecab6e..cf40aff3db8 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2269,14 +2269,15 @@ def test_chat_response_from_updates_coalesces_text_update_without_text() -> None `text_reasoning` parts already worked, which is the asymmetry being fixed. """ - def updates(content_type: str) -> list[ChatResponseUpdate]: + def updates(content_type: Literal["text", "text_reasoning"]) -> list[ChatResponseUpdate]: return [ ChatResponseUpdate(role="assistant", contents=[Content(content_type, text="Hello ")]), ChatResponseUpdate(role="assistant", contents=[Content(content_type)]), ChatResponseUpdate(role="assistant", contents=[Content(content_type, text="world")]), ] - for content_type in ("text", "text_reasoning"): + content_types: tuple[Literal["text", "text_reasoning"], ...] = ("text", "text_reasoning") + for content_type in content_types: response = ChatResponse.from_updates(updates(content_type)) assert [content.text for content in response.messages[0].contents] == ["Hello world"] From 29a9c5959fe0fde93e9a915d69068e5c6e0de883 Mon Sep 17 00:00:00 2001 From: Anish Mehta Date: Tue, 29 Sep 2026 10:13:53 +0530 Subject: [PATCH 3/3] Normalize missing text to an empty string in Content.__init__ --- .../packages/core/agent_framework/_types.py | 16 +++-------- python/packages/core/tests/core/test_types.py | 28 +++++++++++++------ 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/python/packages/core/agent_framework/_types.py b/python/packages/core/agent_framework/_types.py index 0cb5ce06d8a..5f3bdd7e3b7 100644 --- a/python/packages/core/agent_framework/_types.py +++ b/python/packages/core/agent_framework/_types.py @@ -572,8 +572,9 @@ def __init__( ) self.raw_representation = raw_representation - # Set all content-specific attributes - self.text = text + # Set all content-specific attributes. A text content always carries a string, so + # concatenation and `Message.text` never see None. + self.text = (text or "") if type == "text" else text self.protected_data = protected_data self.uri = uri self.media_type = media_type @@ -1581,18 +1582,9 @@ def __add__(self, other: Content) -> Content: def _add_text_content(self, other: Content) -> Content: """Add two TextContent instances.""" - # `text` is optional on a text content, and a provider can stream one that - # carries only annotations or metadata. Concatenating the raw attributes - # raised TypeError and took the whole response down during coalescing, so - # treat a missing delta as empty. `None` is preserved when neither side - # has text, the same way `_add_text_reasoning_content` does it, so an - # absent value stays distinguishable from a real empty string. - self_text = self.text or "" - other_text = other.text or "" - combined_text = None if self.text is None and other.text is None else self_text + other_text return Content( "text", - text=combined_text, + text=self.text + other.text, # type: ignore[attr-defined, operator] annotations=_combine_annotations(self.annotations, other.annotations), additional_properties=_combine_additional_props(self.additional_properties, other.additional_properties), raw_representation=_combine_raw_representations(self.raw_representation, other.raw_representation), diff --git a/python/packages/core/tests/core/test_types.py b/python/packages/core/tests/core/test_types.py index cf40aff3db8..bce159cbb84 100644 --- a/python/packages/core/tests/core/test_types.py +++ b/python/packages/core/tests/core/test_types.py @@ -2250,15 +2250,27 @@ def test_text_content_add_handles_missing_text() -> None: assert (without_text + with_text).text == "Hello" -def test_text_content_add_preserves_none_and_empty_string() -> None: - """`None` means no text, `""` means an empty one, and adding keeps them apart. +def test_text_content_without_text_stores_empty_string() -> None: + """A text content built without text stores "" so every consumer can treat it as a string.""" + assert Content("text").text == "" + assert Content("text", text=None).text == "" + assert Content.from_dict({"type": "text"}).text == "" + assert (Content("text") + Content("text")).text == "" - Same rule `_add_text_reasoning_content` follows, so the two content types do - not disagree about what an absent value means. - """ - assert (Content("text") + Content("text")).text is None - assert (Content("text", text="") + Content("text")).text == "" - assert (Content("text") + Content("text", text="")).text == "" + # Other content types keep None for a missing text. + assert Content("text_reasoning").text is None + + +def test_message_and_response_text_with_text_content_missing_text() -> None: + """The public `.text` accessors join content text directly and must not raise.""" + message = Message(role="assistant", contents=[Content("text")]) + assert message.text == "" + + response = ChatResponse(messages=[Message(role="assistant", contents=[Content("text")])]) + assert response.text == "" + + update = ChatResponseUpdate(role="assistant", contents=[Content("text")]) + assert update.text == "" def test_chat_response_from_updates_coalesces_text_update_without_text() -> None: