From d43004782157ad5ab2247680d7880a70896464d9 Mon Sep 17 00:00:00 2001 From: "Michael J. Sullivan" Date: Wed, 28 Oct 2026 12:19:45 -0700 Subject: [PATCH] Make UIMessage dumping match ai sdk better The fields out to get the camelCase aliases, and also apparently we want to exclude null fields, since the proper format for TS expects them to be missing instead. Annoyingly exclude_none can't be configured in ConfigDict, so in part we just document that. --- src/ai/ui/ai_sdk/outbound_messages.py | 3 +- src/ai/ui/ai_sdk/ui_messages.py | 8 +++++- tests/ui/ai_sdk/test_outbound_messages.py | 35 +++++++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/ai/ui/ai_sdk/outbound_messages.py b/src/ai/ui/ai_sdk/outbound_messages.py index d6055555..af22767a 100644 --- a/src/ai/ui/ai_sdk/outbound_messages.py +++ b/src/ai/ui/ai_sdk/outbound_messages.py @@ -118,7 +118,8 @@ def bundle_to_wire_output(bundle: messages_.MessageBundle) -> Any: The inbound side accepts either shape (see ``_build_result_part``). """ dumped = [ - m.model_dump(mode="json") for m in to_ui_messages(list(bundle.messages)) + m.model_dump(mode="json", exclude_none=True) + for m in to_ui_messages(list(bundle.messages)) ] if not dumped: return None diff --git a/src/ai/ui/ai_sdk/ui_messages.py b/src/ai/ui/ai_sdk/ui_messages.py index 20d905fc..19a0a105 100644 --- a/src/ai/ui/ai_sdk/ui_messages.py +++ b/src/ai/ui/ai_sdk/ui_messages.py @@ -14,7 +14,9 @@ from ...types import messages as messages_ -_UI_MODEL_CONFIG = pydantic.ConfigDict(populate_by_name=True, extra="allow") +_UI_MODEL_CONFIG = pydantic.ConfigDict( + populate_by_name=True, extra="allow", serialize_by_alias=True +) class UITextPart(pydantic.BaseModel): @@ -330,6 +332,10 @@ class UIMessage(pydantic.BaseModel): """Message in AI SDK v6 format. Reference: https://ai-sdk.dev/docs/reference/ai-sdk-core/ui-message + + To send one to an AI SDK client, dump it with + ``model_dump(mode="json", exclude_none=True)``: the TS schemas mark + optional fields ``.optional()``, which rejects ``null``. """ model_config = _UI_MODEL_CONFIG diff --git a/tests/ui/ai_sdk/test_outbound_messages.py b/tests/ui/ai_sdk/test_outbound_messages.py index e83be7a1..09cec9f2 100644 --- a/tests/ui/ai_sdk/test_outbound_messages.py +++ b/tests/ui/ai_sdk/test_outbound_messages.py @@ -666,3 +666,38 @@ def test_deferred_approval_hook_roundtrips_tool_call_id() -> None: assert hook.hook_id == hook_id assert hook.status == "pending" assert hook.tool_call_id == "tc-1" + + +def test_bundle_to_wire_output_uses_wire_aliases() -> None: + bundle = messages_.MessageBundle( + messages=( + messages_.Message( + role="assistant", + parts=[ + messages_.ToolCallPart( + tool_call_id="tc1", tool_name="bash", tool_args="{}" + ), + messages_.FilePart(data=b"png", media_type="image/png"), + ], + ), + messages_.Message( + role="tool", + parts=[ + messages_.ToolResultPart( + tool_call_id="tc1", + tool_name="bash", + result="boom", + result_kind="error", + ) + ], + ), + ) + ) + + wire = outbound_messages.bundle_to_wire_output(bundle) + + tool_part, file_part = wire["parts"] + assert tool_part["toolCallId"] == "tc1" + assert tool_part["errorText"] == "boom" + assert file_part["mediaType"] == "image/png" + assert "providerExecuted" not in tool_part