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 6f6639374f)
This commit is contained in:
ogabrielluiz
2026-05-28 20:03:48 -03:00
parent 09a748519f
commit d1cb8c22a8
3 changed files with 72 additions and 7 deletions

View File

@ -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

View File

@ -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

View File

@ -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"}