mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 23:13:58 +08:00
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:
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
Reference in New Issue
Block a user