From d1cb8c22a8e78e0c8b8fbce5d5ce06f7c141a8db Mon Sep 17 00:00:00 2001 From: ogabrielluiz Date: Thu, 28 May 2026 20:03:48 -0300 Subject: [PATCH] fix(schema): MessageResponse parses microsecond timestamps and ContentBlock partial updates preserve unset fields Two schema regressions surfaced in QA across the content-blocks chain: 1. MessageResponse.timestamp was typed as a bare datetime, but Message.timestamp default is a string with microsecond precision and a UTC timezone label ('%Y-%m-%d %H:%M:%S.%f %Z') that Pydantic's default datetime parser rejects. Any freshly built Message routed through MessageResponse.from_message raised ValidationError. Reuse the shared str_to_timestamp_validator so MessageResponse accepts every format Message itself recognises. 2. ContentBlock.__init__ marked every field as model_fields_set, not just the discriminator. The override defeated exclude_unset for the group content type: a patch like ContentBlock(title='new') dumped every defaulted field and, when merged onto an existing block by aupdate_messages, overwrote fields the caller never touched. Mark only 'type' (the discriminator) so partial updates carry the variant tag without clobbering the rest. Adds regression tests in test_message_content_blocks.py: from_message round-trips Message.timestamp without crashing, and ContentBlock exclude_unset stays narrow to the explicit fields plus the discriminator. (cherry picked from commit 6f6639374faf52bfd7d0041a3fa3aec7a1c26a8a) --- src/lfx/src/lfx/schema/content_types.py | 14 +++-- src/lfx/src/lfx/schema/message.py | 10 +++- .../schema/test_message_content_blocks.py | 55 +++++++++++++++++++ 3 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/lfx/src/lfx/schema/content_types.py b/src/lfx/src/lfx/schema/content_types.py index 9f8ee47f00..2f923453d6 100644 --- a/src/lfx/src/lfx/schema/content_types.py +++ b/src/lfx/src/lfx/schema/content_types.py @@ -262,11 +262,15 @@ class ContentBlock(BaseContent): def __init__(self, **data) -> None: super().__init__(**data) - # Mark every field as "set" so legacy callers iterating - # ``model_fields_set`` (and ``model_dump(exclude_unset=True)``) see - # the full ContentBlock shape, including the ``type="group"`` - # discriminator that downstream validators depend on. - self.model_fields_set.update(type(self).model_fields) + # Mark only the discriminator as "set" so partial-update callers + # using ``model_dump(exclude_unset=True)`` (notably + # ``aupdate_messages``) still carry the ``type="group"`` field + # downstream. Other defaulted fields stay unset so true + # exclude_unset semantics survive: a patch like + # ``ContentBlock(title="...")`` no longer overwrites an existing + # block's ``duration`` / ``header`` / ``contents`` with their + # defaults on merge. + self.model_fields_set.add("type") @field_validator("contents", mode="before") @classmethod diff --git a/src/lfx/src/lfx/schema/message.py b/src/lfx/src/lfx/schema/message.py index 268523d57f..f6df1aef01 100644 --- a/src/lfx/src/lfx/schema/message.py +++ b/src/lfx/src/lfx/schema/message.py @@ -37,7 +37,7 @@ from lfx.schema.content_types import ErrorContent, TextContent from lfx.schema.data import Data from lfx.schema.image import Image, get_file_paths, is_image_file from lfx.schema.properties import Properties, Source -from lfx.schema.validators import timestamp_to_str, timestamp_to_str_validator +from lfx.schema.validators import str_to_timestamp_validator, timestamp_to_str, timestamp_to_str_validator from lfx.utils.constants import MESSAGE_SENDER_AI, MESSAGE_SENDER_NAME_AI, MESSAGE_SENDER_NAME_USER, MESSAGE_SENDER_USER from lfx.utils.image import create_image_content_dict from lfx.utils.mustache_security import safe_mustache_render @@ -725,7 +725,13 @@ class DefaultModel(BaseModel): class MessageResponse(DefaultModel): id: str | UUID | None = Field(default=None) flow_id: UUID | None = Field(default=None) - timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) + # ``Message.timestamp`` is a string with microsecond+timezone precision + # (``%Y-%m-%d %H:%M:%S.%f %Z``) which Pydantic's default datetime parser + # rejects. Reuse the shared parser so MessageResponse.from_message + # accepts any of the formats ``Message`` itself recognises. + timestamp: Annotated[datetime, str_to_timestamp_validator] = Field( + default_factory=lambda: datetime.now(timezone.utc) + ) sender: str sender_name: str session_id: str diff --git a/src/lfx/tests/unit/schema/test_message_content_blocks.py b/src/lfx/tests/unit/schema/test_message_content_blocks.py index b5a75c6e26..20d28c2a16 100644 --- a/src/lfx/tests/unit/schema/test_message_content_blocks.py +++ b/src/lfx/tests/unit/schema/test_message_content_blocks.py @@ -489,3 +489,58 @@ class TestFromLcMessageToolCallId: tool_blocks = [b for b in msg.content_blocks if isinstance(b, ToolContent)] assert [b.text for b in text_blocks] == ["I'll search for that."] assert [b.id for b in tool_blocks] == ["call_abc"] + + +class TestMessageResponseFromMessage: + """Regression tests for ``MessageResponse.from_message`` timestamp parity. + + ``Message.timestamp`` is a string with microsecond precision and a UTC + timezone label (``2026-05-28 19:41:56.196419 UTC``). Pydantic's default + datetime parser rejects that shape, so freshly built Messages used to + raise ``ValidationError`` when round-tripped through + ``MessageResponse.from_message``. The field now uses the shared + ``str_to_timestamp_validator`` so any format ``Message`` recognises also + round-trips through ``MessageResponse``. + """ + + def test_from_message_round_trips_microsecond_timestamp(self): + from lfx.schema.message import Message, MessageResponse + + msg = Message(sender="AI", sender_name="Bot", text="hi") + # If the default format ever drifts, this test should still catch + # the regression because Message.timestamp is fed straight into + # MessageResponse.timestamp during ``from_message``. + assert " UTC" in msg.timestamp + + response = MessageResponse.from_message(msg) + # Naive equality against the source timestamp would require a + # round-trip serialization; just confirm we got a real datetime + # back and that it's tz-aware (UTC). + assert response.timestamp.tzinfo is not None + assert response.timestamp.utcoffset().total_seconds() == 0 + + +class TestContentBlockExcludeUnset: + """Regression test for ``ContentBlock.__init__`` only marking the discriminator as set. + + The previous override marked every field as ``model_fields_set``, which + defeated ``model_dump(exclude_unset=True)`` for ``ContentBlock``: a patch + like ``ContentBlock(title="...")`` would dump every default field and, + when merged onto an existing block by ``aupdate_messages``, would + overwrite fields the caller never touched (e.g. ``duration``). + """ + + def test_exclude_unset_only_carries_explicit_fields(self): + from lfx.schema.content_types import ContentBlock + + patch = ContentBlock(title="new", allow_markdown=False) + dump = patch.model_dump(exclude_unset=True) + # The discriminator must survive so downstream validators that + # consume the partial dict still pick the right ContentType + # variant. + assert dump["type"] == "group" + # Only the explicitly-set fields (plus the discriminator) ride + # along. Defaulted fields like ``duration``, ``contents``, + # ``header``, ``media_url`` must stay out of the dump so they + # don't clobber existing values on merge. + assert dump.keys() <= {"type", "title", "allow_markdown"}