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
12 changes: 10 additions & 2 deletions python/packages/core/agent_framework/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -1757,9 +1757,17 @@ def text(self) -> str:
"""Returns the text content of the message.

Remarks:
This property concatenates the text of all TextContent objects in Content.
Concatenates the text of every ``TextContent`` in :attr:`contents`
without inserting any separator. Whatever spacing or punctuation
the model produced is already inside the chunks, so the join must
not add to it — inserting a space would inject whitespace mid-token
(a path like ``"report_lines/sofp.json"`` becomes
``"re port_ lines/sof p.jso n"``) and corrupts structured JSON
output downstream. This also matches the separator-free behaviour
of ``_coalesce_text_content`` (via ``Content.__add__``), which is
applied to streamed responses by ``_finalize_response``.
"""
return " ".join(content.text for content in self.contents if content.type == "text") # type: ignore[misc]
return "".join(content.text for content in self.contents if content.type == "text") # type: ignore[misc]
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @copilot — your scope concern is the right thing to flag on first pass. Pushing back on the substance, with line references so a reviewer can verify quickly:

The "natural-language segments get merged" risk doesn't survive contact with the coalescing pipeline. _finalize_response (_types.py:1976-1980) calls _coalesce_text_content (_types.py:1944) on every message after both streaming and non-streaming construction, merging adjacent same-type TextContent blocks into one via Content.__add__ (_types.py:1429) — which is itself already separator-free. So in any caller that reads .text from a finalized response message, the join separator is irrelevant because there's only one element to join. I audited the in-repo consumers (foundry/_memory_provider.py, foundry/_agent.py, gemini/_chat_client.py, redis/_context_provider.py, core/_compaction.py, core/_harness/_memory.py, github_copilot/_agent.py, claude/_agent.py, orchestrations/_concurrent.py, azurefunctions/_workflow.py, durabletask/_shim.py, ag-ui/_client.py, lab/tau2/_message_utils.py) and every one of them reads .text post-finalization.

The separator-free behaviour is what the rest of the codebase already does for streamed content. Any change to make Message.text agree with _coalesce_text_content on multi-TextContent messages is necessarily a switch to "".join — they're the same operation, expressed twice.

Why the narrower .value-only fix is genuinely insufficient, not just stylistically different:

  1. Streaming and non-streaming paths currently disagree on .text. _coalesce_text_content only fires from from_updates / from_update_generator, so the streaming path produces a single coalesced TextContent and .text returns it intact. Non-streaming providers (e.g. openai/_chat_client.py:1921-1928) build a Message directly from N output_text items with no coalescing, so .text was hitting " ".join on a multi-element list. The pre-fix test_workflow_as_agent_yield_output_with_list_of_chat_messages literally asserted ["thirdfourth"] (streaming, line 536) and ["third fourth"] (non-streaming, line 544) on the same logical input — the test itself was a screenshot of the bug. Fixing only .value leaves that disagreement in .text untouched.

  2. .value-only doesn't help anyone reading .text directly. Logging, telemetry, downstream serializers, the ChatResponse.text / __str__ paths (_types.py:2289-2292, 2312), and providers that pre-build messages would all keep producing whitespace-corrupted strings.

  3. The existing .value fallback already routes through Message.text (_parse_structured_response_value(self.text, ...) at _types.py:2308, 2572). A .value-only patch that bypasses self.text introduces a second concatenation pathway that can drift from Message.text's behaviour over time. Fixing the source is structurally simpler.

Updated tests are not regressions, they were the bug specification.

  • test_chat_message_contents previously asserted Message.text == "Hello, how are you? I'm fine, thank you!" for ["Hello, how are you?", "I'm fine, thank you!"] — i.e. it asserted that Message.text would inject a space between two complete sentences. The model never produced that space.
  • test_chat_response_updates_to_chat_response_multiple asserted "I'm doing well, thank you!" (two spaces) for chunks ["I'm doing well, ", "thank you!"] — i.e. the trailing space inside the chunk plus an injected space from " ".join.
  • test_workflow_as_agent_yield_output_with_list_of_chat_messages is the streaming/non-streaming disagreement above.

In each case the new assertion is what the model actually emits.

Real-world impact (already in the PR body): Message.text corruption surfaces in production via OpenRouter→Gemini structured output, where the OpenAI-compatible chat client builds TextContent per output_text item on the non-streaming path and OpenRouter forwards Gemini text in multiple items. Failures show as Field required ... input_value={'action ': ...} (space injected into a JSON key) or Input should be 'not_started', ... input_value='not_started ' (space injected into a value). The .value-only fix would address the parsing call site for these but leave the same corrupted string reaching telemetry, logs, and any downstream .text consumer.

Happy to break the change up if a reviewer prefers the historical sequence (e.g. land Message.text first, then a follow-up that drops the now-redundant raw-text walk if _parse_structured_response_value is later refactored), but the substantive position is that fixing Message.text is the root cause and the smaller surface area in the long run.



AgentRunInputs = str | Content | Message | Sequence[str | Content | Message]
Expand Down
42 changes: 38 additions & 4 deletions python/packages/core/tests/core/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -733,7 +733,7 @@ def test_chat_message_text():
def test_chat_message_contents():
"""Test the Message class to ensure it initializes correctly with contents."""
# Create a Message with a role and multiple contents
content1 = Content.from_text("Hello, how are you?")
content1 = Content.from_text("Hello, how are you? ")
content2 = Content.from_text("I'm fine, thank you!")
message = Message(role="user", contents=[content1, content2])

Expand All @@ -742,11 +742,40 @@ def test_chat_message_contents():
assert len(message.contents) == 2
assert message.contents[0].type == "text"
assert message.contents[1].type == "text"
assert message.contents[0].text == "Hello, how are you?"
assert message.contents[0].text == "Hello, how are you? "
assert message.contents[1].text == "I'm fine, thank you!"
# Message.text concatenates without inserting whitespace — any spacing the
# model emitted is already inside the chunks.
assert message.text == "Hello, how are you? I'm fine, thank you!"


def test_chat_message_text_concatenates_without_injecting_spaces():
"""``Message.text`` must concatenate streamed text chunks with no separator.

When chat responses are streamed the SDK accumulates text deltas as
multiple ``TextContent`` blocks. Joining them with a space mid-token
corrupts JSON values used for structured output (a path like
``"report_lines/sofp.json"`` becomes ``"re port_ lines/sof p.jso n"``)
and breaks any downstream consumer that compares ``message.text`` to a
raw model output (logging, telemetry, structured-output parsers).
"""
# Simulate a JSON value split across three streaming deltas — exactly the
# shape ``response.value`` and ``response.text`` see when chunks have not
# been coalesced.
chunks = [
Content.from_text(text='{"resp'),
Content.from_text(text='onse": "He'),
Content.from_text(text='llo"}'),
]
message = Message(role="assistant", contents=chunks)

assert message.text == '{"response": "Hello"}'
# And via ``ChatResponse.value``, the structured-output parse round-trips.
response = ChatResponse(messages=message, response_format=OutputModel)
assert response.value is not None
assert response.value.response == "Hello"


def test_chat_message_with_chatrole_instance():
m = Message(role="user", contents=["hi"])
assert m.role == "user"
Expand Down Expand Up @@ -989,7 +1018,10 @@ def test_chat_response_updates_to_chat_response_multiple():

# Check the type and content
assert len(chat_response.messages) == 1
assert chat_response.text == "I'm doing well, thank you!"
# Each chunk contributes its own whitespace; Message.text concatenates without
# injecting an extra separator, so the trailing space inside ``message1`` is
# the only space between the two segments.
assert chat_response.text == "I'm doing well, thank you!"
assert isinstance(chat_response.messages[0], Message)
assert len(chat_response.messages[0].contents) == 3
assert chat_response.messages[0].message_id == "1"
Expand Down Expand Up @@ -1027,7 +1059,9 @@ def test_chat_response_updates_to_chat_response_multiple_multiple():
assert chat_response.messages[0].contents[2].type == "text"
assert chat_response.messages[0].contents[2].text == "More contextFinal part"

assert chat_response.text == "I'm doing well, thank you! More contextFinal part"
# Message.text skips the reasoning content and concatenates the surviving
# text contents directly — no extra separator is inserted between them.
assert chat_response.text == "I'm doing well, thank you!More contextFinal part"


async def test_chat_response_from_async_generator():
Expand Down
5 changes: 4 additions & 1 deletion python/packages/core/tests/workflow/test_workflow_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,10 @@ async def list_yielding_executor(messages: list[Message], ctx: WorkflowContext[N
assert isinstance(result, AgentResponse)
assert len(result.messages) == 3
texts = [message.text for message in result.messages]
assert texts == ["first message", "second message", "third fourth"]
# Non-streaming path keeps the two TextContent blocks separate; Message.text
# concatenates them without inserting a separator (matching the streaming
# path above where coalescing produces the same "thirdfourth").
assert texts == ["first message", "second message", "thirdfourth"]

async def test_session_conversation_history_included_in_workflow_run(self) -> None:
"""Test that messages provided to agent.run() are passed through to the workflow."""
Expand Down